From f602765a586d1800ac3687944cc08b3f77f7a7f0 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Sun, 5 Jul 2026 12:01:38 +0545 Subject: [PATCH 1/2] feat(data): editable prettified DML preview + live pane snapshots Review changes dialog (DataTable): - Prettify the generated SQL with sql-formatter and render it in an editable Monaco editor (lazy-loaded) instead of a cramped read-only block. Editing the SQL switches Apply to run it verbatim via executeSqlMulti + refetch (onexecutesql), while the unedited path keeps the safe structured per-cell writes. - Include a pending inline new-row INSERT in the combined review + execution, so an added row is no longer silently dropped when applying edits/deletes together. Split panes (PaneSnapshot): - A defocused SQL/Query Editor pane now renders its query text and last results (read-only) instead of the generic 'click to focus' placeholder. --- src/lib/components/DataTable.svelte | 113 ++++++++++++++++++++++--- src/lib/components/PaneSnapshot.svelte | 45 ++++++++++ src/lib/components/StudioShell.svelte | 21 +++++ 3 files changed, 167 insertions(+), 12 deletions(-) diff --git a/src/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte index 9ecc8973..12a32321 100644 --- a/src/lib/components/DataTable.svelte +++ b/src/lib/components/DataTable.svelte @@ -81,6 +81,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; buildDeleteStatements, buildInsertStatements, } from "$lib/dml-preview.js"; + import { formatSql } from "$lib/format-sql.js"; import * as Dialog from "$lib/components/ui/dialog/index.js"; import ShikiBlock from "./ShikiBlock.svelte"; import { @@ -218,6 +219,13 @@ import FilterX from "@lucide/svelte/icons/filter-x"; onfetchrelatedrows = /** @type {(detail: any) => Promise<{ columns: any[], rows: any[], error?: string }>} */ (async () => ({ columns: [], rows: [] })), /** Called when the user confirms the new row draft. Receives the validated values. */ oninsertrow = /** @type {(values: Record) => Promise} */ (async () => {}), + /** + * Execute raw SQL the user hand-edited in the DML preview, then refetch. Only + * invoked when the previewed SQL was actually changed — the unedited path still + * runs through the structured per-cell writes (`onsave`/`ondelete`/`oninsertrow`). + * @type {(sql: string) => Promise} + */ + onexecutesql = /** @type {(sql: string) => Promise} */ (async () => {}), /** True while the insert is in flight — disables the draft row inputs. */ insertSaving = false, /** Assigned by this component so the parent can trigger beginInsertRow(). */ @@ -287,6 +295,12 @@ import FilterX from "@lucide/svelte/icons/filter-x"; let dmlPreview = $state(null); /** True while the confirmed write is in flight. */ let dmlPreviewRunning = $state(false); + /** Prettified SQL shown in the (editable) preview editor. Bound to the editor. */ + let dmlEditedSql = $state(""); + /** The pristine prettified SQL, to detect whether the user edited it. */ + let dmlOriginalSql = $state(""); + /** True once the user has changed the previewed SQL — switches Apply to raw exec. */ + const dmlWasEdited = $derived(dmlEditedSql.trim() !== dmlOriginalSql.trim()); /** @type {import('$lib/dml-preview.js').DmlContext} */ const dmlContext = $derived({ dialect, schema, table: tableName, columns, primaryKey }); @@ -297,8 +311,14 @@ import FilterX from "@lucide/svelte/icons/filter-x"; * @param {NonNullable} config */ function requestWrite(config) { - if ($appPreviewDml) dmlPreview = config; - else void config.run(); + if ($appPreviewDml) { + dmlPreview = config; + // Prettify the generated statements for a readable, editable preview. + dmlOriginalSql = formatSql(config.statements.join("\n")); + dmlEditedSql = dmlOriginalSql; + } else { + void config.run(); + } } /** Run the previewed write, then close the dialog. */ @@ -306,7 +326,20 @@ import FilterX from "@lucide/svelte/icons/filter-x"; if (!dmlPreview || dmlPreviewRunning) return; dmlPreviewRunning = true; try { - await dmlPreview.run(); + if (dmlWasEdited) { + // The user hand-edited the SQL — run it verbatim (as raw statements) and + // refetch. Arbitrary edits can't be mapped back to the staged per-cell + // model, so drop all staged edits/deletes and the inline insert draft. + await onexecutesql(dmlEditedSql.trim()); + pendingEdits = new Map(); + pendingDeletes = new Set(); + pastEdits = []; + futureEdits = []; + savePendingChanges(_persistKey, pendingEdits, pendingDeletes); + cancelNewRow(); + } else { + await dmlPreview.run(); + } dmlPreview = null; } finally { dmlPreviewRunning = false; @@ -1025,21 +1058,41 @@ import FilterX from "@lucide/svelte/icons/filter-x"; } } - /** Open the DML preview for all staged changes (edits + deletes); confirm flushes them. */ + /** + * Validated values for the inline new-row draft, if the user has typed anything + * into it and it passes validation; otherwise null. Lets the combined "Review + * changes" flow include the pending INSERT instead of silently dropping it. + * @returns {Record | null} + */ + function pendingInsertValues() { + if (!newRowDrafts) return null; + const hasAny = Object.values(newRowDrafts).some((v) => v !== "" && v != null); + if (!hasAny) return null; + const editableCols = columns.filter((c) => isEditableType(c.dataType ?? c.data_type ?? "")); + const built = buildInsertPayload(editableCols, primaryKey, newRowDrafts); + return built.ok ? /** @type {Record} */ (built.values) : null; + } + + /** Open the DML preview for all staged changes (edits + deletes + a pending insert). */ function applyPendingEdits() { - if (!hasPendingChanges || saving) return; + const insertValues = pendingInsertValues(); + if ((!hasPendingChanges && !insertValues) || saving) return; // A row staged for deletion doesn't need its cell updates written first. const editEntries = [...pendingEdits.values()].filter((e) => !pendingDeletes.has(e.rowIdx)); const deleteIndices = [...pendingDeletes].sort((a, b) => a - b); + // Order: updates, deletes, then the insert. Inserting last keeps the existing + // row indices (which the updates/deletes address) stable during execution. const statements = [ ...buildUpdateStatements(editEntries, rows, dmlContext), ...(deleteIndices.length ? buildDeleteStatements(deleteIndices, rows, dmlContext) : []), + ...(insertValues ? buildInsertStatements(insertValues, dmlContext) : []), ]; const parts = []; if (editEntries.length) parts.push(`${editEntries.length} cell${editEntries.length === 1 ? "" : "s"} updated`); if (deleteIndices.length) parts.push(`${deleteIndices.length} row${deleteIndices.length === 1 ? "" : "s"} deleted`); + if (insertValues) parts.push("1 row inserted"); requestWrite({ - kind: deleteIndices.length ? "delete" : "update", + kind: deleteIndices.length ? "delete" : insertValues && !editEntries.length ? "insert" : "update", title: "Review changes", description: `${parts.join(", ")}.${deleteIndices.length ? " Deletions cannot be undone." : ""}`, statements, @@ -1049,9 +1102,10 @@ import FilterX from "@lucide/svelte/icons/filter-x"; }); } - /** Flush all staged edits and deletes to the database. */ + /** Flush all staged edits and deletes (and a pending insert) to the database. */ async function executePendingChanges() { - if (!hasPendingChanges || saving) return; + const insertValues = pendingInsertValues(); + if ((!hasPendingChanges && !insertValues) || saving) return; // Edits first (skipping rows about to be deleted), then the deletes. const entries = [...pendingEdits.values()].filter((e) => !pendingDeletes.has(e.rowIdx)); /** @type {typeof entries} */ @@ -1079,6 +1133,17 @@ import FilterX from "@lucide/svelte/icons/filter-x"; } } + // Insert last so the row indices addressed by the updates/deletes above stay + // valid throughout. oninsertrow surfaces its own toast + refetch. + if (insertValues) { + try { + await oninsertrow(insertValues); + cancelNewRow(); + } catch { + // error toast already shown by oninsertrow + } + } + // Keep only the edits that failed so the user can retry / reset them — unless // deletes ran, which splice `rows` and invalidate the failed edits' row indices; // in that case drop them so a retry can't target the wrong row. @@ -4787,17 +4852,41 @@ import FilterX from "@lucide/svelte/icons/filter-x"; -
+
SQL to run{dmlPreview.statements.length > 1 ? ` · ${dmlPreview.statements.length} statements` : ''} + {#if dmlWasEdited} + + {/if}
- -
- + +
+ {#await import('./SqlEditor.svelte')} + + {:then { default: SqlEditor }} + + {/await}
+ {#if dmlWasEdited} +

+ You edited the SQL — Apply will run it exactly as written. +

+ {/if} +
{/if} +{:else if sqlState} + +
+ {#if sqlState.sqlText?.trim()} +
+ +
+ {/if} + {#if sqlState.sqlColumns?.length} +
+ +
+ {:else} +
+ + {#if sqlState.sqlError} +

{sqlState.sqlError}

+ {:else if sqlState.sqlMessage} +

{sqlState.sqlMessage}

+ {:else} +

+ {sqlState.sqlText?.trim() ? 'No results yet' : 'Empty query'} +

+ {/if} +
+ {/if} +
{:else}
diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index 83b2c5c2..85006a3c 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -3574,6 +3574,26 @@ let rowSearch = $state('') } } + /** + * Run raw DML that the user hand-edited in the "Review changes" preview, then + * refetch the current page so the grid reflects it. + * @param {string} sql + */ + async function handleExecuteRawDml(sql) { + if (!sql.trim()) return + const _start = Date.now() + try { + await executeSqlMulti(sql) + recordActivity({ type: 'row_save', title: `Applied edited SQL${activeTable ? ` on ${activeTable}` : ''}`, schema: activeSchema, table: activeTable ?? '', durationMs: Date.now() - _start, success: true }) + await loadRows() + toast.success('Changes applied') + } catch (err) { + recordActivity({ type: 'row_save', title: `Failed edited SQL${activeTable ? ` on ${activeTable}` : ''}`, schema: activeSchema, table: activeTable ?? '', success: false, error: String(err) }) + toast.error('Could not apply changes', { description: String(err) }) + throw err + } + } + /** @param {{ rowIdx: number, colIdx: number, value: unknown }} detail */ async function handleSaveCell(detail) { if (!activeTable || !primaryKey.length) return @@ -4633,6 +4653,7 @@ let rowSearch = $state('') ondelete={handleDeleteRow} onfollowforeignkey={(d) => void handleFollowForeignKey(d)} oninsertrow={handleInsertRow} + onexecutesql={handleExecuteRawDml} insertSaving={insertingRow} bind:beginInsertRow={dtBeginInsertRow} bind:stageDeleteSelected={stageDeleteSelectedRows} From f20a6882339fb278e0c61e9ed7c82595053f4b0f Mon Sep 17 00:00:00 2001 From: broisnischal Date: Sun, 5 Jul 2026 12:39:09 +0545 Subject: [PATCH 2/2] fix(data): themed cross-platform enum/boolean dropdowns Replace the native setNewRowDraft(col.name, e.currentTarget.value)} - onfocus={() => (newRowFocusCol = col.name)} + onValueChange={(v) => setNewRowDraft(col.name, v ?? '')} > - - {#each enumValues as opt (opt)}{/each} - + (newRowFocusCol = col.name)} + class="h-7 w-full min-w-0 rounded-md border-0 bg-transparent px-1 py-0 font-mono text-ui-sm text-foreground shadow-none focus-visible:ring-0 disabled:opacity-50" + > + + {newRowDrafts[col.name] || (col.nullable ? 'NULL / default' : 'Select…')} + + + + {col.nullable ? 'NULL / default' : 'Select…'} + {#each enumValues as opt (opt)} + {opt} + {/each} + + {:else if isBoolean} - + (newRowFocusCol = col.name)} + class="h-7 w-full min-w-0 rounded-md border-0 bg-transparent px-1 py-0 font-mono text-ui-sm text-foreground shadow-none focus-visible:ring-0 disabled:opacity-50" + > + + {newRowDrafts[col.name] || (col.nullable ? 'NULL / default' : 'Default')} + + + + {col.nullable ? 'NULL / default' : 'Default'} + true + false + + {:else if isDateTime} {#if eEnum} - + + + {editingCell.draft || (eNullable ? 'NULL' : 'Select…')} + + + + {#if eNullable} + NULL + {/if} + {#if editingCell.original && !eEnum.includes(editingCell.original)} + {editingCell.original} + {/if} + {#each eEnum as option (option)} + {option} + {/each} + + {:else if isBooleanType(eType)} {@const isOn = editingCell.draft === "true"} {@const isNull = eNullable && editingCell.draft !== "true" && editingCell.draft !== "false"}