diff --git a/apps/tauri/src-tauri/src/commands.rs b/apps/tauri/src-tauri/src/commands.rs index 6c56c2a0..6cfce4bb 100644 --- a/apps/tauri/src-tauri/src/commands.rs +++ b/apps/tauri/src-tauri/src/commands.rs @@ -5,6 +5,7 @@ use crate::connection::{ }; use crate::profiles::{ConnectionProfile, ProfileStore, ProfileStoreError}; use crate::secrets::SharedSecretStore; +use crate::staging::{apply_staging_request, ApplyResult, StagingApplyRequest}; use serde::Deserialize; use tauri::ipc::Channel; use tauri::State; @@ -505,3 +506,12 @@ pub fn db_execute_query( .map_err(|e| ConnectionError::io(format!("query channel closed: {e}"))) }) } + +/// Apply frontend staging edits in a single transaction (#1891). +#[tauri::command] +pub fn db_apply_staging( + request: StagingApplyRequest, + registry: State<'_, ConnectionRegistry>, +) -> Result { + apply_staging_request(®istry, request) +} diff --git a/apps/tauri/src-tauri/src/lib.rs b/apps/tauri/src-tauri/src/lib.rs index d513e58c..c707ebc9 100644 --- a/apps/tauri/src-tauri/src/lib.rs +++ b/apps/tauri/src-tauri/src/lib.rs @@ -20,9 +20,9 @@ pub use staging::{ }; use commands::{ - db_connect_mysql, db_connect_postgres, db_connect_sqlite, db_describe_object, db_disconnect, - db_erd_schema, db_execute_query, db_is_connected, db_list_children, db_list_columns, - db_list_schemas, db_list_tables, db_postgres_object_detail, db_sqlite_overview, + db_apply_staging, db_connect_mysql, db_connect_postgres, db_connect_sqlite, db_describe_object, + db_disconnect, db_erd_schema, db_execute_query, db_is_connected, db_list_children, + db_list_columns, db_list_schemas, db_list_tables, db_postgres_object_detail, db_sqlite_overview, profiles_connect_mysql, profiles_connect_postgres, profiles_connect_sqlite, profiles_delete, profiles_list, profiles_upsert, }; @@ -106,6 +106,7 @@ pub fn run() { db_sqlite_overview, db_erd_schema, db_execute_query, + db_apply_staging, profiles_list, profiles_upsert, profiles_delete, diff --git a/apps/tauri/src-tauri/src/staging/ipc.rs b/apps/tauri/src-tauri/src/staging/ipc.rs new file mode 100644 index 00000000..a5edb6a5 --- /dev/null +++ b/apps/tauri/src-tauri/src/staging/ipc.rs @@ -0,0 +1,98 @@ +//! IPC payload: rebuild a [`StagingBuffer`] from the frontend snapshot and apply. + +use super::apply::{ + apply_staging_mysql, apply_staging_postgres, apply_staging_sqlite, ApplyError, ApplyResult, +}; +use super::StagingBuffer; +use crate::connection::{ + ConnectionError, ConnectionId, ConnectionManager, ConnectionRegistry, DriverKind, +}; +use serde::Deserialize; +use std::collections::BTreeSet; + +/// One staged cell edit (JSON-friendly; Maps don't round-trip cleanly). +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StagedCellEdit { + pub row: usize, + pub col: usize, + pub value: String, +} + +/// Frontend staging snapshot for apply (#1891). +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StagingApplyRequest { + pub connection_id: ConnectionId, + pub table_name: String, + pub schema: Option, + pub columns: Vec, + pub primary_keys: Vec, + pub original_rows: Vec>, + pub modified_cells: Vec, + pub inserted_rows: Vec>, + pub deleted_row_indices: Vec, +} + +impl StagingApplyRequest { + pub fn into_buffer(self) -> StagingBuffer { + let mut buf = StagingBuffer::new( + self.columns, + self.original_rows, + self.primary_keys, + ); + for edit in self.modified_cells { + buf.set_cell(edit.row, edit.col, edit.value); + } + for row in self.inserted_rows { + buf.add_row(Some(row)); + } + let deleted: BTreeSet = self.deleted_row_indices.into_iter().collect(); + for idx in deleted { + buf.toggle_delete_row(idx); + } + buf + } +} + +/// Apply staged edits on the live connection; clears buffer only on success (Rust side). +pub fn apply_staging_request( + registry: &ConnectionRegistry, + req: StagingApplyRequest, +) -> Result { + let id = req.connection_id.clone(); + let table = req.table_name.clone(); + let schema = req.schema.clone(); + let mut buffer = req.into_buffer(); + + let driver = registry + .driver_of(&id) + .ok_or_else(|| ConnectionError::NotFound { id: id.clone() })?; + + let result = match driver { + DriverKind::Sqlite => registry.with_sqlite(&id, |s| { + s.with_connection(|conn| { + apply_staging_sqlite(conn, &mut buffer, &table, schema.as_deref()) + .map_err(map_apply_error) + }) + }), + DriverKind::Postgres => registry.with_postgres(&id, |s| { + s.with_pool(|pool| { + apply_staging_postgres(pool, &mut buffer, &table, schema.as_deref()) + .map_err(map_apply_error) + }) + }), + DriverKind::Mysql => registry.with_mysql(&id, |s| { + s.with_pool(|pool| { + apply_staging_mysql(pool, &mut buffer, &table, schema.as_deref()) + .map_err(map_apply_error) + }) + }), + }?; + + Ok(result) +} + +fn map_apply_error(err: ApplyError) -> ConnectionError { + ConnectionError::io(err.to_string()) +} diff --git a/apps/tauri/src-tauri/src/staging/mod.rs b/apps/tauri/src-tauri/src/staging/mod.rs index 25e76d8e..c882f8e5 100644 --- a/apps/tauri/src-tauri/src/staging/mod.rs +++ b/apps/tauri/src-tauri/src/staging/mod.rs @@ -8,6 +8,7 @@ mod apply; mod apply_live; mod buffer; mod dml; +mod ipc; pub use apply::{ apply_plan_mysql, apply_plan_postgres, apply_plan_sqlite, apply_staging_mysql, @@ -20,3 +21,4 @@ pub use dml::{ generate_plan, MutationType, SqlDialect, StagingColumnMeta, TableMutationPlan, TableMutationStatement, }; +pub use ipc::{apply_staging_request, StagingApplyRequest}; diff --git a/apps/tauri/src/api/staging.ts b/apps/tauri/src/api/staging.ts new file mode 100644 index 00000000..02a6c574 --- /dev/null +++ b/apps/tauri/src/api/staging.ts @@ -0,0 +1,24 @@ +import { invoke } from "@tauri-apps/api/core"; + +export type StagingApplyRequest = { + connectionId: string; + tableName: string; + schema?: string | null; + columns: string[]; + primaryKeys: string[]; + originalRows: string[][]; + modifiedCells: { row: number; col: number; value: string }[]; + insertedRows: string[][]; + deletedRowIndices: number[]; +}; + +export type ApplyResult = { + statements_applied: number; +}; + +/** Apply staged grid edits in one DB transaction. */ +export async function dbApplyStaging( + request: StagingApplyRequest, +): Promise { + return invoke("db_apply_staging", { request }); +} diff --git a/apps/tauri/src/staging/buffer.test.ts b/apps/tauri/src/staging/buffer.test.ts index 77564c4a..28e81b87 100644 --- a/apps/tauri/src/staging/buffer.test.ts +++ b/apps/tauri/src/staging/buffer.test.ts @@ -41,4 +41,16 @@ describe("StagingBuffer (frontend)", () => { expect(buf.effectiveRows()[0]?.[2]).toBe("NULL"); expect(NULL_SENTINEL).toContain("QUERYA_NULL"); }); + + it("addRow and toApplySnapshot", () => { + const buf = sample(); + buf.setCell(0, 1, "Alicia"); + buf.addRow(["3", "Carol", "carol@test.com"]); + const snap = buf.toApplySnapshot(); + expect(snap.modifiedCells).toEqual([ + { row: 0, col: 1, value: "Alicia" }, + ]); + expect(snap.insertedRows).toHaveLength(1); + expect(snap.primaryKeys).toEqual(["id"]); + }); }); diff --git a/apps/tauri/src/staging/buffer.ts b/apps/tauri/src/staging/buffer.ts index c2bf0cbf..bc24fa6a 100644 --- a/apps/tauri/src/staging/buffer.ts +++ b/apps/tauri/src/staging/buffer.ts @@ -33,11 +33,11 @@ export class StagingBuffer { replaceBaseline( columns: string[], rows: string[][], - primaryKeys: string[] = [], + primaryKeys?: string[], ): void { this.columns = [...columns]; this.originalRows = rows.map((r) => [...r]); - this.primaryKeys = [...primaryKeys]; + if (primaryKeys) this.primaryKeys = [...primaryKeys]; this.modifiedCells.clear(); this.insertedRows = []; this.deletedRowIndices.clear(); @@ -165,6 +165,44 @@ export class StagingBuffer { this.revision += 1; } + addRow(initial?: string[]): number { + const row = + initial ?? Array.from({ length: this.columns.length }, () => ""); + this.insertedRows.push([...row]); + this.revision += 1; + return this.totalRowCount() - 1; + } + + /** Snapshot for `db_apply_staging` IPC. */ + toApplySnapshot(): { + columns: string[]; + primaryKeys: string[]; + originalRows: string[][]; + modifiedCells: { row: number; col: number; value: string }[]; + insertedRows: string[][]; + deletedRowIndices: number[]; + } { + const modifiedCells: { row: number; col: number; value: string }[] = []; + for (const [row, cols] of this.modifiedCells) { + for (const [col, value] of cols) { + modifiedCells.push({ row, col, value }); + } + } + return { + columns: [...this.columns], + primaryKeys: [...this.primaryKeys], + originalRows: this.originalRows.map((r) => [...r]), + modifiedCells, + insertedRows: this.insertedRows.map((r) => [...r]), + deletedRowIndices: [...this.deletedRowIndices], + }; + } + + setPrimaryKeys(keys: string[]): void { + this.primaryKeys = [...keys]; + this.revision += 1; + } + /** Effective grid values (deleted rows kept for strike-through). */ effectiveRows(): string[][] { const out: string[][] = []; diff --git a/apps/tauri/src/stores/gridStaging.test.ts b/apps/tauri/src/stores/gridStaging.test.ts index 5d54550b..867c00a6 100644 --- a/apps/tauri/src/stores/gridStaging.test.ts +++ b/apps/tauri/src/stores/gridStaging.test.ts @@ -37,4 +37,20 @@ describe("useGridStagingStore", () => { expect(store.isDirty).toBe(false); expect(store.getCellValue(0, 0)).toBe("2"); }); + + it("revertAll discards dirty markers", () => { + const store = useGridStagingStore(); + store.setTarget({ + connectionId: "c1", + tableName: "users", + primaryKeys: ["id"], + }); + store.attachFromQuery(["id", "name"], [["1", "A"]]); + store.setCell(0, 1, "B"); + expect(store.isDirty).toBe(true); + expect(store.canApply).toBe(true); + store.revertAll(); + expect(store.isDirty).toBe(false); + expect(store.getCellValue(0, 1)).toBe("A"); + }); }); diff --git a/apps/tauri/src/stores/gridStaging.ts b/apps/tauri/src/stores/gridStaging.ts index 51aac152..d88f0e17 100644 --- a/apps/tauri/src/stores/gridStaging.ts +++ b/apps/tauri/src/stores/gridStaging.ts @@ -1,37 +1,69 @@ import { defineStore } from "pinia"; import { computed, ref } from "vue"; +import { dbApplyStaging } from "../api/staging"; +import { formatConnectionError } from "../api/connection"; import { rowsToStrings, StagingBuffer } from "../staging/buffer"; +export type StagingTableTarget = { + connectionId: string; + tableName: string; + schema?: string | null; + primaryKeys: string[]; +}; + /** * Pinia façade over {@link StagingBuffer} for the result grid. - * Apply / discard IPC lands in #1891; this store owns dirty markers (#1890). + * Dirty markers (#1890); apply / discard IPC (#1891). */ export const useGridStagingStore = defineStore("gridStaging", () => { const buffer = ref(null); /** Mirrors buffer revision so Vue / Glide can watch cheaply. */ const revision = ref(0); + const target = ref(null); + const saving = ref(false); + const applyError = ref(null); + const selectedRowIndex = ref(null); const isDirty = computed(() => buffer.value?.isDirty() ?? false); const changeCount = computed(() => buffer.value?.changeCount() ?? 0); const rowCount = computed(() => buffer.value?.totalRowCount() ?? 0); + const canApply = computed( + () => + isDirty.value && + !!target.value && + target.value.primaryKeys.length > 0 && + !saving.value, + ); function bump() { revision.value = buffer.value?.getRevision() ?? 0; } + function setTarget(next: StagingTableTarget | null) { + target.value = next; + if (next && buffer.value) { + buffer.value.setPrimaryKeys(next.primaryKeys); + bump(); + } + } + function attachFromQuery(columns: string[], rows: unknown[][]) { const stringRows = rowsToStrings(rows); + const pks = target.value?.primaryKeys ?? []; if (!buffer.value) { - buffer.value = new StagingBuffer(columns, stringRows); + buffer.value = new StagingBuffer(columns, stringRows, pks); } else { - buffer.value.replaceBaseline(columns, stringRows); + buffer.value.replaceBaseline(columns, stringRows, pks); } + applyError.value = null; bump(); } function clear() { buffer.value = null; revision.value = 0; + applyError.value = null; + selectedRowIndex.value = null; } function setCell(row: number, col: number, value: string) { @@ -55,16 +87,69 @@ export const useGridStagingStore = defineStore("gridStaging", () => { return buffer.value?.effectiveRows() ?? []; } + function addRow() { + buffer.value?.addRow(); + bump(); + } + + function toggleDeleteSelected() { + const row = selectedRowIndex.value; + if (row == null) return; + buffer.value?.toggleDeleteRow(row); + bump(); + } + function revertAll() { buffer.value?.revertAll(); + applyError.value = null; bump(); } + async function applyChanges(): Promise { + const buf = buffer.value; + const t = target.value; + if (!buf || !t || !buf.isDirty()) return false; + if (t.primaryKeys.length === 0) { + applyError.value = "Primary key required to save changes"; + return false; + } + saving.value = true; + applyError.value = null; + try { + const snap = buf.toApplySnapshot(); + const result = await dbApplyStaging({ + connectionId: t.connectionId, + tableName: t.tableName, + schema: t.schema ?? null, + columns: snap.columns, + primaryKeys: snap.primaryKeys.length ? snap.primaryKeys : t.primaryKeys, + originalRows: snap.originalRows, + modifiedCells: snap.modifiedCells, + insertedRows: snap.insertedRows, + deletedRowIndices: snap.deletedRowIndices, + }); + buf.revertAll(); + bump(); + return result.statements_applied >= 0; + } catch (err) { + applyError.value = formatConnectionError(err); + return false; + } finally { + saving.value = false; + } + } + return { revision, isDirty, changeCount, rowCount, + target, + saving, + applyError, + selectedRowIndex, + canApply, + setTarget, attachFromQuery, clear, setCell, @@ -72,6 +157,9 @@ export const useGridStagingStore = defineStore("gridStaging", () => { rowStatus, getCellValue, effectiveRows, + addRow, + toggleDeleteSelected, revertAll, + applyChanges, }; }); diff --git a/apps/tauri/src/stores/queryResult.ts b/apps/tauri/src/stores/queryResult.ts index a42b4031..cf20d9fd 100644 --- a/apps/tauri/src/stores/queryResult.ts +++ b/apps/tauri/src/stores/queryResult.ts @@ -61,7 +61,9 @@ export const useQueryResultStore = defineStore("queryResult", () => { acc = emptyAccumulatedQuery(); bindBuffers(); syncScalars(); - useGridStagingStore().clear(); + const staging = useGridStagingStore(); + staging.clear(); + staging.setTarget(null); bump(); } diff --git a/apps/tauri/src/ui/GridStagingToolbar.vue b/apps/tauri/src/ui/GridStagingToolbar.vue new file mode 100644 index 00000000..90d25afc --- /dev/null +++ b/apps/tauri/src/ui/GridStagingToolbar.vue @@ -0,0 +1,211 @@ + + + + + diff --git a/apps/tauri/src/ui/ResultGrid.vue b/apps/tauri/src/ui/ResultGrid.vue index 19ae5fc2..5f0b3edc 100644 --- a/apps/tauri/src/ui/ResultGrid.vue +++ b/apps/tauri/src/ui/ResultGrid.vue @@ -11,8 +11,13 @@ import { buildQueryaGlideTheme, subscribeThemeChanges, } from "./glide/queryaGlideTheme"; +import GridStagingToolbar from "./GridStagingToolbar.vue"; import "@glideapps/glide-data-grid/dist/index.css"; +const emit = defineEmits<{ + applied: []; +}>(); + const query = useQueryResultStore(); const staging = useGridStagingStore(); const { columns, rows, revision, rowCount, running } = storeToRefs(query); @@ -26,8 +31,11 @@ let ro: ResizeObserver | null = null; let unsubTheme: (() => void) | null = null; let glideTheme: Partial = buildQueryaGlideTheme(); +const showToolbar = computed( + () => columns.value.length > 0 && !running.value, +); + const displayRows = computed(() => { - // Prefer staged effective values when dirty so edits show immediately. if (staging.isDirty && staging.rowCount > 0) { return staging.effectiveRows(); } @@ -42,6 +50,10 @@ function onCellEdited(row: number, col: number, value: string) { staging.setCell(row, col, value); } +function onCellClicked(row: number) { + staging.selectedRowIndex = row; +} + function render() { if (!root || !host.value) return; root.render( @@ -55,6 +67,7 @@ function render() { cellStatus: (r, c) => staging.cellStatus(r, c), rowStatus: (r) => staging.rowStatus(r), onCellEdited, + onRowActivated: onCellClicked, }), ); } @@ -97,24 +110,35 @@ onBeforeUnmount(() => {