From e6953cc1c966ee21fa7770c4fc8fff65668c32c4 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 15 Nov 2026 17:22:42 +0300 Subject: [PATCH] feat(frontend): Glide grid edits with staging dirty markers Wire cell edits into a Pinia StagingBuffer, highlight modified/deleted/inserted rows in Glide, and refresh the baseline when query results land. Closes #1890 --- apps/tauri/src/staging/buffer.test.ts | 44 +++++ apps/tauri/src/staging/buffer.ts | 206 ++++++++++++++++++++ apps/tauri/src/stores/gridStaging.test.ts | 40 ++++ apps/tauri/src/stores/gridStaging.ts | 77 ++++++++ apps/tauri/src/stores/queryResult.ts | 19 +- apps/tauri/src/ui/ResultGrid.vue | 43 +++- apps/tauri/src/ui/glide/ResultDataEditor.ts | 101 +++++++++- 7 files changed, 514 insertions(+), 16 deletions(-) create mode 100644 apps/tauri/src/staging/buffer.test.ts create mode 100644 apps/tauri/src/staging/buffer.ts create mode 100644 apps/tauri/src/stores/gridStaging.test.ts create mode 100644 apps/tauri/src/stores/gridStaging.ts diff --git a/apps/tauri/src/staging/buffer.test.ts b/apps/tauri/src/staging/buffer.test.ts new file mode 100644 index 00000000..77564c4a --- /dev/null +++ b/apps/tauri/src/staging/buffer.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { NULL_SENTINEL, StagingBuffer } from "./buffer"; + +describe("StagingBuffer (frontend)", () => { + function sample() { + return new StagingBuffer( + ["id", "name", "email"], + [ + ["1", "Alice", "alice@test.com"], + ["2", "Bob", "bob@test.com"], + ], + ["id"], + ); + } + + it("starts clean", () => { + const buf = sample(); + expect(buf.isDirty()).toBe(false); + expect(buf.cellStatus(0, 1)).toBe("clean"); + expect(buf.getCellValue(0, 1)).toBe("Alice"); + }); + + it("marks cell dirty and clears when reverted to original", () => { + const buf = sample(); + buf.setCell(0, 1, "Alice Cooper"); + expect(buf.isDirty()).toBe(true); + expect(buf.cellStatus(0, 1)).toBe("modified"); + expect(buf.rowStatus(0)).toBe("modified"); + expect(buf.getCellValue(0, 1)).toBe("Alice Cooper"); + buf.setCell(0, 1, "Alice"); + expect(buf.isDirty()).toBe(false); + expect(buf.cellStatus(0, 1)).toBe("clean"); + }); + + it("toggle delete and null sentinel", () => { + const buf = sample(); + buf.toggleDeleteRow(1); + expect(buf.rowStatus(1)).toBe("deleted"); + buf.setCellNull(0, 2); + expect(buf.getCellValue(0, 2)).toBe("NULL"); + expect(buf.effectiveRows()[0]?.[2]).toBe("NULL"); + expect(NULL_SENTINEL).toContain("QUERYA_NULL"); + }); +}); diff --git a/apps/tauri/src/staging/buffer.ts b/apps/tauri/src/staging/buffer.ts new file mode 100644 index 00000000..c2bf0cbf --- /dev/null +++ b/apps/tauri/src/staging/buffer.ts @@ -0,0 +1,206 @@ +/** Frontend staging buffer — Flutter / Rust `StagingBuffer` parity (cell layer). */ + +export const NULL_SENTINEL = "\u0000__QUERYA_NULL__\u0000"; + +export type StagedRowStatus = + | "unchanged" + | "modified" + | "inserted" + | "deleted"; + +export type StagedCellStatus = "clean" | "modified"; + +export class StagingBuffer { + private columns: string[]; + private originalRows: string[][]; + private primaryKeys: string[]; + private modifiedCells = new Map>(); + private insertedRows: string[][] = []; + private deletedRowIndices = new Set(); + private revision = 0; + + constructor( + columns: string[], + rows: string[][], + primaryKeys: string[] = [], + ) { + this.columns = [...columns]; + this.originalRows = rows.map((r) => [...r]); + this.primaryKeys = [...primaryKeys]; + } + + /** Replace baseline (e.g. after a new query) and clear staged edits. */ + replaceBaseline( + columns: string[], + rows: string[][], + primaryKeys: string[] = [], + ): void { + this.columns = [...columns]; + this.originalRows = rows.map((r) => [...r]); + this.primaryKeys = [...primaryKeys]; + this.modifiedCells.clear(); + this.insertedRows = []; + this.deletedRowIndices.clear(); + this.revision += 1; + } + + getRevision(): number { + return this.revision; + } + + getColumns(): string[] { + return this.columns; + } + + getPrimaryKeys(): string[] { + return this.primaryKeys; + } + + totalRowCount(): number { + return this.originalRows.length + this.insertedRows.length; + } + + isDirty(): boolean { + return ( + this.modifiedCells.size > 0 || + this.insertedRows.length > 0 || + this.deletedRowIndices.size > 0 + ); + } + + changeCount(): number { + let cells = 0; + for (const m of this.modifiedCells.values()) cells += m.size; + return cells + this.insertedRows.length + this.deletedRowIndices.size; + } + + getCellValue(row: number, col: number): string { + if (row < this.originalRows.length) { + const staged = this.modifiedCells.get(row)?.get(col); + if (staged !== undefined) return sanitizeNull(staged); + return this.originalRows[row]?.[col] ?? ""; + } + const insertIdx = row - this.originalRows.length; + return sanitizeNull(this.insertedRows[insertIdx]?.[col] ?? ""); + } + + rowStatus(row: number): StagedRowStatus { + if (row >= this.originalRows.length) { + return row < this.totalRowCount() ? "inserted" : "unchanged"; + } + if (this.deletedRowIndices.has(row)) return "deleted"; + const mods = this.modifiedCells.get(row); + if (mods && mods.size > 0) return "modified"; + return "unchanged"; + } + + cellStatus(row: number, col: number): StagedCellStatus { + if ( + row < this.originalRows.length && + this.modifiedCells.get(row)?.has(col) + ) { + return "modified"; + } + return "clean"; + } + + setCell(row: number, col: number, value: string): void { + if (row < 0 || col < 0) return; + if (row < this.originalRows.length) { + const orig = this.originalRows[row]?.[col] ?? ""; + if (value === orig) { + const map = this.modifiedCells.get(row); + if (map) { + map.delete(col); + if (map.size === 0) this.modifiedCells.delete(row); + this.revision += 1; + } + } else { + let map = this.modifiedCells.get(row); + if (!map) { + map = new Map(); + this.modifiedCells.set(row, map); + } + if (map.get(col) !== value) { + map.set(col, value); + this.revision += 1; + } + } + return; + } + const insertIdx = row - this.originalRows.length; + const ins = this.insertedRows[insertIdx]; + if (!ins) return; + while (ins.length <= col) ins.push(""); + if (ins[col] !== value) { + ins[col] = value; + this.revision += 1; + } + } + + setCellNull(row: number, col: number): void { + this.setCell(row, col, NULL_SENTINEL); + } + + toggleDeleteRow(row: number): void { + if (row < 0) return; + if (row < this.originalRows.length) { + if (this.deletedRowIndices.has(row)) this.deletedRowIndices.delete(row); + else this.deletedRowIndices.add(row); + this.revision += 1; + } else { + const insertIdx = row - this.originalRows.length; + if (insertIdx >= 0 && insertIdx < this.insertedRows.length) { + this.insertedRows.splice(insertIdx, 1); + this.revision += 1; + } + } + } + + revertAll(): void { + if (!this.isDirty()) return; + this.modifiedCells.clear(); + this.insertedRows = []; + this.deletedRowIndices.clear(); + this.revision += 1; + } + + /** Effective grid values (deleted rows kept for strike-through). */ + effectiveRows(): string[][] { + const out: string[][] = []; + for (let r = 0; r < this.originalRows.length; r++) { + const mods = this.modifiedCells.get(r); + if (mods) { + const row = [...(this.originalRows[r] ?? [])]; + for (const [c, v] of mods) { + if (c < row.length) row[c] = sanitizeNull(v); + } + out.push(row); + } else { + out.push([...(this.originalRows[r] ?? [])]); + } + } + for (const ins of this.insertedRows) { + out.push(ins.map(sanitizeNull)); + } + return out; + } +} + +function sanitizeNull(value: string): string { + return value === NULL_SENTINEL ? "NULL" : value; +} + +/** Coerce query result cells to staging strings. */ +export function rowsToStrings(rows: unknown[][]): string[][] { + return rows.map((row) => + row.map((cell) => { + if (cell === null || cell === undefined) return "NULL"; + if (typeof cell === "string") return cell; + if (typeof cell === "number" || typeof cell === "boolean") { + return String(cell); + } + return JSON.stringify(cell); + }), + ); +} diff --git a/apps/tauri/src/stores/gridStaging.test.ts b/apps/tauri/src/stores/gridStaging.test.ts new file mode 100644 index 00000000..5d54550b --- /dev/null +++ b/apps/tauri/src/stores/gridStaging.test.ts @@ -0,0 +1,40 @@ +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it } from "vitest"; +import { useGridStagingStore } from "./gridStaging"; + +describe("useGridStagingStore", () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + it("attaches baseline and tracks dirty cell markers", () => { + const store = useGridStagingStore(); + store.attachFromQuery( + ["id", "name"], + [ + ["1", "Alice"], + ["2", "Bob"], + ], + ); + expect(store.isDirty).toBe(false); + expect(store.cellStatus(0, 1)).toBe("clean"); + + store.setCell(0, 1, "Alice Cooper"); + expect(store.isDirty).toBe(true); + expect(store.changeCount).toBe(1); + expect(store.cellStatus(0, 1)).toBe("modified"); + expect(store.rowStatus(0)).toBe("modified"); + expect(store.getCellValue(0, 1)).toBe("Alice Cooper"); + expect(store.effectiveRows()[0]?.[1]).toBe("Alice Cooper"); + }); + + it("replaceBaseline clears prior edits", () => { + const store = useGridStagingStore(); + store.attachFromQuery(["id"], [["1"]]); + store.setCell(0, 0, "9"); + expect(store.isDirty).toBe(true); + store.attachFromQuery(["id"], [["2"]]); + expect(store.isDirty).toBe(false); + expect(store.getCellValue(0, 0)).toBe("2"); + }); +}); diff --git a/apps/tauri/src/stores/gridStaging.ts b/apps/tauri/src/stores/gridStaging.ts new file mode 100644 index 00000000..51aac152 --- /dev/null +++ b/apps/tauri/src/stores/gridStaging.ts @@ -0,0 +1,77 @@ +import { defineStore } from "pinia"; +import { computed, ref } from "vue"; +import { rowsToStrings, StagingBuffer } from "../staging/buffer"; + +/** + * Pinia façade over {@link StagingBuffer} for the result grid. + * Apply / discard IPC lands in #1891; this store owns dirty markers (#1890). + */ +export const useGridStagingStore = defineStore("gridStaging", () => { + const buffer = ref(null); + /** Mirrors buffer revision so Vue / Glide can watch cheaply. */ + const revision = ref(0); + + const isDirty = computed(() => buffer.value?.isDirty() ?? false); + const changeCount = computed(() => buffer.value?.changeCount() ?? 0); + const rowCount = computed(() => buffer.value?.totalRowCount() ?? 0); + + function bump() { + revision.value = buffer.value?.getRevision() ?? 0; + } + + function attachFromQuery(columns: string[], rows: unknown[][]) { + const stringRows = rowsToStrings(rows); + if (!buffer.value) { + buffer.value = new StagingBuffer(columns, stringRows); + } else { + buffer.value.replaceBaseline(columns, stringRows); + } + bump(); + } + + function clear() { + buffer.value = null; + revision.value = 0; + } + + function setCell(row: number, col: number, value: string) { + buffer.value?.setCell(row, col, value); + bump(); + } + + function cellStatus(row: number, col: number) { + return buffer.value?.cellStatus(row, col) ?? "clean"; + } + + function rowStatus(row: number) { + return buffer.value?.rowStatus(row) ?? "unchanged"; + } + + function getCellValue(row: number, col: number) { + return buffer.value?.getCellValue(row, col) ?? ""; + } + + function effectiveRows(): string[][] { + return buffer.value?.effectiveRows() ?? []; + } + + function revertAll() { + buffer.value?.revertAll(); + bump(); + } + + return { + revision, + isDirty, + changeCount, + rowCount, + attachFromQuery, + clear, + setCell, + cellStatus, + rowStatus, + getCellValue, + effectiveRows, + revertAll, + }; +}); diff --git a/apps/tauri/src/stores/queryResult.ts b/apps/tauri/src/stores/queryResult.ts index bbfafb4e..a42b4031 100644 --- a/apps/tauri/src/stores/queryResult.ts +++ b/apps/tauri/src/stores/queryResult.ts @@ -10,6 +10,7 @@ import { formatConnectionError, } from "../api/connection"; import { useConnectionsStore } from "./connections"; +import { useGridStagingStore } from "./gridStaging"; import { usePreferencesStore } from "./preferences"; import { useShellNavStore } from "./shellNav"; import { useShellStatusStore } from "./shellStatus"; @@ -47,10 +48,20 @@ export const useQueryResultStore = defineStore("queryResult", () => { error.value = acc.error; } + function syncStagingBaseline() { + const staging = useGridStagingStore(); + if (acc.columns.length === 0) { + staging.clear(); + return; + } + staging.attachFromQuery(acc.columns, acc.rows); + } + function reset() { acc = emptyAccumulatedQuery(); bindBuffers(); syncScalars(); + useGridStagingStore().clear(); bump(); } @@ -67,7 +78,12 @@ export const useQueryResultStore = defineStore("queryResult", () => { } else { syncScalars(); } - if (ev.type === "error") error.value = acc.error; + if (ev.type === "error") { + error.value = acc.error; + useGridStagingStore().clear(); + } else if (ev.type === "done") { + syncStagingBaseline(); + } bump(); } @@ -83,6 +99,7 @@ export const useQueryResultStore = defineStore("queryResult", () => { acc.elapsedMs = 0; bindBuffers(); syncScalars(); + syncStagingBaseline(); bump(); if (opts.message) { useShellStatusStore().setQueryMetrics({ diff --git a/apps/tauri/src/ui/ResultGrid.vue b/apps/tauri/src/ui/ResultGrid.vue index 7c0cf2fb..19ae5fc2 100644 --- a/apps/tauri/src/ui/ResultGrid.vue +++ b/apps/tauri/src/ui/ResultGrid.vue @@ -1,10 +1,11 @@