Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions apps/tauri/src/staging/buffer.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
206 changes: 206 additions & 0 deletions apps/tauri/src/staging/buffer.ts
Original file line number Diff line number Diff line change
@@ -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<number, Map<number, string>>();
private insertedRows: string[][] = [];
private deletedRowIndices = new Set<number>();
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);
}),
);
}
40 changes: 40 additions & 0 deletions apps/tauri/src/stores/gridStaging.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
77 changes: 77 additions & 0 deletions apps/tauri/src/stores/gridStaging.ts
Original file line number Diff line number Diff line change
@@ -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<StagingBuffer | null>(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,
};
});
Loading
Loading