diff --git a/src-tauri/src/db/query.rs b/src-tauri/src/db/query.rs index 91c13f9b..57652535 100644 --- a/src-tauri/src/db/query.rs +++ b/src-tauri/src/db/query.rs @@ -1731,8 +1731,9 @@ pub async fn execute_sql_on_conn( /// Hard row cap for ad-hoc SQL execution. Prevents OOM on tables with millions of rows. const EXECUTE_SQL_MAX_ROWS: usize = 1_000_000_000; -/// Statement timeout for ad-hoc queries (milliseconds). -const EXECUTE_SQL_TIMEOUT_MS: i64 = 30_000; +/// Statement timeout for ad-hoc queries (milliseconds). Generous enough for +/// heavier scans (e.g. tables with large TOASTed JSON columns) to finish. +const EXECUTE_SQL_TIMEOUT_MS: i64 = 60_000; async fn execute_sql_pg( pool: &sqlx::PgPool, diff --git a/src/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte index 9ecc8973..88582c11 100644 --- a/src/lib/components/DataTable.svelte +++ b/src/lib/components/DataTable.svelte @@ -81,7 +81,9 @@ 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 * as Select from "$lib/components/ui/select/index.js"; import ShikiBlock from "./ShikiBlock.svelte"; import { savePendingChanges, @@ -218,6 +220,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 +296,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 +312,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 +327,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; @@ -413,6 +447,8 @@ import FilterX from "@lucide/svelte/icons/filter-x"; let tableContainer = $state(/** @type {HTMLDivElement | null} */ (null)); /** Whether to select-all text when the edit input is focused. */ let selectOnEditFocus = $state(true); + /** Whether the enum cell-editor dropdown is open (auto-opens on edit). */ + let enumEditorOpen = $state(false); /** Raw cell value before the current edit started (for undo tracking). */ let lastEditOriginalValue = $state(/** @type {unknown} */ (undefined)); /** @@ -836,11 +872,15 @@ import FilterX from "@lucide/svelte/icons/filter-x"; draft: initialChar !== undefined ? initialChar : original, original, }; + // Enum columns edit via a dropdown — open it immediately so a single + // interaction (double-click / Enter) reveals the choices. + enumEditorOpen = !!getColumnEnumValues(col); } function cancelEdit() { if (!editingCell) return; editingCell = null; + enumEditorOpen = false; tick().then(() => tableContainer?.focus({ preventScroll: true })); } @@ -1025,21 +1065,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 +1109,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 +1140,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. @@ -4222,30 +4294,50 @@ import FilterX from "@lucide/svelte/icons/filter-x"; {#if isAuto} auto {:else if enumValues} - + (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"} @@ -4787,17 +4902,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} + {/each} {/each} @@ -212,10 +224,21 @@ {@const Icon = ICONS[selected.id]} {@const on = isOn(selected.id)}
- -
+ +
- + {#if Icon}{/if}
@@ -223,7 +246,12 @@

{KIND_LABEL[selected.kind] ?? "Extension"}

diff --git a/src/lib/components/JsonTree.svelte b/src/lib/components/JsonTree.svelte index 0299c874..18808016 100644 --- a/src/lib/components/JsonTree.svelte +++ b/src/lib/components/JsonTree.svelte @@ -39,19 +39,46 @@ ) const isContainer = $derived(isArray || isObject) + let expanded = $state(depth < defaultDepth) + let childLimit = $state(CHILD_PAGE) + + // Total child count — cheap for arrays (.length). Avoids materializing a tuple + // for every element of a huge array/object just to show a count. + const totalCount = $derived( + isArray ? /** @type {unknown[]} */ (value).length + : isObject ? Object.keys(/** @type {Record} */ (value)).length + : 0, + ) + + // Only the currently-visible children (up to childLimit) are materialized, so + // expanding a 100k-element array never builds 100k tuples at once. /** @type {[string, unknown][]} */ - const entries = $derived.by(() => { - if (isArray) return /** @type {unknown[]} */ (value).map((v, i) => /** @type {[string, unknown]} */ ([String(i), v])) - if (isObject) return Object.entries(/** @type {Record} */ (value)) + const visibleEntries = $derived.by(() => { + if (isArray) { + const arr = /** @type {unknown[]} */ (value) + const n = Math.min(arr.length, childLimit) + /** @type {[string, unknown][]} */ + const out = [] + for (let i = 0; i < n; i++) out.push([String(i), arr[i]]) + return out + } + if (isObject) { + /** @type {[string, unknown][]} */ + const out = [] + let i = 0 + for (const k in /** @type {Record} */ (value)) { + if (i >= childLimit) break + out.push([k, /** @type {Record} */ (value)[k]]) + i++ + } + return out + } return [] }) - let expanded = $state(depth < defaultDepth) - let childLimit = $state(CHILD_PAGE) - const summary = $derived.by(() => { - if (isArray) return entries.length === 1 ? '1 item' : `${entries.length} items` - if (isObject) return entries.length === 1 ? '1 key' : `${entries.length} keys` + if (isArray) return totalCount === 1 ? '1 item' : `${totalCount} items` + if (isObject) return totalCount === 1 ? '1 key' : `${totalCount} keys` return '' }) @@ -152,7 +179,7 @@ {#if isContainer && expanded}
- {#each entries.slice(0, childLimit) as [k, v] (k)} + {#each visibleEntries as [k, v] (k)} {/each} - {#if entries.length > childLimit} + {#if totalCount > childLimit} {/if}
diff --git a/src/lib/components/KeyboardShortcutsDialog.svelte b/src/lib/components/KeyboardShortcutsDialog.svelte index d7a3b1cf..24c95561 100644 --- a/src/lib/components/KeyboardShortcutsDialog.svelte +++ b/src/lib/components/KeyboardShortcutsDialog.svelte @@ -50,6 +50,7 @@ { keys: [mod, "⇧", "D"], desc: "Data view" }, { keys: [mod, "⇧", "S"], desc: "SQL editor" }, { keys: [mod, "⇧", "O"], desc: "ORM Runner" }, + { keys: [mod, "⇧", "X"], desc: "Extensions" }, { keys: [mod, "⇧", "E"], desc: "Toggle AI panel" }, { keys: [mod, "I"], desc: "Toggle AI sidebar" }, { keys: [mod, "⇧", "L"], desc: "Activity log" }, diff --git a/src/lib/components/PaneSnapshot.svelte b/src/lib/components/PaneSnapshot.svelte index b1ca4bc0..06b02f07 100644 --- a/src/lib/components/PaneSnapshot.svelte +++ b/src/lib/components/PaneSnapshot.svelte @@ -1,11 +1,14 @@