From 977ef37f29e21d98cecad9dce300a7210c387033 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Sat, 4 Jul 2026 22:00:45 +0545 Subject: [PATCH 01/20] fix(diagrams): disable font ligatures in Mermaid code editor Geist Mono renders arrow sequences (->>, -->>) as ligatures that overlap the preceding word, producing a strikethrough-like artifact over participant names in the diagram editor/creator. Disable liga/calt and font-variant-ligatures on the code textarea so raw Mermaid syntax renders literally. --- src/lib/components/DiagramsPage.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/components/DiagramsPage.svelte b/src/lib/components/DiagramsPage.svelte index 23454de5..66c7e03e 100644 --- a/src/lib/components/DiagramsPage.svelte +++ b/src/lib/components/DiagramsPage.svelte @@ -290,7 +290,7 @@
From 0f1a60616e7ac2dbbaa0deab8af95f2c45a482be Mon Sep 17 00:00:00 2001 From: broisnischal Date: Sat, 4 Jul 2026 22:19:52 +0545 Subject: [PATCH 02/20] feat:: dml --- src/lib/components/DataTable.svelte | 208 +++++++++++++++++++---- src/lib/components/SettingsDialog.svelte | 5 + src/lib/components/StudioShell.svelte | 1 + src/lib/dml-preview.js | 165 ++++++++++++++++++ src/lib/stores/settings.js | 12 +- 5 files changed, 356 insertions(+), 35 deletions(-) create mode 100644 src/lib/dml-preview.js diff --git a/src/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte index 128ea545..29c116b8 100644 --- a/src/lib/components/DataTable.svelte +++ b/src/lib/components/DataTable.svelte @@ -3,7 +3,7 @@ import { zoomState } from '$lib/stores/canvas-zoom.svelte.js' // Zoom is driven through the app-level settings so the canvas scales together // with the rest of the UI (applySettings mirrors the app zoom into zoomState). - import { increaseZoom, decreaseZoom, resetZoom } from '$lib/stores/settings.js' + import { increaseZoom, decreaseZoom, resetZoom, appPreviewDml } from '$lib/stores/settings.js' import { toast } from "$lib/components/ui/sonner/toast.svelte.js"; import { Checkbox } from "$lib/components/ui/checkbox/index.js"; import * as ContextMenu from "$lib/components/ui/context-menu/index.js"; @@ -76,6 +76,12 @@ import FilterX from "@lucide/svelte/icons/filter-x"; nowTimeOnly, } from "$lib/insert-field.js"; import { cellLinkHref, cellUrlType } from "$lib/cell-display.js"; + import { + buildUpdateStatements, + buildDeleteStatements, + buildInsertStatements, + } from "$lib/dml-preview.js"; + import * as Dialog from "$lib/components/ui/dialog/index.js"; import { formatCellValue, transformsFor, enabledGenerators, linkifyValue, statsNeeded, annotatorEnabled, anyDisplayExtEnabled } from "$lib/plugins/registry.js"; import { pluginState } from "$lib/stores/plugins.js"; import Wand2 from "@lucide/svelte/icons/wand-2"; @@ -149,6 +155,8 @@ import FilterX from "@lucide/svelte/icons/filter-x"; /** Schema + table name used for INSERT statement generation */ schema = '', tableName = '', + /** Engine family — drives identifier quoting in the DML preview. */ + dialect = /** @type {import('$lib/dml-preview.js').Dialect} */ ('postgres'), /** Set of column names to hide. Controlled externally (toolbar). */ hiddenColumns = /** @type {Set} */ (new Set()), /** @@ -233,6 +241,52 @@ import FilterX from "@lucide/svelte/icons/filter-x"; * on every cell of large tables. */ const hasPendingEdits = $derived(pendingEdits.size > 0); + /** + * DML preview / confirm dialog. Non-null while open. Every write path (apply + * staged edits, insert a new row, delete rows) routes through this so the user + * can review the exact SQL before it runs. `run` performs the actual write. + * @type {{ kind: 'update' | 'insert' | 'delete', title: string, description: string, statements: string[], confirmLabel: string, destructive: boolean, run: () => Promise } | null} + */ + let dmlPreview = $state(null); + /** True while the confirmed write is in flight. */ + let dmlPreviewRunning = $state(false); + + /** @type {import('$lib/dml-preview.js').DmlContext} */ + const dmlContext = $derived({ dialect, schema, table: tableName, columns, primaryKey }); + + /** + * Route a write through the confirm dialog, or run it straight away when the + * "Preview SQL before applying" setting is off. + * @param {NonNullable} config + */ + function requestWrite(config) { + if ($appPreviewDml) dmlPreview = config; + else void config.run(); + } + + /** Run the previewed write, then close the dialog. */ + async function confirmDmlPreview() { + if (!dmlPreview || dmlPreviewRunning) return; + dmlPreviewRunning = true; + try { + await dmlPreview.run(); + dmlPreview = null; + } finally { + dmlPreviewRunning = false; + } + } + + /** Copy the previewed statements to the clipboard. */ + async function copyDmlPreview() { + if (!dmlPreview) return; + try { + await navigator.clipboard.writeText(dmlPreview.statements.join("\n")); + toast.success("SQL copied"); + } catch (err) { + toast.error("Could not copy", { description: String(err) }); + } + } + /** @type {HTMLInputElement | HTMLSelectElement | HTMLButtonElement | null} */ let editInput = $state(null); @@ -265,10 +319,6 @@ import FilterX from "@lucide/svelte/icons/filter-x"; let pendingContextMenu = $state(false); /** Block item activation from the right-click pointerup that opened the menu */ let suppressMenuSelect = $state(false); - /** True while waiting for a second click to confirm row delete in context menu */ - let deleteRowConfirmPending = $state(false); - /** @type {ReturnType | null} */ - let _deleteRowConfirmTimer = null; /** Row indices with inline JSON detail open */ let expandedRows = $state(new Set()); /** @type {Record} */ @@ -919,8 +969,25 @@ import FilterX from "@lucide/svelte/icons/filter-x"; } } + /** Open the DML preview for the staged edits; the user confirms to flush them. */ + function applyPendingEdits() { + if (pendingEdits.size === 0 || saving) return; + const entries = [...pendingEdits.values()]; + const statements = buildUpdateStatements(entries, rows, dmlContext); + const n = entries.length; + requestWrite({ + kind: "update", + title: "Review changes", + description: `${n} cell${n === 1 ? "" : "s"} will be updated.`, + statements, + confirmLabel: `Apply ${n} change${n === 1 ? "" : "s"}`, + destructive: false, + run: executePendingEdits, + }); + } + /** Flush all staged edits to the database. */ - async function applyPendingEdits() { + async function executePendingEdits() { if (pendingEdits.size === 0 || saving) return; const entries = [...pendingEdits.values()]; /** @type {typeof entries} */ @@ -1008,7 +1075,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; newRowFocusCol = null } - async function submitNewRow() { + function submitNewRow() { if (!newRowDrafts || insertSaving) return const editableCols = columns.filter(c => isEditableType(c.dataType ?? c.data_type ?? '')) const built = buildInsertPayload(editableCols, primaryKey, newRowDrafts) @@ -1016,8 +1083,22 @@ import FilterX from "@lucide/svelte/icons/filter-x"; toast.error('Cannot insert row', { description: built.message }) return } + const values = /** @type {Record} */ (built.values) + requestWrite({ + kind: "insert", + title: "Review insert", + description: "A new row will be inserted.", + statements: buildInsertStatements(values, dmlContext), + confirmLabel: "Insert row", + destructive: false, + run: () => executeInsertRow(values), + }) + } + + /** @param {Record} values */ + async function executeInsertRow(values) { try { - await oninsertrow(/** @type {Record} */ (built.values)) + await oninsertrow(values) newRowDrafts = null newRowFocusCol = null } catch { @@ -1254,7 +1335,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; } /** @param {number} rowIdx */ - async function deleteRow(rowIdx) { + function deleteRow(rowIdx) { if (readonly) return; if (!primaryKey.length) { toast.error("Cannot delete", { @@ -1263,6 +1344,20 @@ import FilterX from "@lucide/svelte/icons/filter-x"; return; } const rowIndices = rowIndicesToDelete(rowIdx); + const n = rowIndices.length; + requestWrite({ + kind: "delete", + title: n === 1 ? "Review delete" : "Review deletes", + description: `${n === 1 ? "1 row" : `${formatCompactCount(n)} rows`} will be permanently deleted. This cannot be undone.`, + statements: buildDeleteStatements(rowIndices, rows, dmlContext), + confirmLabel: n === 1 ? "Delete row" : `Delete ${formatCompactCount(n)} rows`, + destructive: true, + run: () => executeDeleteRows(rowIndices), + }); + } + + /** @param {number[]} rowIndices */ + async function executeDeleteRows(rowIndices) { try { await ondelete({ rowIndices }); const n = rowIndices.length; @@ -3603,8 +3698,6 @@ import FilterX from "@lucide/svelte/icons/filter-x"; } else { pendingContextMenu = false; suppressMenuSelect = false; - deleteRowConfirmPending = false; - if (_deleteRowConfirmTimer) { clearTimeout(_deleteRowConfirmTimer); _deleteRowConfirmTimer = null; } } }} > @@ -4222,30 +4315,13 @@ import FilterX from "@lucide/svelte/icons/filter-x"; { - if (suppressMenuSelect) return; - if (!deleteRowConfirmPending) { - e.preventDefault(); - deleteRowConfirmPending = true; - if (_deleteRowConfirmTimer) clearTimeout(_deleteRowConfirmTimer); - _deleteRowConfirmTimer = setTimeout(() => { - deleteRowConfirmPending = false; - _deleteRowConfirmTimer = null; - }, 2500); - } else { - deleteRowConfirmPending = false; - if (_deleteRowConfirmTimer) { clearTimeout(_deleteRowConfirmTimer); _deleteRowConfirmTimer = null; } - deleteRow(contextRowIdx); - } - }} + class="whitespace-nowrap" + onSelect={() => runMenuAction(() => deleteRow(contextRowIdx))} > - {deleteRowConfirmPending - ? "Confirm Delete" - : selected.size > 1 && selected.has(contextRowIdx) - ? `Delete ${formatCompactCount(selected.size)} rows` - : "Delete row"} + {selected.size > 1 && selected.has(contextRowIdx) + ? `Delete ${formatCompactCount(selected.size)} rows` + : "Delete row"} ⌘⌫ {/if} @@ -4303,3 +4379,69 @@ import FilterX from "@lucide/svelte/icons/filter-x"; onsave={commitQuickLook} /> + + { if (!o && !dmlPreviewRunning) dmlPreview = null }} +> + + {#if dmlPreview} + + {dmlPreview.title} + + {dmlPreview.description} + + + +
+ + SQL to run{dmlPreview.statements.length > 1 ? ` · ${dmlPreview.statements.length} statements` : ''} + + +
+ +
+
{dmlPreview.statements.join('\n')}
+
+ + + + + + {/if} +
+
+ diff --git a/src/lib/components/SettingsDialog.svelte b/src/lib/components/SettingsDialog.svelte index 478da962..77b81e4f 100644 --- a/src/lib/components/SettingsDialog.svelte +++ b/src/lib/components/SettingsDialog.svelte @@ -99,6 +99,10 @@ settings = updateSettings({ autoReconnectOnStartup: !settings.autoReconnectOnStartup }); } + function togglePreviewDml() { + settings = updateSettings({ previewDmlBeforeApply: !settings.previewDmlBeforeApply }); + } + /** @type {boolean | null} */ let launchAtLogin = $state(null); @@ -329,6 +333,7 @@
{@render toggleRow('Launch at login', 'Start Stroke when you sign in', launchAtLogin ?? false, toggleLaunchAtLogin)} {@render toggleRow('Auto reconnect on startup', 'Reconnect to the last database on launch', settings.autoReconnectOnStartup, toggleAutoReconnect)} + {@render toggleRow('Preview SQL before applying', 'Review the DML before edits, inserts, and deletes run', settings.previewDmlBeforeApply, togglePreviewDml)} {@render toggleRow('MCP auto-start', 'Start the MCP server on database connect', settings.mcpAutoStart, toggleMcpAutoStart)}
diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index 5d0d3f64..3a9452ad 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -4154,6 +4154,7 @@ let rowSearch = $state('') onfetchrelatedrows={handleFetchRelatedRows} schema={activeSchema} tableName={activeTable ?? ''} + dialect={dbType} indexes={activeTableIndexes} {hiddenColumns} {reloadToken} diff --git a/src/lib/dml-preview.js b/src/lib/dml-preview.js new file mode 100644 index 00000000..d920202f --- /dev/null +++ b/src/lib/dml-preview.js @@ -0,0 +1,165 @@ +/** + * DML preview builders. + * + * The backend executes table edits/inserts/deletes with parameterized queries + * (values are bound, never string-interpolated). For a human-readable "review + * before applying" preview we inline the values here so the user can see exactly + * what each write will do. The identifier quoting and statement shapes mirror + * `src-tauri/src/db/*` per engine family; the inlined literals are for display + * only and are NOT what gets sent to the database. + * + * @typedef {'postgres' | 'mysql' | 'sqlite' | 'd1' | 'libsql' | 'duckdb' | 'clickhouse' | 'mssql'} Dialect + * + * @typedef {{ name: string, [k: string]: unknown }} Column + * + * @typedef {{ + * dialect: Dialect, + * schema?: string, + * table: string, + * columns: Column[], + * primaryKey: string[], + * }} DmlContext + */ + +/** Engine families that don't qualify grid writes with a schema. */ +const NO_SCHEMA = new Set(['sqlite', 'd1', 'libsql', 'duckdb']) + +/** + * Quote an identifier for the given dialect. + * @param {string} name @param {Dialect} dialect + */ +export function quoteIdent(name, dialect) { + const s = String(name) + if (dialect === 'mysql') return '`' + s.replace(/`/g, '``') + '`' + return '"' + s.replace(/"/g, '""') + '"' +} + +/** + * Schema-qualified, quoted table name. + * @param {DmlContext} ctx + */ +export function qualifiedTable(ctx) { + const t = quoteIdent(ctx.table, ctx.dialect) + if (ctx.schema && !NO_SCHEMA.has(ctx.dialect)) { + return quoteIdent(ctx.schema, ctx.dialect) + '.' + t + } + return t +} + +/** Single-quote a string literal, doubling embedded quotes. */ +function quoteString(/** @type {string} */ s) { + return "'" + s.replace(/'/g, "''") + "'" +} + +/** + * Render a JS value as a SQL literal for display. + * @param {unknown} value @param {Dialect} dialect + */ +export function sqlLiteral(value, dialect) { + if (value === null || value === undefined) return 'NULL' + if (typeof value === 'number') return Number.isFinite(value) ? String(value) : 'NULL' + if (typeof value === 'bigint') return String(value) + if (typeof value === 'boolean') { + // SQLite/D1/libsql/MySQL have no boolean literal — they store 1/0. + if (dialect === 'sqlite' || dialect === 'd1' || dialect === 'libsql' || dialect === 'mysql') { + return value ? '1' : '0' + } + return value ? 'TRUE' : 'FALSE' + } + if (typeof value === 'object') { + try { return quoteString(JSON.stringify(value)) } catch { return quoteString(String(value)) } + } + return quoteString(String(value)) +} + +/** + * Map column name → index, for reading primary-key values out of a row array. + * @param {Column[]} columns + */ +function nameIndex(columns) { + /** @type {Map} */ + const m = new Map() + columns.forEach((c, i) => m.set(c.name, i)) + return m +} + +/** + * Build the `WHERE` clause that targets a single row by its primary key. + * Returns null when the table has no primary key (writes aren't row-addressable). + * @param {unknown[]} row @param {DmlContext} ctx @param {Map} idx + */ +function pkWhere(row, ctx, idx) { + if (!ctx.primaryKey.length) return null + const parts = ctx.primaryKey.map((pk) => { + const ci = idx.get(pk) + const val = ci == null ? null : row?.[ci] + const col = quoteIdent(pk, ctx.dialect) + return val === null || val === undefined + ? `${col} IS NULL` + : `${col} = ${sqlLiteral(val, ctx.dialect)}` + }) + return parts.join(' AND ') +} + +/** + * One UPDATE statement per staged cell edit — matching the backend, which + * applies edits one cell at a time. + * @param {{ rowIdx: number, colIdx: number, value: unknown }[]} edits + * @param {unknown[][]} rows + * @param {DmlContext} ctx + * @returns {string[]} + */ +export function buildUpdateStatements(edits, rows, ctx) { + const idx = nameIndex(ctx.columns) + const tbl = qualifiedTable(ctx) + /** @type {string[]} */ + const out = [] + for (const e of edits) { + const col = ctx.columns[e.colIdx] + if (!col) continue + const where = pkWhere(rows[e.rowIdx], ctx, idx) + const setClause = `${quoteIdent(col.name, ctx.dialect)} = ${sqlLiteral(e.value, ctx.dialect)}` + out.push(where + ? `UPDATE ${tbl} SET ${setClause} WHERE ${where};` + : `UPDATE ${tbl} SET ${setClause} WHERE /* no primary key */;`) + } + return out +} + +/** + * DELETE statements for the given row indices. Collapses to a single `IN (…)` + * when the table has a single-column primary key (matching the backend). + * @param {number[]} rowIndices + * @param {unknown[][]} rows + * @param {DmlContext} ctx + * @returns {string[]} + */ +export function buildDeleteStatements(rowIndices, rows, ctx) { + const idx = nameIndex(ctx.columns) + const tbl = qualifiedTable(ctx) + if (!ctx.primaryKey.length) { + return [`DELETE FROM ${tbl} WHERE /* no primary key */;`] + } + if (ctx.primaryKey.length === 1) { + const pk = ctx.primaryKey[0] + const ci = idx.get(pk) + const vals = rowIndices.map((ri) => sqlLiteral(ci == null ? null : rows[ri]?.[ci], ctx.dialect)) + return [`DELETE FROM ${tbl} WHERE ${quoteIdent(pk, ctx.dialect)} IN (${vals.join(', ')});`] + } + return rowIndices.map((ri) => `DELETE FROM ${tbl} WHERE ${pkWhere(rows[ri], ctx, idx)};`) +} + +/** + * INSERT statement for a new-row draft (record of column name → value). + * @param {Record} values + * @param {DmlContext} ctx + * @returns {string[]} + */ +export function buildInsertStatements(values, ctx) { + const tbl = qualifiedTable(ctx) + const names = Object.keys(values) + if (!names.length) return [`INSERT INTO ${tbl} DEFAULT VALUES;`] + const cols = names.map((n) => quoteIdent(n, ctx.dialect)).join(', ') + const vals = names.map((n) => sqlLiteral(values[n], ctx.dialect)).join(', ') + return [`INSERT INTO ${tbl} (${cols}) VALUES (${vals});`] +} diff --git a/src/lib/stores/settings.js b/src/lib/stores/settings.js index ea3478aa..0eda5cf7 100644 --- a/src/lib/stores/settings.js +++ b/src/lib/stores/settings.js @@ -13,7 +13,7 @@ const STORAGE_KEY = 'stroke:settings' /** @typedef {import('$lib/themes/registry.js').ThemeId} ThemeId */ /** @typedef {'geist' | 'serif' | 'apple'} FontId */ /** @typedef {'regular' | 'light' | 'bold'} IconStyleId */ -/** @typedef {{ theme: ThemeId, zoom: number, font: FontId, iconStyle: IconStyleId, mcpAutoStart: boolean, launchAtLogin: boolean, autoReconnectOnStartup: boolean }} AppSettings */ +/** @typedef {{ theme: ThemeId, zoom: number, font: FontId, iconStyle: IconStyleId, mcpAutoStart: boolean, launchAtLogin: boolean, autoReconnectOnStartup: boolean, previewDmlBeforeApply: boolean }} AppSettings */ /** UI zoom scale (font + layout). 1 = 100%. */ export const ZOOM_STEPS = [0.8, 0.85, 0.9, 0.95, 1, 1.05, 1.1, 1.15, 1.25, 1.5] @@ -79,6 +79,7 @@ export const DEFAULT_SETTINGS = { mcpAutoStart: false, launchAtLogin: false, autoReconnectOnStartup: true, + previewDmlBeforeApply: true, } /** Reactive app font id (synced by applySettings). */ @@ -94,6 +95,9 @@ export const appZoom = writable(DEFAULT_ZOOM) /** Reactive app theme id (synced by applySettings). */ export const appThemeId = writable(/** @type {ThemeId} */ (DEFAULT_THEME_ID)) +/** Reactive: show a SQL preview/confirm before applying grid writes (synced by applySettings). */ +export const appPreviewDml = writable(true) + const LAST_DARK_KEY = 'stroke:last-dark-theme' const LAST_LIGHT_KEY = 'stroke:last-light-theme' @@ -152,9 +156,10 @@ export function loadSettings() { const mcpAutoStart = parsed.mcpAutoStart === true const launchAtLogin = parsed.launchAtLogin === true const autoReconnectOnStartup = parsed.autoReconnectOnStartup !== false + const previewDmlBeforeApply = parsed.previewDmlBeforeApply !== false const font = normalizeFont(parsed.font) const iconStyle = normalizeIconStyle(parsed.iconStyle) - return { theme, zoom, font, iconStyle, mcpAutoStart, launchAtLogin, autoReconnectOnStartup } + return { theme, zoom, font, iconStyle, mcpAutoStart, launchAtLogin, autoReconnectOnStartup, previewDmlBeforeApply } } catch { return { ...DEFAULT_SETTINGS } } @@ -210,6 +215,9 @@ export function applySettings(settings) { root.setAttribute('data-icon-style', iconStyle) appIconStyle.set(iconStyle) + // Grid-write DML preview toggle — DataTable subscribes to gate its confirm dialog. + appPreviewDml.set(settings.previewDmlBeforeApply !== false) + // Keep the canvas-table zoom in lockstep with the app zoom so Cmd +/-/0 (and // the zoom buttons) scale the grid alongside the rest of the UI. The canvas // renderer reads zoomState directly and repaints on change. From 529ec57e1b780cf867af92de7312868e88e7d8b3 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Sat, 4 Jul 2026 23:06:03 +0545 Subject: [PATCH 03/20] feat(data): staged deletes, edit persistence, close guards, export progress, FK scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DataTable: stage row deletes as a red diff (minus + strikethrough), batched into Apply with a syntax-highlighted (ShikiBlock) SQL preview - Preserve staged edits/deletes across tab switches & unmount via a module cache (pending-table-edits.js); stable persist-key survives teardown - Warn before closing a tab or quitting with unsaved changes - StatusBar: go-to-first/last-column controls + ⌘⌥←/→ shortcuts (shown only on horizontal overflow) - Export: chunked async CSV/JSON builders + loading toast so large exports no longer freeze the UI - AiChat: diagram Save↔Check toggle instead of a permanent 'saved' check; drop silent auto-save - FkSubviewPanel: shift+scroll now scrolls the sub-view, not the main grid --- src/lib/components/AiChat.svelte | 25 +- src/lib/components/DataTable.svelte | 331 ++++++++++++++++------- src/lib/components/FkSubviewPanel.svelte | 1 + src/lib/components/StatusBar.svelte | 18 +- src/lib/components/StudioShell.svelte | 141 ++++++++-- src/lib/export.js | 71 ++++- src/lib/stores/pending-table-edits.js | 57 ++++ 7 files changed, 514 insertions(+), 130 deletions(-) create mode 100644 src/lib/stores/pending-table-edits.js diff --git a/src/lib/components/AiChat.svelte b/src/lib/components/AiChat.svelte index 38a767b6..978413fd 100644 --- a/src/lib/components/AiChat.svelte +++ b/src/lib/components/AiChat.svelte @@ -1295,6 +1295,8 @@ /** IDs of chart items the user has already saved this session */ let savedChartIds = $state(/** @type {Set} */ (new Set())); + /** IDs of diagram items the user has explicitly saved this session */ + let savedDiagramIds = $state(/** @type {Set} */ (new Set())); /** Blocks collapsed by user (in set = collapsed; default open for both code and SQL) */ let collapsed = $state(/** @type {Set} */ (new Set())); @@ -1931,7 +1933,6 @@ if (execIdx >= 0) items.splice(execIdx, 1); toolResult = JSON.stringify({ error: "No diagram code provided." }); } else { - saveDiagram(title, code); const diagId = uid(); const execIdx = items.findIndex((i) => i.id === execId); const diagItem = /** @type {ChatItem} */ ({ @@ -1947,7 +1948,7 @@ success: true, title, diagramType, - message: "Diagram rendered and saved to Diagrams library.", + message: "Diagram rendered. The user can save it to the Diagrams library.", }); } } else if (call.function.name === "list_tables") { @@ -3287,9 +3288,23 @@ > { + if (savedDiagramIds.has(item.id)) return; + saveDiagram(item.title || "Diagram", item.code); + savedDiagramIds = new Set([...savedDiagramIds, item.id]); + toast.success("Diagram saved", { + description: "View it in the Diagrams page", + action: onopendiagramspage + ? { label: "Open", onClick: onopendiagramspage } + : undefined, + }); + }} + >{#if savedDiagramIds.has(item.id)}{:else}Save{/if}
diff --git a/src/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte index 29c116b8..b11b77ff 100644 --- a/src/lib/components/DataTable.svelte +++ b/src/lib/components/DataTable.svelte @@ -82,6 +82,12 @@ import FilterX from "@lucide/svelte/icons/filter-x"; buildInsertStatements, } from "$lib/dml-preview.js"; import * as Dialog from "$lib/components/ui/dialog/index.js"; + import ShikiBlock from "./ShikiBlock.svelte"; + import { + savePendingChanges, + loadPendingChanges, + clearPendingChanges, + } from "$lib/stores/pending-table-edits.js"; import { formatCellValue, transformsFor, enabledGenerators, linkifyValue, statsNeeded, annotatorEnabled, anyDisplayExtEnabled } from "$lib/plugins/registry.js"; import { pluginState } from "$lib/stores/plugins.js"; import Wand2 from "@lucide/svelte/icons/wand-2"; @@ -180,6 +186,13 @@ import FilterX from "@lucide/svelte/icons/filter-x"; * table to the top / bottom. */ scrollToTop = $bindable(/** @type {() => void} */ (() => {})), scrollToBottom = $bindable(/** @type {() => void} */ (() => {})), + /** Assigned by this component; the parent (StatusBar) calls these to jump the + * table to the far left / right when it scrolls horizontally. */ + scrollToLeft = $bindable(/** @type {() => void} */ (() => {})), + scrollToRight = $bindable(/** @type {() => void} */ (() => {})), + /** Bindable: true when the grid content is wider than the viewport (so the + * parent can show the horizontal go-to-edge controls). */ + canScrollHorizontally = $bindable(false), /** Assigned by this component; the parent (toolbar "Jump to column" menu) * calls focusColumn(name) to scroll a column into view and briefly * highlight it. */ @@ -209,6 +222,9 @@ import FilterX from "@lucide/svelte/icons/filter-x"; insertSaving = false, /** Assigned by this component so the parent can trigger beginInsertRow(). */ beginInsertRow = $bindable(/** @type {() => void} */ (() => {})), + /** Assigned by this component so the parent (⌘⌫ / toolbar) can stage the + * selected rows for deletion instead of deleting them immediately. */ + stageDeleteSelected = $bindable(/** @type {() => void} */ (() => {})), /** When true all write operations (edit, delete, insert) are disabled. */ readonly = false, /** Bindable: controls whether the virtual columns management panel is open. */ @@ -235,12 +251,33 @@ import FilterX from "@lucide/svelte/icons/filter-x"; * The cell shows the staged value (marked dirty) until the user clicks Apply. * @type {Map} */ - let pendingEdits = $state(new Map()); + // Restore any changes staged for this table before the component last unmounted + // (switching to a SQL/AI tab, or another table tab, tears DataTable down). + const _restoredPending = untrack(() => loadPendingChanges(columnWidthsKey ?? '')); + + // Stable table key for persistence. `columnWidthsKey` derives from the parent's + // `activeTable`, which is nulled during teardown when switching to a SQL/AI tab — + // so reading it in onDestroy would lose the key. Track the last non-empty value. + let _persistKey = untrack(() => columnWidthsKey ?? ''); + $effect(() => { if (columnWidthsKey) _persistKey = columnWidthsKey; }); + + let pendingEdits = $state(_restoredPending.edits); /** Cheap gate so per-cell staged-edit lookups are skipped entirely when there * are no unsaved edits (the common case) — avoids a string alloc + Map.get * on every cell of large tables. */ const hasPendingEdits = $derived(pendingEdits.size > 0); + /** + * Row indices staged for deletion — shown with a red diff marker until Apply. + * Kept separate from `pendingEdits` so a row can be edited then deleted, and + * so the gutter/row rendering can distinguish the two. + * @type {Set} + */ + let pendingDeletes = $state(_restoredPending.deletes); + const hasPendingDeletes = $derived(pendingDeletes.size > 0); + /** Any unsaved change (edit or delete) — drives the tab/close guards. */ + const hasPendingChanges = $derived(pendingEdits.size > 0 || pendingDeletes.size > 0); + /** * DML preview / confirm dialog. Non-null while open. Every write path (apply * staged edits, insert a new row, delete rows) routes through this so the user @@ -276,17 +313,6 @@ import FilterX from "@lucide/svelte/icons/filter-x"; } } - /** Copy the previewed statements to the clipboard. */ - async function copyDmlPreview() { - if (!dmlPreview) return; - try { - await navigator.clipboard.writeText(dmlPreview.statements.join("\n")); - toast.success("SQL copied"); - } catch (err) { - toast.error("Could not copy", { description: String(err) }); - } - } - /** @type {HTMLInputElement | HTMLSelectElement | HTMLButtonElement | null} */ let editInput = $state(null); @@ -969,27 +995,35 @@ import FilterX from "@lucide/svelte/icons/filter-x"; } } - /** Open the DML preview for the staged edits; the user confirms to flush them. */ + /** Open the DML preview for all staged changes (edits + deletes); confirm flushes them. */ function applyPendingEdits() { - if (pendingEdits.size === 0 || saving) return; - const entries = [...pendingEdits.values()]; - const statements = buildUpdateStatements(entries, rows, dmlContext); - const n = entries.length; + if (!hasPendingChanges || 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); + const statements = [ + ...buildUpdateStatements(editEntries, rows, dmlContext), + ...(deleteIndices.length ? buildDeleteStatements(deleteIndices, rows, 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`); requestWrite({ - kind: "update", + kind: deleteIndices.length ? "delete" : "update", title: "Review changes", - description: `${n} cell${n === 1 ? "" : "s"} will be updated.`, + description: `${parts.join(", ")}.${deleteIndices.length ? " Deletions cannot be undone." : ""}`, statements, - confirmLabel: `Apply ${n} change${n === 1 ? "" : "s"}`, - destructive: false, - run: executePendingEdits, + confirmLabel: "Apply changes", + destructive: deleteIndices.length > 0, + run: executePendingChanges, }); } - /** Flush all staged edits to the database. */ - async function executePendingEdits() { - if (pendingEdits.size === 0 || saving) return; - const entries = [...pendingEdits.values()]; + /** Flush all staged edits and deletes to the database. */ + async function executePendingChanges() { + if (!hasPendingChanges || 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} */ const failed = []; let okCount = 0; @@ -1002,23 +1036,47 @@ import FilterX from "@lucide/svelte/icons/filter-x"; toast.error("Could not save", { description: String(err) }); } } - // Keep only the edits that failed so the user can retry / reset them. + + let deletedCount = 0; + const deleteIndices = [...pendingDeletes].sort((a, b) => a - b); + if (deleteIndices.length) { + try { + await ondelete({ rowIndices: deleteIndices }); + deletedCount = deleteIndices.length; + pendingDeletes = new Set(); + } catch (err) { + toast.error("Could not delete", { description: String(err) }); + } + } + + // 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. const next = new Map(); - for (const edit of failed) next.set(editKey(edit.rowIdx, edit.colIdx), edit); + if (deletedCount === 0) { + for (const edit of failed) next.set(editKey(edit.rowIdx, edit.colIdx), edit); + } pendingEdits = next; pastEdits = []; futureEdits = []; - if (okCount > 0) { - toast.success("Changes applied", { description: `${okCount} cell${okCount === 1 ? "" : "s"} updated` }); + // Sync the cross-unmount cache to the post-apply state (clears it when empty). + savePendingChanges(_persistKey, pendingEdits, pendingDeletes); + if (okCount > 0 || deletedCount > 0) { + const parts = []; + if (okCount > 0) parts.push(`${okCount} cell${okCount === 1 ? "" : "s"} updated`); + if (deletedCount > 0) parts.push(`${deletedCount} row${deletedCount === 1 ? "" : "s"} deleted`); + toast.success("Changes applied", { description: parts.join(", ") }); } } - /** Discard all staged edits. */ + /** Discard all staged edits and deletes. */ function resetPendingEdits() { - if (pendingEdits.size === 0) return; + if (!hasPendingChanges) return; pendingEdits = new Map(); + pendingDeletes = new Set(); pastEdits = []; futureEdits = []; + clearPendingChanges(columnWidthsKey ?? ''); } // Surface staged-edit state to the parent (→ StatusBar Apply/Reset buttons). @@ -1026,12 +1084,14 @@ import FilterX from "@lucide/svelte/icons/filter-x"; applyEdits = applyPendingEdits; resetEdits = resetPendingEdits; }); - $effect(() => { pendingEditCount = pendingEdits.size; }); + $effect(() => { pendingEditCount = pendingEdits.size + pendingDeletes.size; }); // Surface scroll-to-top / scroll-to-bottom to the parent (→ StatusBar buttons). $effect(() => { scrollToTop = () => tableContainer?.scrollTo({ top: 0 }); scrollToBottom = () => { if (tableContainer) tableContainer.scrollTo({ top: tableContainer.scrollHeight }); }; + scrollToLeft = () => tableContainer?.scrollTo({ left: 0 }); + scrollToRight = () => { if (tableContainer) tableContainer.scrollTo({ left: tableContainer.scrollWidth }); }; focusColumn = focusColumnByName; getScroll = () => ({ left: _scrollLeft, top: _scrollTop }); applyScroll = (pos) => { @@ -1070,6 +1130,21 @@ import FilterX from "@lucide/svelte/icons/filter-x"; } }) + // Surface staged-delete-of-selection to the parent (⌘⌫ / toolbar delete). + $effect(() => { + stageDeleteSelected = () => { + if (readonly || selected.size === 0) return; + if (!primaryKey.length) { + toast.error("Cannot delete", { description: "This table has no primary key." }); + return; + } + const next = new Set(pendingDeletes); + for (const ri of selected) next.add(ri); + pendingDeletes = next; + scheduleDraw(); + } + }) + function cancelNewRow() { newRowDrafts = null newRowFocusCol = null @@ -1334,7 +1409,11 @@ import FilterX from "@lucide/svelte/icons/filter-x"; return [rowIdx]; } - /** @param {number} rowIdx */ + /** + * Stage the row(s) for deletion — shown with a red diff marker until Apply. + * Deletes are batched with edits and flushed together from the Apply button. + * @param {number} rowIdx + */ function deleteRow(rowIdx) { if (readonly) return; if (!primaryKey.length) { @@ -1344,29 +1423,19 @@ import FilterX from "@lucide/svelte/icons/filter-x"; return; } const rowIndices = rowIndicesToDelete(rowIdx); - const n = rowIndices.length; - requestWrite({ - kind: "delete", - title: n === 1 ? "Review delete" : "Review deletes", - description: `${n === 1 ? "1 row" : `${formatCompactCount(n)} rows`} will be permanently deleted. This cannot be undone.`, - statements: buildDeleteStatements(rowIndices, rows, dmlContext), - confirmLabel: n === 1 ? "Delete row" : `Delete ${formatCompactCount(n)} rows`, - destructive: true, - run: () => executeDeleteRows(rowIndices), - }); + const next = new Set(pendingDeletes); + for (const ri of rowIndices) next.add(ri); + pendingDeletes = next; + scheduleDraw(); } - /** @param {number[]} rowIndices */ - async function executeDeleteRows(rowIndices) { - try { - await ondelete({ rowIndices }); - const n = rowIndices.length; - toast.success( - n === 1 ? "Row deleted" : `${formatCompactCount(n)} rows deleted`, - ); - } catch (err) { - toast.error("Could not delete", { description: String(err) }); - } + /** Unstage a row previously marked for deletion. @param {number} rowIdx */ + function undoDeleteRow(rowIdx) { + if (!pendingDeletes.has(rowIdx)) return; + const next = new Set(pendingDeletes); + next.delete(rowIdx); + pendingDeletes = next; + scheduleDraw(); } /** @param {number} rowIdx */ @@ -1646,6 +1715,11 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // Total scrollable width includes virtual expr cols + virtual rel columns const totalContentWidth = $derived(geom.totalWidth + vexprTotalW + virtualRelCols.length * VIRTUAL_COL_W) + // Surface whether the grid overflows horizontally so the parent can show the + // go-to-left / go-to-right controls only when they'd actually do something. + $effect(() => { + canScrollHorizontally = totalContentWidth > _viewportWidth + 1 + }) // Insert row spans ALL columns (including hidden) so every field can be filled. const insertRowTotalWidth = $derived( gutterWidth + columns.reduce((acc, c) => acc + widthForColumn(c.name, c.dataType ?? c.data_type ?? ''), 0) @@ -1762,9 +1836,21 @@ import FilterX from "@lucide/svelte/icons/filter-x"; _scrollTop = 0 if (tableContainer && tableContainer.scrollTop !== 0) tableContainer.scrollTop = 0 fkSubview = null + // A fresh page of rows (page/filter/sort/search) invalidates row-index-keyed + // staged changes — drop them and their cache entry so a later Apply can't + // target the wrong rows. + if (pendingEdits.size) pendingEdits = new Map() + if (pendingDeletes.size) pendingDeletes = new Set() + clearPendingChanges(columnWidthsKey ?? '') }) }) + // Persist staged changes when the component tears down (switching to a SQL/AI + // tab unmounts DataTable) so they survive until the user returns to the table. + onDestroy(() => { + savePendingChanges(_persistKey, pendingEdits, pendingDeletes) + }) + const allSelected = $derived( rows.length > 0 && selected.size === rows.length, ); @@ -2027,11 +2113,17 @@ import FilterX from "@lucide/svelte/icons/filter-x"; expandedRows: new Set(expandedRows), fkSubview: fkSubview, }) + // Preserve unsaved edits/deletes for the table we're leaving so they're + // restored when the user returns instead of being silently discarded. + savePendingChanges(_lastTabKey, pendingEdits, pendingDeletes) } // Restore state for the tab we're entering (fresh Set/null if first visit) const saved = _tabExpandCache.get(newKey) expandedRows = saved ? new Set(saved.expandedRows) : new Set() fkSubview = saved?.fkSubview ?? null + const restored = loadPendingChanges(newKey) + pendingEdits = restored.edits + pendingDeletes = restored.deletes // Always reset non-content states focusedRow = null focusedCol = null @@ -2043,15 +2135,6 @@ import FilterX from "@lucide/svelte/icons/filter-x"; }) }); - // Drop staged edits when the table changes or rows are reordered (sort), - // since edits are keyed by row index — applying them afterwards could target - // the wrong rows. - $effect(() => { - void columnWidthsKey; - void (rowSort ? `${rowSort.column}:${rowSort.direction}` : ""); - untrack(() => { if (pendingEdits.size) pendingEdits = new Map(); }); - }); - // Document-level capture so undo/redo fires even during the brief window between // editingCell being cleared and the container div regaining focus. $effect(() => { @@ -2495,6 +2578,8 @@ import FilterX from "@lucide/svelte/icons/filter-x"; const AMBER = 'rgb(245, 158, 11)' const AMBER_FG = 'rgba(251, 191, 36, 0.85)' const BLUE_FG = 'rgba(96, 165, 250, 0.8)' + // Staged-delete diff marker (red — matches the destructive accent). + const RED = 'rgb(239, 68, 68)' ctx.imageSmoothingEnabled = false ctx.clearRect(0, 0, W, H) @@ -2524,7 +2609,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; if (ry + ROW_HEIGHT <= HEADER_H) continue drawBodyRow(ctx, i, ry, { cFg, cText, cMuted, cGrid, cMutedBg, cRing, cAccent, cPanel, usedW, navName, - AMBER, BLUE_FG, cPrimary, frozenW, + AMBER, BLUE_FG, RED, cPrimary, frozenW, }) } } @@ -2544,7 +2629,12 @@ import FilterX from "@lucide/svelte/icons/filter-x"; const rh = ROW_HEIGHT // Row background — selected uses primary tint, others use muted. const isSel = selected.has(idx) - if (isSel) { + const isPendingDelete = hasPendingDeletes && pendingDeletes.has(idx) + if (isPendingDelete) { + // Red diff tint for rows staged for deletion. + ctx.fillStyle = withAlpha(c.RED, hoveredRow === idx ? 0.2 : 0.14) + ctx.fillRect(0, ry, c.usedW, rh) + } else if (isSel) { ctx.fillStyle = withAlpha(c.cPrimary, hoveredRow === idx ? 0.18 : 0.13) ctx.fillRect(0, ry, c.usedW, rh) } else if (focusedRow === idx) { @@ -2575,6 +2665,19 @@ import FilterX from "@lucide/svelte/icons/filter-x"; drawRowGutters(ctx, idx, -_scrollLeft, ry, rh, c) + // Staged-delete decoration: a left red bar + a strikethrough across the row. + if (isPendingDelete) { + ctx.fillStyle = c.RED + ctx.fillRect(0, ry, 2, rh) + ctx.strokeStyle = withAlpha(c.RED, 0.7) + ctx.lineWidth = 1 + const sy = Math.round(ry + rh / 2) + 0.5 + ctx.beginPath() + ctx.moveTo(0, sy) + ctx.lineTo(c.usedW, sy) + ctx.stroke() + } + // Row ring when focused + selected. if (focusedRow === idx && isSel) { ctx.strokeStyle = withAlpha(c.cPrimary, 0.45) @@ -2882,16 +2985,35 @@ import FilterX from "@lucide/svelte/icons/filter-x"; /** @param {CanvasRenderingContext2D} ctx @param {number} offsetX scroll-adjusted left offset */ function drawRowGutters(ctx, idx, offsetX, ry, rh, c) { if (gutterWidth <= 0) return + const isPendingDelete = hasPendingDeletes && pendingDeletes.has(idx) ctx.fillStyle = c.cPanel ctx.fillRect(offsetX, ry, gutterWidth, rh) + if (isPendingDelete) { + // Red diff tint over the gutter so the marker reads on the same band. + ctx.fillStyle = withAlpha(c.RED, hoveredRow === idx ? 0.2 : 0.14) + ctx.fillRect(offsetX, ry, gutterWidth, rh) + } let gx = offsetX if (showRowExpand) { - const expanded = expandedRows.has(idx) || fkSubview?.rowIdx === idx - const hov = hoveredRow === idx - if (expanded || hov) { - drawIcon(ctx, expanded ? 'chevron-down' : 'chevron-right', - gx + (GUTTER_EXPAND_W - 14) / 2, ry + (rh - 14) / 2, 14, - expanded ? c.cFg : c.cMuted, 2) + if (isPendingDelete) { + // A red minus in place of the expand chevron marks the staged deletion. + ctx.strokeStyle = c.RED + ctx.lineWidth = 2 + ctx.lineCap = 'round' + const cx = gx + GUTTER_EXPAND_W / 2 + const cy = Math.round(ry + rh / 2) + 0.5 + ctx.beginPath() + ctx.moveTo(cx - 5, cy) + ctx.lineTo(cx + 5, cy) + ctx.stroke() + } else { + const expanded = expandedRows.has(idx) || fkSubview?.rowIdx === idx + const hov = hoveredRow === idx + if (expanded || hov) { + drawIcon(ctx, expanded ? 'chevron-down' : 'chevron-right', + gx + (GUTTER_EXPAND_W - 14) / 2, ry + (rh - 14) / 2, 14, + expanded ? c.cFg : c.cMuted, 2) + } } gx += GUTTER_EXPAND_W } @@ -3230,10 +3352,21 @@ import FilterX from "@lucide/svelte/icons/filter-x"; if (!el) return const onShiftWheel = (/** @type {WheelEvent} */ e) => { if (!e.shiftKey) return - if (e.deltaY || e.deltaX) { + const delta = e.deltaY || e.deltaX + if (!delta) return + // If the pointer is over a nested horizontally-scrollable panel (the FK + // sub-view), scroll that instead of the main grid — otherwise shift+scroll + // over the sub-view would move the table underneath it. + const inner = e.target instanceof Element + ? e.target.closest('[data-fk-subview-scroll]') + : null + if (inner && inner !== el && inner.scrollWidth > inner.clientWidth) { e.preventDefault() - el.scrollLeft += e.deltaY || e.deltaX + inner.scrollLeft += delta + return } + e.preventDefault() + el.scrollLeft += delta } el.addEventListener('wheel', onShiftWheel, { passive: false }) return () => el.removeEventListener('wheel', onShiftWheel) @@ -3243,7 +3376,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; $effect(() => { void rows; void columns; void columnWidths void pinnedColumns; void hiddenColumns; void selected; void focusedRow - void focusedCol; void editingCell; void pendingEdits; void expandedRows + void focusedCol; void editingCell; void pendingEdits; void pendingDeletes; void expandedRows void rowSort; void _viewportWidth // NOTE: _scrollTop / _scrollLeft are intentionally NOT tracked here — scrolling // repaints via scheduleDraw() inside onContainerScroll, so this effect (which @@ -4312,18 +4445,29 @@ import FilterX from "@lucide/svelte/icons/filter-x"; Duplicate row - runMenuAction(() => deleteRow(contextRowIdx))} - > - - {selected.size > 1 && selected.has(contextRowIdx) - ? `Delete ${formatCompactCount(selected.size)} rows` - : "Delete row"} - ⌘⌫ - + {#if pendingDeletes.has(contextRowIdx)} + runMenuAction(() => undoDeleteRow(contextRowIdx))} + > + + Undo delete + + {:else} + runMenuAction(() => deleteRow(contextRowIdx))} + > + + {selected.size > 1 && selected.has(contextRowIdx) + ? `Delete ${formatCompactCount(selected.size)} rows` + : "Delete row"} + ⌘⌫ + + {/if} {/if} {/if} @@ -4397,18 +4541,11 @@ import FilterX from "@lucide/svelte/icons/filter-x"; SQL to run{dmlPreview.statements.length > 1 ? ` · ${dmlPreview.statements.length} statements` : ''} - -
-
{dmlPreview.statements.join('\n')}
+ +
+
diff --git a/src/lib/components/FkSubviewPanel.svelte b/src/lib/components/FkSubviewPanel.svelte index a0c073e5..53475b8a 100644 --- a/src/lib/components/FkSubviewPanel.svelte +++ b/src/lib/components/FkSubviewPanel.svelte @@ -82,6 +82,7 @@ class="overflow-x-auto" class:overflow-y-auto={needsScroll} style={needsScroll ? 'max-height: 280px' : ''} + data-fk-subview-scroll > diff --git a/src/lib/components/StatusBar.svelte b/src/lib/components/StatusBar.svelte index 7ec0c134..890818f3 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -27,6 +27,8 @@ import Undo2 from '@lucide/svelte/icons/undo-2' import ChevronsUp from '@lucide/svelte/icons/chevrons-up' import ChevronsDown from '@lucide/svelte/icons/chevrons-down' + import ChevronsLeft from '@lucide/svelte/icons/chevrons-left' + import ChevronsRight from '@lucide/svelte/icons/chevrons-right' import Plus from '@lucide/svelte/icons/plus' import MoreHorizontal from '@lucide/svelte/icons/more-horizontal' import GitBranch from '@lucide/svelte/icons/git-branch' @@ -81,6 +83,10 @@ showTableNav = false, onscrolltabletop = /** @type {() => void} */ (() => {}), onscrolltablebottom = /** @type {() => void} */ (() => {}), + /** Show the go-to-left / go-to-right controls only when the grid overflows. */ + canScrollTableHorizontally = false, + onscrolltableleft = /** @type {() => void} */ (() => {}), + onscrolltableright = /** @type {() => void} */ (() => {}), /** Live mode (auto-refresh active table) — Postgres/SQLite only. */ live = false, liveSupported = false, @@ -584,12 +590,20 @@ {#if showTableNav} {@render sep()}
- - + {#if canScrollTableHorizontally} + + + {/if}
{/if} diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index 3a9452ad..2ed1659f 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -140,6 +140,11 @@ cloneTableTabState, cloneSqlTabState, } from '$lib/studio-tabs.js' + import { + pendingChangesCount, + clearPendingChanges, + anyPendingChanges, + } from '$lib/stores/pending-table-edits.js' import { createNotebook, deserializeNotebook, titleFromPath } from '$lib/notebook.js' import { openNotebookFile } from '$lib/api.js' import { formatCompactCount, normalizeTableRowCount } from '$lib/table-list.js' @@ -196,7 +201,7 @@ remapNullableRowIndex, remapRowIndexSet, } from '$lib/table-row-indices.js' - import { rowsToCsv, rowsToJson, saveExportFile, buildExportFilename } from '$lib/export.js' + import { rowsToCsv, rowsToJson, rowsToCsvAsync, rowsToJsonAsync, saveExportFile, buildExportFilename } from '$lib/export.js' import { recordQueryExecution, listQueryHistory, @@ -521,11 +526,56 @@ let applyEdits = $state(() => {}) /** @type {() => void} */ let resetEdits = $state(() => {}) - // ── Table scroll controls (StatusBar go-to-top / go-to-bottom) ── + /** Bound from DataTable — stages the selected rows for deletion (red diff). */ + let stageDeleteSelectedRows = $state(() => {}) + + /** Table key ("schema.table") for a tab, or '' for non-table tabs. */ + function tabTableKey(/** @type {any} */ tab) { + return tab?.kind === 'table' && tab.state?.table + ? `${tab.state.schema}.${tab.state.table}` + : '' + } + + /** Count of unsaved changes staged in a given tab (live for the active one, cached otherwise). */ + function tabPendingCount(/** @type {any} */ tab) { + if (!tab) return 0 + if (tab.id === activeTabId) return pendingEditCount + const key = tabTableKey(tab) + return key ? pendingChangesCount(key) : 0 + } + + /** True when any table (active or backgrounded) has unsaved staged changes. */ + function hasAnyUnsavedChanges() { + return pendingEditCount > 0 || anyPendingChanges() + } + + // Confirm before quitting the app while table edits are still unsaved. + onMount(() => { + let unlisten = () => {} + ;(async () => { + try { + const { getCurrentWindow } = await import('@tauri-apps/api/window') + unlisten = await getCurrentWindow().onCloseRequested((event) => { + if (hasAnyUnsavedChanges()) { + const ok = window.confirm('You have unsaved table changes. Discard them and quit?') + if (!ok) event.preventDefault() + } + }) + } catch { /* non-Tauri / web preview — no window close event */ } + })() + return () => unlisten() + }) + // ── Table scroll controls (StatusBar go-to-top / go-to-bottom / left / right) ── /** @type {() => void} */ let scrollTableTop = $state(() => {}) /** @type {() => void} */ let scrollTableBottom = $state(() => {}) + /** @type {() => void} */ + let scrollTableLeft = $state(() => {}) + /** @type {() => void} */ + let scrollTableRight = $state(() => {}) + /** True when the active grid overflows horizontally (from DataTable). */ + let tableCanScrollH = $state(false) /** @type {(name: string) => void} */ let focusTableColumn = $state(() => {}) // Read/restore the data grid's scroll so horizontal (+ vertical) position is @@ -1240,7 +1290,7 @@ let rowSearch = $state('') ) return if (activeTab?.kind !== 'table' || !activeTable || selected.size === 0) return e.preventDefault() - void deleteSelectedRows() + stageDeleteSelectedRows() }) createHotkey('Mod+R', (e) => { @@ -1258,7 +1308,7 @@ let rowSearch = $state('') /** @param {KeyboardEvent} e */ function onArrowKey(e) { const mod = e.ctrlKey || e.metaKey - if (!mod || e.altKey) return + if (!mod) return if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) return if (commandOpen || showConnectionModal || showSettingsModal) return const el = document.activeElement @@ -1268,6 +1318,14 @@ let rowSearch = $state('') (el instanceof HTMLElement && el.isContentEditable) ) return + // Ctrl/Cmd+Alt+Left/Right → scroll grid to the first / last column. + if (e.altKey) { + if (activeTab?.kind !== 'table' || !activeTable) return + if (e.key === 'ArrowLeft') { e.preventDefault(); scrollTableLeft(); return } + if (e.key === 'ArrowRight') { e.preventDefault(); scrollTableRight(); return } + return + } + // Ctrl/Cmd+Up → scroll table to top; Ctrl/Cmd+Down → scroll table to bottom if (e.key === 'ArrowUp' && !e.shiftKey) { e.preventDefault() @@ -1781,6 +1839,18 @@ let rowSearch = $state('') async function closeTab(id) { const idx = tabs.findIndex((t) => t.id === id) if (idx < 0) return + // Guard: closing a tab discards its unsaved edits/deletes. + const closing = tabs[idx] + const count = tabPendingCount(closing) + if (count > 0) { + const ok = window.confirm( + `This table has ${count} unsaved change${count === 1 ? '' : 's'}. Close the tab and discard them?`, + ) + if (!ok) return + if (id === activeTabId) resetEdits() + const key = tabTableKey(closing) + if (key) clearPendingChanges(key) + } const nextTabs = tabs.filter((t) => t.id !== id) if (nextTabs.length === 0) { tabs = [createWelcomeTab()] @@ -2411,6 +2481,9 @@ let rowSearch = $state('') fetchingTabIds.delete(tabId) return } + // A fresh fetch replaces the row set, so any row-index-keyed staged changes + // cached for this table no longer line up — drop them. + clearPendingChanges(`${s.schema}.${s.table}`) // Single helper to patch the tab state — avoids multiple tabs.map() calls per fetch /** @param {Partial} patch */ @@ -2614,14 +2687,48 @@ let rowSearch = $state('') } /** @param {'csv' | 'json'} format */ + let exportingData = $state(false) + async function handleExport(format) { + if (exportingData) return const exportRows = selected.size > 0 ? [...selected].sort((a, b) => a - b).map((i) => rows[i]).filter(Boolean) : rows - const content = format === 'csv' ? rowsToCsv(columns, exportRows) : rowsToJson(columns, exportRows) const filename = buildExportFilename(activeTable, format) - await saveExportFile(content, filename, format) - recordActivity({ type: 'export', title: `Exported ${activeTable} as ${format.toUpperCase()}`, schema: activeSchema, table: activeTable ?? undefined, rowCount: exportRows.length, success: true, detail: filename }) + const n = exportRows.length + // Small exports build instantly; only large ones need the async/progress path. + const LARGE = 20000 + exportingData = true + // A persistent toast that stays up for the whole build/save, dismissed on completion. + const toastId = toast.info(`Exporting ${formatCompactCount(n)} rows…`, { + description: `Preparing ${format.toUpperCase()} — please wait`, + duration: 60 * 60 * 1000, + }) + try { + // Yield once so the toast paints before any heavy synchronous work. + await new Promise((res) => setTimeout(res, 16)) + /** @type {string} */ + let content + if (n > LARGE) { + content = format === 'csv' + ? await rowsToCsvAsync(columns, exportRows) + : await rowsToJsonAsync(columns, exportRows) + } else { + content = format === 'csv' ? rowsToCsv(columns, exportRows) : rowsToJson(columns, exportRows) + } + const saved = await saveExportFile(content, filename, format) + toast.dismiss(toastId) + if (saved) { + toast.success(`Exported ${formatCompactCount(n)} rows`, { description: filename }) + recordActivity({ type: 'export', title: `Exported ${activeTable} as ${format.toUpperCase()}`, schema: activeSchema, table: activeTable ?? undefined, rowCount: n, success: true, detail: filename }) + } + } catch (e) { + toast.dismiss(toastId) + toast.error('Export failed', { description: String(e) }) + recordActivity({ type: 'export', title: `Failed to export ${activeTable} as ${format.toUpperCase()}`, schema: activeSchema, table: activeTable ?? undefined, success: false, error: String(e) }) + } finally { + exportingData = false + } } /** @@ -3148,17 +3255,6 @@ let rowSearch = $state('') await handleDeleteRows(detail.rowIndices) } - async function deleteSelectedRows() { - if (selected.size === 0) return - const n = selected.size - try { - await handleDeleteRows([...selected]) - toast.success(n === 1 ? 'Row deleted' : `${formatCompactCount(n)} rows deleted`) - } catch (err) { - toast.error('Could not delete', { description: String(err) }) - } - } - /** @param {Record} values */ async function handleInsertRow(values) { if (!activeTable) return @@ -4112,7 +4208,7 @@ let rowSearch = $state('') {infiniteScroll} oninfinitescrolltoggle={toggleInfiniteScroll} live={liveEnabled} - ondeleteselected={() => void deleteSelectedRows()} + ondeleteselected={() => stageDeleteSelectedRows()} onexport={handleExport} onaddrow={() => dtBeginInsertRow?.()} onopeninsql={openTableInSqlEditor} @@ -4174,6 +4270,9 @@ let rowSearch = $state('') bind:resetEdits bind:scrollToTop={scrollTableTop} bind:scrollToBottom={scrollTableBottom} + bind:scrollToLeft={scrollTableLeft} + bind:scrollToRight={scrollTableRight} + bind:canScrollHorizontally={tableCanScrollH} bind:focusColumn={focusTableColumn} bind:getScroll={tableGetScroll} bind:applyScroll={tableApplyScroll} @@ -4212,6 +4311,7 @@ let rowSearch = $state('') oninsertrow={handleInsertRow} insertSaving={insertingRow} bind:beginInsertRow={dtBeginInsertRow} + bind:stageDeleteSelected={stageDeleteSelectedRows} readonly={tableReadonly} /> @@ -4441,6 +4541,9 @@ let rowSearch = $state('') showTableNav={activeTab?.kind === 'table'} onscrolltabletop={() => scrollTableTop()} onscrolltablebottom={() => scrollTableBottom()} + canScrollTableHorizontally={tableCanScrollH} + onscrolltableleft={() => scrollTableLeft()} + onscrolltableright={() => scrollTableRight()} live={liveEnabled} {liveSupported} ontogglelive={() => { liveEnabled = !liveEnabled }} diff --git a/src/lib/export.js b/src/lib/export.js index 21193e80..c17fd09d 100644 --- a/src/lib/export.js +++ b/src/lib/export.js @@ -5,17 +5,45 @@ * @param {unknown[][]} rows * @returns {string} */ +const csvEscape = (/** @type {unknown} */ v) => { + if (v === null || v === undefined) return '' + const s = typeof v === 'object' ? JSON.stringify(v) : String(v) + return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s +} + export function rowsToCsv(columns, rows) { - const escape = (/** @type {unknown} */ v) => { - if (v === null || v === undefined) return '' - const s = typeof v === 'object' ? JSON.stringify(v) : String(v) - return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s - } - const header = columns.map((c) => escape(c.name)).join(',') - const body = rows.map((row) => row.map(escape).join(',')).join('\n') + const header = columns.map((c) => csvEscape(c.name)).join(',') + const body = rows.map((row) => row.map(csvEscape).join(',')).join('\n') return header + '\n' + body } +/** A macrotask yield so the UI can paint between chunks of a big export. */ +const yieldToUi = () => new Promise((res) => setTimeout(res, 0)) + +/** + * Chunked, cooperative CSV builder for large exports — yields to the event loop + * every `chunkSize` rows so the main thread stays responsive and a progress + * indicator can animate instead of the app appearing frozen. + * @param {Array<{ name: string }>} columns + * @param {unknown[][]} rows + * @param {{ chunkSize?: number, onProgress?: (done: number, total: number) => void }} [opts] + * @returns {Promise} + */ +export async function rowsToCsvAsync(columns, rows, opts = {}) { + const { chunkSize = 5000, onProgress } = opts + const total = rows.length + const blocks = [columns.map((c) => csvEscape(c.name)).join(',')] + for (let i = 0; i < total; i += chunkSize) { + const end = Math.min(i + chunkSize, total) + let block = '' + for (let r = i; r < end; r++) block += (r === i ? '' : '\n') + rows[r].map(csvEscape).join(',') + blocks.push(block) + onProgress?.(end, total) + await yieldToUi() + } + return blocks.join('\n') +} + /** * Convert columns + rows to a JSON array of objects. * @param {Array<{ name: string }>} columns @@ -32,6 +60,35 @@ export function rowsToJson(columns, rows) { return JSON.stringify(records, null, 2) } +/** + * Chunked, cooperative JSON builder for large exports (see rowsToCsvAsync). + * @param {Array<{ name: string }>} columns + * @param {unknown[][]} rows + * @param {{ chunkSize?: number, onProgress?: (done: number, total: number) => void }} [opts] + * @returns {Promise} + */ +export async function rowsToJsonAsync(columns, rows, opts = {}) { + const { chunkSize = 5000, onProgress } = opts + const total = rows.length + if (total === 0) return '[]' + const parts = ['['] + for (let i = 0; i < total; i += chunkSize) { + const end = Math.min(i + chunkSize, total) + let block = '' + for (let r = i; r < end; r++) { + /** @type {Record} */ + const obj = {} + columns.forEach((col, ci) => { obj[col.name] = rows[r][ci] ?? null }) + block += (r === 0 ? '' : ',') + '\n ' + JSON.stringify(obj) + } + parts.push(block) + onProgress?.(end, total) + await yieldToUi() + } + parts.push('\n]') + return parts.join('') +} + /** * Build a default filename like "users_2025-05-24.csv" * @param {string | null} tableName diff --git a/src/lib/stores/pending-table-edits.js b/src/lib/stores/pending-table-edits.js new file mode 100644 index 00000000..be8d4f7e --- /dev/null +++ b/src/lib/stores/pending-table-edits.js @@ -0,0 +1,57 @@ +/** + * In-memory cache of staged (unsaved) table changes, keyed by table key + * (`"schema.table"`). DataTable unmounts when you switch to a non-table tab + * (SQL/AI) or another table, which would otherwise drop its staged edits and + * deletes. This module-level cache outlives the component so the changes are + * restored when you return to the table. + * + * Row-index based: only valid while the underlying row set is unchanged. Callers + * clear the entry whenever rows are refetched/reordered (page, filter, sort). + * + * @typedef {{ rowIdx: number, colIdx: number, value: unknown, original: unknown }} StagedEdit + * @typedef {{ edits: Map, deletes: Set }} StagedChanges + */ + +/** @type {Map} */ +const cache = new Map() + +/** + * @param {string} key + * @param {Map} edits + * @param {Set} deletes + */ +export function savePendingChanges(key, edits, deletes) { + if (!key) return + const hasEdits = (edits?.size ?? 0) > 0 + const hasDeletes = (deletes?.size ?? 0) > 0 + if (!hasEdits && !hasDeletes) { + cache.delete(key) + return + } + cache.set(key, { edits: new Map(edits), deletes: new Set(deletes) }) +} + +/** + * @param {string} key + * @returns {StagedChanges} A fresh copy (empty when nothing cached). + */ +export function loadPendingChanges(key) { + const v = key ? cache.get(key) : null + return { edits: new Map(v?.edits ?? []), deletes: new Set(v?.deletes ?? []) } +} + +/** @param {string} key */ +export function clearPendingChanges(key) { + if (key) cache.delete(key) +} + +/** @param {string} key @returns {number} Count of staged changes for the key. */ +export function pendingChangesCount(key) { + const v = key ? cache.get(key) : null + return (v?.edits.size ?? 0) + (v?.deletes.size ?? 0) +} + +/** @returns {boolean} True if any table has cached staged changes. */ +export function anyPendingChanges() { + return cache.size > 0 +} From 4275d806015d3771a70f27e8d46748c26f0a46d3 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Sat, 4 Jul 2026 23:28:48 +0545 Subject: [PATCH 04/20] feat(data-table): rectangular block cell selection - Drag, Shift+Arrow, and Shift+Click select a rectangular range of cells - Blue block highlight + border drawn on the canvas - Cmd/Ctrl+C copies the range as TSV; Escape collapses it - Range collapses on plain click/arrow and resets on tab switch --- src/lib/components/DataTable.svelte | 195 +++++++++++++++++++++++++++- 1 file changed, 191 insertions(+), 4 deletions(-) diff --git a/src/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte index b11b77ff..7bd85a05 100644 --- a/src/lib/components/DataTable.svelte +++ b/src/lib/components/DataTable.svelte @@ -368,6 +368,29 @@ import FilterX from "@lucide/svelte/icons/filter-x"; /** Anchor column for shift+click range selection (plain var — not reactive). */ let _lastHeaderClickedCol = /** @type {string | null} */ (null); + /** + * Rectangular cell-range selection (spreadsheet-style). The fixed corner is + * `selAnchor`; the moving corner is `focusedRow`/`focusedCol` (visible-column + * space). null = plain single-cell focus. Extended via Shift+Arrow or drag. + * @type {{ row: number, col: number } | null} + */ + let selAnchor = $state(null); + /** True while a click-drag range selection is in progress. */ + let _rangeDragging = false; + /** Pointer-down cell + position, to distinguish a click from a drag-select. */ + let _rangeDownCell = /** @type {{ row: number, col: number, x: number, y: number } | null} */ (null); + + /** The active rectangular range in visible-column space, or null for a single cell. */ + const cellRange = $derived.by(() => { + if (selAnchor === null || focusedRow === null || focusedCol === null) return null; + const r0 = Math.min(selAnchor.row, focusedRow); + const r1 = Math.max(selAnchor.row, focusedRow); + const c0 = Math.min(selAnchor.col, focusedCol); + const c1 = Math.max(selAnchor.col, focusedCol); + if (r0 === r1 && c0 === c1) return null; // collapsed to one cell + return { r0, r1, c0, c1 }; + }); + /** Extend column selection from anchor to `toColName`, using geom.cols order. */ function extendColSelection(toColName) { if (!_lastHeaderClickedCol) { selectedCols = new Set([toColName]); _lastHeaderClickedCol = toColName; return } @@ -1079,6 +1102,41 @@ import FilterX from "@lucide/svelte/icons/filter-x"; clearPendingChanges(columnWidthsKey ?? ''); } + /** Collapse any range back to the single focused cell. */ + function clearCellRange() { + if (selAnchor !== null) selAnchor = null; + } + + /** Plain-text value of a cell for range copy (TSV). */ + function cellCopyText(/** @type {number} */ rowIdx, /** @type {number} */ actualIdx) { + const v = effectiveCellValue(rowIdx, actualIdx); + if (v === null || v === undefined) return ''; + if (typeof v === 'object') { try { return JSON.stringify(v); } catch { return String(v); } } + return String(v); + } + + /** Copy the current rectangular range to the clipboard as TSV (paste-ready for Sheets/Excel). */ + async function copyCellRange() { + const range = cellRange; + if (!range) return false; + const cols = navigableColumns.slice(range.c0, range.c1 + 1); + const actualIdxs = cols.map((col) => _nameToActualIdx.get(col.name) ?? -1); + /** @type {string[]} */ + const lines = []; + for (let r = range.r0; r <= range.r1; r++) { + lines.push(actualIdxs.map((ai) => cellCopyText(r, ai).replace(/\t/g, ' ').replace(/\r?\n/g, ' ')).join('\t')); + } + try { + await navigator.clipboard.writeText(lines.join('\n')); + const nCells = (range.r1 - range.r0 + 1) * (range.c1 - range.c0 + 1); + toast.success(`Copied ${nCells} cells`); + return true; + } catch (err) { + toast.error('Could not copy', { description: String(err) }); + return false; + } + } + // Surface staged-edit state to the parent (→ StatusBar Apply/Reset buttons). $effect(() => { applyEdits = applyPendingEdits; @@ -2127,6 +2185,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // Always reset non-content states focusedRow = null focusedCol = null + selAnchor = null pastEdits = [] futureEdits = [] selectedCols = new Set() @@ -2327,6 +2386,12 @@ import FilterX from "@lucide/svelte/icons/filter-x"; void copyColSelection(); return; } + // Ctrl+C: copy the rectangular cell range as TSV. + if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && (e.key === "c" || e.key === "C") && cellRange && !editingCell) { + e.preventDefault(); + void copyCellRange(); + return; + } // Undo / redo — active even while the cell input has focus if ((e.ctrlKey || e.metaKey) && !e.altKey && (e.key === "z" || e.key === "Z")) { if (!editingCell) { @@ -2370,6 +2435,20 @@ import FilterX from "@lucide/svelte/icons/filter-x"; const curRow = focusedRow ?? 0; const curCol = focusedCol ?? 0; + // Shift+Arrow extends a rectangular range from the anchor; a plain arrow (or + // Tab) collapses it back to the single focused cell. + if (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "ArrowRight" || e.key === "ArrowLeft") { + if (e.shiftKey) { + if (selAnchor === null && focusedRow !== null && focusedCol !== null) { + selAnchor = { row: focusedRow, col: focusedCol }; + } + } else { + clearCellRange(); + } + } else if (e.key === "Tab") { + clearCellRange(); + } + switch (e.key) { case "ArrowDown": { e.preventDefault(); @@ -2423,8 +2502,9 @@ import FilterX from "@lucide/svelte/icons/filter-x"; } case "Escape": { e.preventDefault(); - // Priority: close FK sub-view → clear col selection → clear cell focus + // Priority: close FK sub-view → collapse cell range → clear col selection → clear cell focus if (fkSubview !== null) { fkSubview = null; break; } + if (cellRange) { clearCellRange(); scheduleDraw(); break; } if (selectedCols.size) { selectedCols = new Set(); _lastHeaderClickedCol = null; scheduleDraw(); break; } focusedRow = null; focusedCol = null; break; @@ -2600,6 +2680,23 @@ import FilterX from "@lucide/svelte/icons/filter-x"; ctx.clip() ctx.textBaseline = 'middle' + // Precompute the rectangular cell-range (in column-name space) once per frame + // so drawCell can cheaply tint in-range cells and stroke the range border. + const range = cellRange + /** @type {Set | null} */ + let rangeColNames = null + let rangeFirstCol = '', rangeLastCol = '', rangeR0 = -1, rangeR1 = -1 + if (range) { + rangeColNames = new Set() + for (let ci = range.c0; ci <= range.c1; ci++) { + const nm = navigableColumns[ci]?.name + if (nm) rangeColNames.add(nm) + } + rangeFirstCol = navigableColumns[range.c0]?.name ?? '' + rangeLastCol = navigableColumns[range.c1]?.name ?? '' + rangeR0 = range.r0; rangeR1 = range.r1 + } + const bodyTopY = Math.max(0, _scrollTop - HEADER_H - insertRowOffset) if (visibleColumns.length > 0) { let i = rowIndexAtY(rowTops, n, bodyTopY) @@ -2610,6 +2707,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; drawBodyRow(ctx, i, ry, { cFg, cText, cMuted, cGrid, cMutedBg, cRing, cAccent, cPanel, usedW, navName, AMBER, BLUE_FG, RED, cPrimary, frozenW, + rangeColNames, rangeFirstCol, rangeLastCol, rangeR0, rangeR1, }) } } @@ -2843,6 +2941,20 @@ import FilterX from "@lucide/svelte/icons/filter-x"; else if (activeFk) { ctx.fillStyle = withAlpha(c.cAccent, 0.15); ctx.fillRect(cellX, ry, w, rh) } else if (isFocusedCell) { ctx.fillStyle = withAlpha(c.cPrimary, 0.08); ctx.fillRect(cellX, ry, w, rh) } else if (dir?.bgTint) { ctx.fillStyle = dir.bgTint; ctx.fillRect(cellX, ry, w, rh) } + + // Rectangular range selection — tint every in-range cell + stroke the outer border. + const inRange = c.rangeColNames && idx >= c.rangeR0 && idx <= c.rangeR1 && c.rangeColNames.has(col.name) + if (inRange) { + ctx.fillStyle = withAlpha(c.cPrimary, 0.16); ctx.fillRect(cellX, ry, w, rh) + ctx.strokeStyle = withAlpha(c.cPrimary, 0.9) + ctx.lineWidth = 1 + ctx.beginPath() + if (idx === c.rangeR0) { ctx.moveTo(cellX, ry + 0.5); ctx.lineTo(cellX + w, ry + 0.5) } + if (idx === c.rangeR1) { ctx.moveTo(cellX, ry + rh - 0.5); ctx.lineTo(cellX + w, ry + rh - 0.5) } + if (col.name === c.rangeFirstCol) { ctx.moveTo(cellX + 0.5, ry); ctx.lineTo(cellX + 0.5, ry + rh) } + if (col.name === c.rangeLastCol) { ctx.moveTo(cellX + w - 0.5, ry); ctx.lineTo(cellX + w - 0.5, ry + rh) } + ctx.stroke() + } } else { // Active edit cell — the DOM overlay draws its own ring-inset; no canvas // border needed here (a canvas strokeRect would bleed outside the cell on @@ -3342,6 +3454,18 @@ import FilterX from "@lucide/svelte/icons/filter-x"; if (_scrollLoopId) cancelAnimationFrame(_scrollLoopId) }) + // End a drag-select on pointer-up anywhere (the release often lands outside the + // canvas). Registered on window so it fires regardless of where the pointer is. + $effect(() => { + const up = () => onCanvasPointerUp() + window.addEventListener('pointerup', up) + window.addEventListener('pointercancel', up) + return () => { + window.removeEventListener('pointerup', up) + window.removeEventListener('pointercancel', up) + } + }) + // Shift+wheel → horizontal scroll. Non-passive so we can call preventDefault, // but bails out immediately on non-Shift events so the compositor waits <0.05ms. // Registered via $effect (not onwheel attribute) to keep the Svelte template @@ -3376,7 +3500,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; $effect(() => { void rows; void columns; void columnWidths void pinnedColumns; void hiddenColumns; void selected; void focusedRow - void focusedCol; void editingCell; void pendingEdits; void pendingDeletes; void expandedRows + void focusedCol; void editingCell; void pendingEdits; void pendingDeletes; void expandedRows; void selAnchor void rowSort; void _viewportWidth // NOTE: _scrollTop / _scrollLeft are intentionally NOT tracked here — scrolling // repaints via scheduleDraw() inside onContainerScroll, so this effect (which @@ -3543,10 +3667,29 @@ import FilterX from "@lucide/svelte/icons/filter-x"; scheduleDraw() } if (editingCell) cancelEdit() + + // Shift+Click extends a rectangular range from the anchor to the clicked + // cell (spreadsheet-style). The anchor is the previously-focused cell. + if (e.shiftKey) { + const vi2 = actualToVisColIdx(actualIdx) + if (vi2 >= 0) { + if (selAnchor === null) { + selAnchor = (focusedRow !== null && focusedCol !== null) + ? { row: focusedRow, col: focusedCol } + : { row: idx, col: vi2 } + } + focusedRow = idx + focusedCol = vi2 + scheduleDraw() + } + tableContainer?.focus({ preventScroll: true }) + return + } + + clearCellRange() // plain click collapses any rectangular range focusedRow = idx const vi = actualToVisColIdx(actualIdx) if (vi >= 0) focusedCol = vi - if (e.shiftKey) { openInInspector(idx); return } if (inspectorRow !== null) inspectorRow = idx const cached = _colCache[actualIdx] @@ -3673,14 +3816,58 @@ import FilterX from "@lucide/svelte/icons/filter-x"; function onCanvasPointerDown(/** @type {PointerEvent} */ e) { if (e.button !== 0) return - const { y } = canvasXY(e) + const { x, y } = canvasXY(e) // Header resize is handled by the DOM overlay — canvas only handles body. if (y < HEADER_H) return + // Record the cell for a potential drag-select; the range only begins once the + // pointer moves past a small threshold (so plain clicks keep their behavior). + const t = hitTest(x, y) + if (t.kind === 'cell') { + const vi = actualToVisColIdx(/** @type {number} */ (t.actualIdx)) + if (vi >= 0) _rangeDownCell = { row: /** @type {number} */ (t.idx), col: vi, x, y } + } else { + _rangeDownCell = null + } + } + + function onCanvasPointerUp() { + if (_rangeDragging) { + _rangeDragging = false + _suppressNextClick = true // the drag already set focus/range; don't run the click action + } + _rangeDownCell = null } function onCanvasPointerMove(/** @type {PointerEvent} */ e) { const { x, y } = canvasXY(e) if (resizingColName) return + + // Drag-select a rectangular cell range. Begins once the pointer leaves a small + // threshold from the mousedown cell, so a plain click is never treated as a drag. + if (_rangeDownCell && (e.buttons & 1)) { + if (!_rangeDragging) { + if (Math.abs(x - _rangeDownCell.x) > 4 || Math.abs(y - _rangeDownCell.y) > 4) { + _rangeDragging = true + selAnchor = { row: _rangeDownCell.row, col: _rangeDownCell.col } + focusedRow = _rangeDownCell.row + focusedCol = _rangeDownCell.col + if (selectedCols.size) { selectedCols = new Set(); _lastHeaderClickedCol = null } + } + } + if (_rangeDragging) { + const t = hitTest(x, y) + if (t.kind === 'cell') { + const vi = actualToVisColIdx(/** @type {number} */ (t.actualIdx)) + if (vi >= 0) { + focusedRow = /** @type {number} */ (t.idx) + focusedCol = vi + scrollRowIntoView(/** @type {number} */ (t.idx)) + } + } + scheduleDraw() + return + } + } if (y < HEADER_H) { _resizeHoverCol = null _zoomGuard.block = false From 226a7f62e5d06da4885976a2247572385277f43e Mon Sep 17 00:00:00 2001 From: broisnischal Date: Sat, 4 Jul 2026 23:46:33 +0545 Subject: [PATCH 05/20] feat(workspace): VS Code-style split-pane / editor-group layout Add a resizable split-pane workspace for the main content tabs. Tabs can be dragged from a pane's tab strip and dropped on another pane's edge to split (left/right/top/bottom) or center to move between groups. Each pane has its own tab strip and active tab; the focused pane drives the existing global data pipeline while background panes render read-only snapshots from tab.state. - pane-layout.js: pure binary split-tree model + immutable ops - PaneLayout.svelte: recursive renderer, draggable splitters, drop zones - PaneSnapshot.svelte: read-only DataTable for background panes - TabBar.svelte: draggable tabs emitting drag start/end - StudioShell.svelte: pane state, reconciliation effect, group-aware activate/close/focus, DnD + resize handlers, snippet-based rendering --- src/lib/components/PaneLayout.svelte | 165 ++++++++++++++++ src/lib/components/PaneSnapshot.svelte | 71 +++++++ src/lib/components/StudioShell.svelte | 261 +++++++++++++++++++++++-- src/lib/components/TabBar.svelte | 12 ++ src/lib/pane-layout.js | 215 ++++++++++++++++++++ 5 files changed, 711 insertions(+), 13 deletions(-) create mode 100644 src/lib/components/PaneLayout.svelte create mode 100644 src/lib/components/PaneSnapshot.svelte create mode 100644 src/lib/pane-layout.js diff --git a/src/lib/components/PaneLayout.svelte b/src/lib/components/PaneLayout.svelte new file mode 100644 index 00000000..f41b6932 --- /dev/null +++ b/src/lib/components/PaneLayout.svelte @@ -0,0 +1,165 @@ + + +{#if node.type === 'split'} +
+
+ +
+ + + + +
+ +
+
+{:else} + {@const isFocused = node.id === focusedGroupId} +
onfocusgroup(node.id)} + > + {@render renderGroup(node, isFocused)} + + {#if dragActive} +
+ {#each zones as z (z.edge)} +
(hoverEdge = z.edge)} + ondragleave={() => { if (hoverEdge === z.edge) hoverEdge = null }} + ondrop={(e) => onDrop(e, node.id, z.edge)} + >
+ {/each} +
+ {/if} +
+{/if} diff --git a/src/lib/components/PaneSnapshot.svelte b/src/lib/components/PaneSnapshot.svelte new file mode 100644 index 00000000..b1ca4bc0 --- /dev/null +++ b/src/lib/components/PaneSnapshot.svelte @@ -0,0 +1,71 @@ + + +{#if tableState} + {#if !tableState.table} +
+ +

No table

+
+ {:else if tableState.columns.length === 0} +
+ +

Focus this pane to load {tableState.table}

+
+ {:else} +
+ +
+ {/if} +{:else} +
+ +
+

{tab ? tabDisplayTitle(tab) : 'Empty pane'}

+

+ Click this pane to focus it and interact with this view. +

+
+
+{/if} diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index 5d0d3f64..60f9d4e3 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -30,6 +30,9 @@ import { toast } from '$lib/components/ui/sonner/toast.svelte.js' import Sidebar from './Sidebar.svelte' import TabBar from './TabBar.svelte' + import PaneLayout from './PaneLayout.svelte' + import PaneSnapshot from './PaneSnapshot.svelte' + import * as PaneTree from '$lib/pane-layout.js' import TabLoading from './TabLoading.svelte' import TableToolbar from './TableToolbar.svelte' import StructureView from './StructureView.svelte' @@ -300,6 +303,18 @@ let tabs = $state([]) let activeTabId = $state(/** @type {string | null} */ (null)) + // ── Split-pane / editor-group layout ───────────────────────────────────── + // `paneRoot` is a binary split tree whose leaves ("groups") reference tab ids + // from the flat `tabs` array above. The FOCUSED group's active tab is kept in + // sync with the global `activeTabId`, so the existing data pipeline (columns, + // rows, loadRows, handleSaveCell, …) drives whatever the focused pane shows. + // Background panes render read-only snapshots from each tab's own `tab.state`. + /** @type {import('$lib/pane-layout.js').PaneNode | null} */ + let paneRoot = $state(null) + let activeGroupId = $state(/** @type {string | null} */ (null)) + /** Tab id currently being dragged (drives the split drop targets). */ + let dragTabId = $state(/** @type {string | null} */ (null)) + // ── Tab navigation history (back/forward) ──────────────────────────────── /** @type {string[]} */ let navHistory = $state([]) @@ -626,6 +641,8 @@ let rowSearch = $state('') let ormError = $state('') const activeTab = $derived(tabs.find((t) => t.id === activeTabId) ?? null) + /** Fast id → tab lookup for per-group tab-strip rendering. */ + const tabsById = $derived(new Map(tabs.map((t) => [t.id, t]))) /** 'table' | 'view' | 'materialized_view' | 'foreign_table' — for the active table tab */ const activeTableKind = $derived( activeTab?.kind === 'table' @@ -1815,6 +1832,182 @@ let rowSearch = $state('') if (!pinned.some((t) => t.id === activeTabId)) await activateTab(pinned[0].id) } + // ── Pane-layout reconciliation & group-aware actions ───────────────────── + // AI tabs live in a full-window overlay, not the tab flow, so they never + // belong to a group. + /** @param {StudioTab} t */ + function isGroupable(t) { + return t.kind !== 'ai' + } + + /** + * Keep `paneRoot` / `activeGroupId` consistent with `tabs` + `activeTabId`. + * Runs from an effect on every tab/active change. Idempotent: it only assigns + * new references when something actually changed, so it can't loop. + */ + function reconcilePanes() { + const ids = tabs.filter(isGroupable).map((t) => t.id) + const idSet = new Set(ids) + const root = untrack(() => paneRoot) + const curGroup = untrack(() => activeGroupId) + + if (ids.length === 0) { + if (root !== null) paneRoot = null + if (curGroup !== null) activeGroupId = null + return + } + + let next = root + if (!next) { + next = PaneTree.makeGroup(ids, activeTabId) + } else { + // Drop tab ids that no longer exist and repair each group's active tab. + next = PaneTree.mapGroups(next, (g) => { + const kept = g.tabIds.filter((id) => idSet.has(id)) + let active = g.activeTabId + if (!active || !idSet.has(active)) active = kept[kept.length - 1] ?? null + if (kept.length === g.tabIds.length && active === g.activeTabId) return g + return { ...g, tabIds: kept, activeTabId: active } + }) + next = PaneTree.prune(next) + } + + // Place brand-new tabs (not yet in any group) into the active group. + const placed = new Set() + for (const g of PaneTree.allGroups(next)) for (const id of g.tabIds) placed.add(id) + const missing = ids.filter((id) => !placed.has(id)) + if (missing.length) { + let targetId = + curGroup && PaneTree.findGroup(next, curGroup) ? curGroup : PaneTree.firstGroup(next)?.id ?? null + if (!targetId || !next) { + next = PaneTree.makeGroup(missing, activeTabId) + } else { + for (const id of missing) next = PaneTree.addTabToGroup(next, targetId, id) + } + } + + // Focus follows the global active tab: its holding group becomes active and + // mirrors `activeTabId`. + let nextGroup = curGroup + if (activeTabId && idSet.has(activeTabId)) { + const holder = PaneTree.groupOfTab(next, activeTabId) + if (holder) { + nextGroup = holder.id + if (holder.activeTabId !== activeTabId) { + next = PaneTree.updateGroup(next, holder.id, (g) => ({ ...g, activeTabId })) + } + } + } + if (!nextGroup || !PaneTree.findGroup(next, nextGroup)) { + nextGroup = PaneTree.firstGroup(next)?.id ?? null + } + + if (next !== root) paneRoot = next + if (nextGroup !== curGroup) activeGroupId = nextGroup + } + + $effect(() => { + // Depend on tab identity/order + the active tab. + tabs.map((t) => t.id).join('') + void activeTabId + reconcilePanes() + }) + + /** Focus a pane (its active tab drives the live pipeline). @param {string} groupId */ + async function focusGroup(groupId) { + if (groupId === activeGroupId) return + const g = PaneTree.findGroup(paneRoot, groupId) + if (!g) return + activeGroupId = groupId + if (g.activeTabId && g.activeTabId !== activeTabId) await activateTab(g.activeTabId) + } + + /** Activate a tab within a specific pane (pane tab-strip click). */ + async function focusTabInGroup(groupId, tabId) { + activeGroupId = groupId + paneRoot = PaneTree.updateGroup(paneRoot, groupId, (g) => ({ ...g, activeTabId: tabId })) + await activateTab(tabId) + } + + /** Close a tab from a specific pane, keeping focus within that pane. */ + async function closeTabInGroup(groupId, id) { + const g = PaneTree.findGroup(paneRoot, groupId) + const idx = tabs.findIndex((t) => t.id === id) + if (idx < 0) return + const nextTabs = tabs.filter((t) => t.id !== id) + if (nextTabs.length === 0) { + tabs = [createWelcomeTab()] + activeTabId = tabs[0].id + clearTableEditor() + return + } + // Pick the next tab to activate *within this group* when closing its active. + let nextActiveId = /** @type {string | null} */ (null) + if (id === activeTabId && g) { + const gi = g.tabIds.indexOf(id) + const rest = g.tabIds.filter((t) => t !== id) + if (rest.length) { + nextActiveId = rest[Math.min(gi, rest.length - 1)] + } else { + const other = PaneTree.allGroups(paneRoot).find( + (gr) => gr.id !== groupId && gr.activeTabId && gr.activeTabId !== id, + ) + if (other) { + activeGroupId = other.id + nextActiveId = other.activeTabId + } + } + } + tabs = nextTabs + if (id === activeTabId) { + if (nextActiveId) await activateTab(nextActiveId) + else { + const fallback = nextTabs[Math.min(idx, nextTabs.length - 1)] + if (fallback) await activateTab(fallback.id) + } + } + } + + /** Resize a split node (splitter drag). */ + function handlePaneResize(splitId, sizes) { + paneRoot = PaneTree.setSizes(paneRoot, splitId, sizes) + } + + /** + * Handle a tab dropped on a pane drop-zone: center = move into that group, + * an edge = split that group and place the tab in the new pane. + * @param {string} targetGroupId + * @param {import('$lib/pane-layout.js').DropEdge} edge + */ + function handleDropZone(targetGroupId, edge) { + const tabId = dragTabId + dragTabId = null + if (!tabId || !paneRoot) return + const source = PaneTree.groupOfTab(paneRoot, tabId) + if (!source) return + + if (edge === 'center') { + if (source.id === targetGroupId) { + void focusTabInGroup(targetGroupId, tabId) + return + } + let next = PaneTree.removeTab(paneRoot, tabId) + next = PaneTree.addTabToGroup(next, targetGroupId, tabId) + paneRoot = PaneTree.prune(next) ?? next + void focusTabInGroup(targetGroupId, tabId) + return + } + + // Splitting the source into itself with only one tab is a no-op. + if (source.id === targetGroupId && source.tabIds.length <= 1) return + const newGroup = PaneTree.makeGroup([tabId], tabId) + let next = PaneTree.removeTab(paneRoot, tabId) + next = PaneTree.splitGroup(next, targetGroupId, edge, newGroup) + paneRoot = PaneTree.prune(next) ?? next + activeGroupId = newGroup.id + void activateTab(tabId) + } + /** * "View data structure" from the sidebar — open the table's tab and switch * straight to the structure view (the structure auto-load effect fetches it). @@ -3657,21 +3850,62 @@ let rowSearch = $state('') {:else} - {#if tabBarVisible} - t.kind !== 'ai')} - {activeTabId} - onselect={(id) => activateTab(id)} - onclose={closeTab} - oncloseothers={closeOtherTabs} - oncloseall={closeAllTabs} - onpintoggle={toggleTabPin} - onnew={openWelcomeTab} - {recentTabs} - onrecentselect={(schema, table) => { if (aiMode) exitAiMode(); void openTableTab(schema, table) }} - /> + + {#if paneRoot} + void focusGroup(gid)} + /> + {:else} + {#if tabBarVisible} + t.kind !== 'ai')} + {activeTabId} + onselect={(id) => activateTab(id)} + onclose={closeTab} + oncloseothers={closeOtherTabs} + oncloseall={closeAllTabs} + onpintoggle={toggleTabPin} + onnew={openWelcomeTab} + {recentTabs} + onrecentselect={(schema, table) => { if (aiMode) exitAiMode(); void openTableTab(schema, table) }} + /> + {/if} + {@render sharedContent()} {/if} + {#snippet groupPane(/** @type {import('$lib/pane-layout.js').GroupNode} */ group, /** @type {boolean} */ isFocused)} + {#if tabBarVisible} + tabsById.get(id)).filter(Boolean)} + activeTabId={group.activeTabId} + onselect={(id) => void focusTabInGroup(group.id, id)} + onclose={(id) => void closeTabInGroup(group.id, id)} + oncloseothers={closeOtherTabs} + oncloseall={closeAllTabs} + onpintoggle={toggleTabPin} + onnew={() => { void focusGroup(group.id); openWelcomeTab() }} + {recentTabs} + onrecentselect={(schema, table) => { if (aiMode) exitAiMode(); void focusGroup(group.id).then(() => openTableTab(schema, table)) }} + ondragtabstart={(id) => { dragTabId = id }} + ondragtabend={() => { dragTabId = null }} + /> + {/if} + {#if isFocused} + {@render sharedContent()} + {:else} + + {/if} + {/snippet} + + {#snippet sharedContent()} {#if activeTab?.kind === 'ai'} {:else if activeTab?.kind === 'schema'} @@ -4374,6 +4608,7 @@ let rowSearch = $state('') {/if} + {/snippet} {/if} {/if} diff --git a/src/lib/components/TabBar.svelte b/src/lib/components/TabBar.svelte index 279a9b35..48868be1 100644 --- a/src/lib/components/TabBar.svelte +++ b/src/lib/components/TabBar.svelte @@ -55,6 +55,9 @@ /** @type {import('$lib/stores/recent-tabs.js').RecentTab[]} */ recentTabs = [], onrecentselect = /** @type {(schema: string, table: string) => void} */ (() => {}), + /** Fired when a tab drag begins — enables split-pane drop targets. */ + ondragtabstart = /** @param {string} _id */ (_id) => {}, + ondragtabend = () => {}, } = $props() /** @type {HTMLElement | null} */ @@ -111,6 +114,15 @@ {#snippet child({ props: ctxProps })}
{ + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', tab.id) + } + ondragtabstart(tab.id) + }} + ondragend={() => ondragtabend()} class={cn( 'group/tab relative flex min-w-0 max-w-[200px] shrink-0 items-stretch transition-colors duration-100', active ? 'bg-panel' : 'hover:bg-muted/20', diff --git a/src/lib/pane-layout.js b/src/lib/pane-layout.js new file mode 100644 index 00000000..9e5daabd --- /dev/null +++ b/src/lib/pane-layout.js @@ -0,0 +1,215 @@ +/** + * Editor-group / split-pane layout model for the workspace. + * + * The layout is a binary tree. Leaves are "groups" (each has its own tab strip + * and one active tab). Interior nodes are "splits" (row = side-by-side, + * col = stacked) holding exactly two children with relative sizes. + * + * The flat `tabs` array in StudioShell stays the single source of tab DATA; + * groups only reference tab ids. All operations here are pure and return a new + * tree (structural sharing where nothing changed) so Svelte reactivity is + * predictable. + * + * @typedef {object} GroupNode + * @property {'group'} type + * @property {string} id + * @property {string[]} tabIds + * @property {string | null} activeTabId + * + * @typedef {object} SplitNode + * @property {'split'} type + * @property {string} id + * @property {'row' | 'col'} dir + * @property {[PaneNode, PaneNode]} children + * @property {[number, number]} sizes — percentages, sum ≈ 100 + * + * @typedef {GroupNode | SplitNode} PaneNode + * @typedef {'center' | 'left' | 'right' | 'top' | 'bottom'} DropEdge + */ + +let paneSeq = 0 + +export function nextGroupId() { + paneSeq += 1 + return `grp-${paneSeq}` +} + +export function nextSplitId() { + paneSeq += 1 + return `spl-${paneSeq}` +} + +/** + * @param {string[]} [tabIds] + * @param {string | null} [activeTabId] + * @returns {GroupNode} + */ +export function makeGroup(tabIds = [], activeTabId = null) { + const ids = [...tabIds] + return { + type: 'group', + id: nextGroupId(), + tabIds: ids, + activeTabId: activeTabId && ids.includes(activeTabId) ? activeTabId : (ids[ids.length - 1] ?? null), + } +} + +/** @param {PaneNode | null} node @returns {GroupNode[]} */ +export function allGroups(node, acc = /** @type {GroupNode[]} */ ([])) { + if (!node) return acc + if (node.type === 'group') { + acc.push(node) + return acc + } + allGroups(node.children[0], acc) + allGroups(node.children[1], acc) + return acc +} + +/** @param {PaneNode | null} node @returns {GroupNode | null} */ +export function firstGroup(node) { + if (!node) return null + if (node.type === 'group') return node + return firstGroup(node.children[0]) +} + +/** @param {PaneNode | null} node @param {string} id @returns {GroupNode | null} */ +export function findGroup(node, id) { + for (const g of allGroups(node)) if (g.id === id) return g + return null +} + +/** @param {PaneNode | null} node @param {string} tabId @returns {GroupNode | null} */ +export function groupOfTab(node, tabId) { + for (const g of allGroups(node)) if (g.tabIds.includes(tabId)) return g + return null +} + +/** @param {PaneNode | null} node @returns {number} */ +export function groupCount(node) { + return allGroups(node).length +} + +/** True when the tree is a single group (the "no split" / classic layout). */ +export function isSingleGroup(node) { + return !!node && node.type === 'group' +} + +/** + * Immutably replace every group node via `fn`. Returns the same reference when + * nothing changed so downstream reactive reads stay stable. + * @param {PaneNode | null} node + * @param {(g: GroupNode) => GroupNode} fn + * @returns {PaneNode | null} + */ +export function mapGroups(node, fn) { + if (!node) return node + if (node.type === 'group') return fn(node) + const a = mapGroups(node.children[0], fn) + const b = mapGroups(node.children[1], fn) + if (a === node.children[0] && b === node.children[1]) return node + return { ...node, children: /** @type {[PaneNode, PaneNode]} */ ([a, b]) } +} + +/** + * Update a single group by id. + * @param {PaneNode | null} node + * @param {string} id + * @param {(g: GroupNode) => GroupNode} updater + */ +export function updateGroup(node, id, updater) { + return mapGroups(node, (g) => (g.id === id ? updater(g) : g)) +} + +/** + * Drop empty groups and collapse splits that end up with a single child. + * @param {PaneNode | null} node + * @returns {PaneNode | null} + */ +export function prune(node) { + if (!node) return null + if (node.type === 'group') return node.tabIds.length ? node : null + const a = prune(node.children[0]) + const b = prune(node.children[1]) + if (!a && !b) return null + if (!a) return b + if (!b) return a + if (a === node.children[0] && b === node.children[1]) return node + return { ...node, children: /** @type {[PaneNode, PaneNode]} */ ([a, b]) } +} + +/** + * Split `targetGroupId` in the direction implied by `edge`, placing `newGroup` + * on the appropriate side. + * @param {PaneNode} root + * @param {string} targetGroupId + * @param {DropEdge} edge + * @param {GroupNode} newGroup + * @returns {PaneNode} + */ +export function splitGroup(root, targetGroupId, edge, newGroup) { + const dir = edge === 'left' || edge === 'right' ? 'row' : 'col' + const before = edge === 'left' || edge === 'top' + return /** @type {PaneNode} */ ( + mapGroups(root, (g) => { + if (g.id !== targetGroupId) return g + const children = /** @type {[PaneNode, PaneNode]} */ (before ? [newGroup, g] : [g, newGroup]) + return { type: 'split', id: nextSplitId(), dir, children, sizes: [50, 50] } + }) + ) +} + +/** + * Set the sizes of the split node with `splitId`. + * @param {PaneNode | null} root + * @param {string} splitId + * @param {[number, number]} sizes + */ +export function setSizes(root, splitId, sizes) { + /** @param {PaneNode | null} node @returns {PaneNode | null} */ + function rec(node) { + if (!node || node.type === 'group') return node + if (node.id === splitId) return { ...node, sizes } + const a = rec(node.children[0]) + const b = rec(node.children[1]) + if (a === node.children[0] && b === node.children[1]) return node + return { ...node, children: /** @type {[PaneNode, PaneNode]} */ ([a, b]) } + } + return rec(root) +} + +/** + * Remove `tabId` from whichever group holds it, fixing that group's active tab. + * Does NOT prune empty groups (caller decides). + * @param {PaneNode | null} root + * @param {string} tabId + */ +export function removeTab(root, tabId) { + return mapGroups(root, (g) => { + if (!g.tabIds.includes(tabId)) return g + const tabIds = g.tabIds.filter((t) => t !== tabId) + let activeTabId = g.activeTabId + if (activeTabId === tabId) { + const idx = g.tabIds.indexOf(tabId) + activeTabId = tabIds[Math.min(idx, tabIds.length - 1)] ?? null + } + return { ...g, tabIds, activeTabId } + }) +} + +/** + * Add `tabId` to `groupId` (optionally at `index`) and make it active there. + * @param {PaneNode | null} root + * @param {string} groupId + * @param {string} tabId + * @param {number} [index] + */ +export function addTabToGroup(root, groupId, tabId, index) { + return updateGroup(root, groupId, (g) => { + if (g.tabIds.includes(tabId)) return { ...g, activeTabId: tabId } + const tabIds = [...g.tabIds] + if (index == null || index < 0 || index > tabIds.length) tabIds.push(tabId) + else tabIds.splice(index, 0, tabId) + return { ...g, tabIds, activeTabId: tabId } + }) +} From a6f271505128759fcdc7bd5e20ae2e805437a777 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Sat, 4 Jul 2026 23:47:54 +0545 Subject: [PATCH 06/20] refactor(workspace): route Mod+W / editor close through group-aware closeActiveTab --- src/lib/components/StudioShell.svelte | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index 60f9d4e3..b17f894d 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -1082,7 +1082,7 @@ let rowSearch = $state('') if (!connection) return e.preventDefault() if (aiMode) { exitAiMode(); return } - if (activeTabId) closeTab(activeTabId) + closeActiveTab() }) // Chord: Ctrl/⌘+K then W → close all tabs. (Mod+K opens the command palette; @@ -1968,6 +1968,13 @@ let rowSearch = $state('') } } + /** Close the focused pane's active tab (Mod+W / editor shortcuts). */ + function closeActiveTab() { + if (!activeTabId) return + if (activeGroupId) void closeTabInGroup(activeGroupId, activeTabId) + else void closeTab(activeTabId) + } + /** Resize a split node (splitter drag). */ function handlePaneResize(splitId, sizes) { paneRoot = PaneTree.setSizes(paneRoot, splitId, sizes) @@ -4163,7 +4170,7 @@ let rowSearch = $state('') onrun={(d) => void runOrm(d)} onmodi={() => { if (connection) toggleAiSidebar() }} onmodb={() => { sidebarOpen = !sidebarOpen }} - onmodw={() => { if (activeTabId) closeTab(activeTabId) }} + onmodw={() => closeActiveTab()} onmodn={() => { if (connection) openWelcomeTab() }} onmodm={() => cycleTheme()} onmodt={() => { if (connection) { commandPage = 'tables'; commandOpen = true } }} @@ -4204,7 +4211,7 @@ let rowSearch = $state('') onmods={() => saveActiveTabState()} onmodi={() => { if (connection) toggleAiSidebar() }} onmodb={() => { sidebarOpen = !sidebarOpen }} - onmodw={() => { if (activeTabId) closeTab(activeTabId) }} + onmodw={() => closeActiveTab()} onmodn={() => { if (connection) openWelcomeTab() }} onmodm={() => cycleTheme()} onmodt={() => { if (connection) { commandPage = 'tables'; commandOpen = true } }} From c06c010bb4432a472996248101a185144ba4adfd Mon Sep 17 00:00:00 2001 From: broisnischal Date: Sat, 4 Jul 2026 23:58:26 +0545 Subject: [PATCH 07/20] fix(data-table): scroll jitter, pinch-zoom blur, JSON-expand vibration, in-app confirm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Round scrollLeft/Top to whole pixels (canvas sub-pixel shimmer on horizontal scroll) - Intercept pinch/ctrl-wheel → route to crisp app zoom instead of blurry webview page-zoom - JSON-expand overlay: position:sticky instead of reactive translateX (fixes vibration) - Replace window.confirm (blocked in Tauri) with an in-app confirm dialog for close-guards --- src/lib/components/DataTable.svelte | 46 +++++++++++++++++---- src/lib/components/StudioShell.svelte | 59 ++++++++++++++++++++++++--- 2 files changed, 90 insertions(+), 15 deletions(-) diff --git a/src/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte index 7bd85a05..b0d03c73 100644 --- a/src/lib/components/DataTable.svelte +++ b/src/lib/components/DataTable.svelte @@ -2218,8 +2218,8 @@ import FilterX from "@lucide/svelte/icons/filter-x"; if (!container) return _viewportWidth = container.clientWidth _viewportHeight = container.clientHeight - _scrollTop = container.scrollTop - _scrollLeft = container.scrollLeft + _scrollTop = Math.round(container.scrollTop) + _scrollLeft = Math.round(container.scrollLeft) // Use contentRect directly — it's provided synchronously by the ResizeObserver // entry with no forced layout reflow. Removing the rAF here eliminates one full @@ -2254,8 +2254,12 @@ import FilterX from "@lucide/svelte/icons/filter-x"; _scrollLoopId = 0 return } - _scrollTop = el.scrollTop - _scrollLeft = el.scrollLeft + // Snap to whole CSS pixels. The canvas is sticky-pinned at the viewport's + // integer left edge, so drawing content at a fractional scrollLeft puts text + // and gridlines on sub-pixel x — WebKit then re-antialiases them every frame, + // which reads as horizontal "vibration". Integer offsets render stably. + _scrollTop = Math.round(el.scrollTop) + _scrollLeft = Math.round(el.scrollLeft) try { draw() } catch (err) { @@ -2272,8 +2276,9 @@ import FilterX from "@lucide/svelte/icons/filter-x"; const el = e.currentTarget invalidateCanvasRect() // Update state immediately so any synchronous consumers (hit-test etc.) are current. - _scrollTop = el.scrollTop - _scrollLeft = el.scrollLeft + // Rounded to whole pixels to avoid sub-pixel shimmer during horizontal scroll. + _scrollTop = Math.round(el.scrollTop) + _scrollLeft = Math.round(el.scrollLeft) // Cancel any pending one-shot draw — the loop handles all scroll redraws at // the display's native frame rate (120Hz on ProMotion). if (_drawRafId) { cancelAnimationFrame(_drawRafId); _drawRafId = 0 } @@ -3474,7 +3479,21 @@ import FilterX from "@lucide/svelte/icons/filter-x"; $effect(() => { const el = tableContainer if (!el) return + // Accumulates pinch/ctrl-wheel delta so many small gesture ticks map to whole + // app-zoom steps instead of one step per event. + let _zoomAccum = 0 const onShiftWheel = (/** @type {WheelEvent} */ e) => { + // Trackpad pinch (and ctrl+wheel) arrive as wheel events with ctrlKey=true. + // Left unhandled, WebKit page-zooms the whole webview — which bitmap-scales + // the canvas and makes the grid text blurry. Intercept it and drive the app's + // own (crisp, re-rendered) zoom instead, keeping the native page zoom at 1. + if (e.ctrlKey) { + e.preventDefault() + _zoomAccum += e.deltaY + while (_zoomAccum <= -24) { increaseZoom(); _zoomAccum += 24 } + while (_zoomAccum >= 24) { decreaseZoom(); _zoomAccum -= 24 } + return + } if (!e.shiftKey) return const delta = e.deltaY || e.deltaX if (!delta) return @@ -4237,12 +4256,20 @@ import FilterX from "@lucide/svelte/icons/filter-x";
{/if} - + {#each [...expandedRows] as exIdx (exIdx)} {#if rows[exIdx] !== undefined}
+
+
{/if} {/each} diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index 2ed1659f..7ca67199 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -549,17 +549,35 @@ return pendingEditCount > 0 || anyPendingChanges() } + // In-app confirm dialog. `window.confirm` is blocked in the Tauri webview + // ("dialog.confirm not allowed"), so route all confirmations through this. + /** @type {{ message: string, confirmLabel: string, resolve: (v: boolean) => void } | null} */ + let confirmDialog = $state(null) + /** @param {string} message @param {string} [confirmLabel] @returns {Promise} */ + function askConfirm(message, confirmLabel = 'Discard') { + return new Promise((resolve) => { confirmDialog = { message, confirmLabel, resolve } }) + } + /** @param {boolean} v */ + function resolveConfirm(v) { + const d = confirmDialog + confirmDialog = null + d?.resolve(v) + } + // Confirm before quitting the app while table edits are still unsaved. onMount(() => { let unlisten = () => {} ;(async () => { try { const { getCurrentWindow } = await import('@tauri-apps/api/window') - unlisten = await getCurrentWindow().onCloseRequested((event) => { - if (hasAnyUnsavedChanges()) { - const ok = window.confirm('You have unsaved table changes. Discard them and quit?') - if (!ok) event.preventDefault() - } + const win = getCurrentWindow() + unlisten = await win.onCloseRequested(async (event) => { + if (!hasAnyUnsavedChanges()) return + // Stop the close, ask in-app, then force-close if confirmed. Use destroy() + // (not close()) so we don't re-enter this handler. + event.preventDefault() + const ok = await askConfirm('You have unsaved table changes. Discard them and quit?', 'Discard & quit') + if (ok) await win.destroy() }) } catch { /* non-Tauri / web preview — no window close event */ } })() @@ -1843,8 +1861,9 @@ let rowSearch = $state('') const closing = tabs[idx] const count = tabPendingCount(closing) if (count > 0) { - const ok = window.confirm( + const ok = await askConfirm( `This table has ${count} unsaved change${count === 1 ? '' : 's'}. Close the tab and discard them?`, + 'Close & discard', ) if (!ok) return if (id === activeTabId) resetEdits() @@ -4610,4 +4629,32 @@ let rowSearch = $state('') }} /> {/if} + + +{#if confirmDialog} + +{/if} From 545b21d986069626ea836ec46f32f0a7a5aade8d Mon Sep 17 00:00:00 2001 From: broisnischal Date: Sun, 5 Jul 2026 00:06:46 +0545 Subject: [PATCH 08/20] fix(data-table): focus grid after drag-select so Cmd+C copies the range --- src/lib/components/DataTable.svelte | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte index b0d03c73..3101af62 100644 --- a/src/lib/components/DataTable.svelte +++ b/src/lib/components/DataTable.svelte @@ -3853,6 +3853,8 @@ import FilterX from "@lucide/svelte/icons/filter-x"; if (_rangeDragging) { _rangeDragging = false _suppressNextClick = true // the drag already set focus/range; don't run the click action + // Focus the grid so ⌘C (copy range as TSV) and Shift+Arrow are captured. + tableContainer?.focus({ preventScroll: true }) } _rangeDownCell = null } From 992935395e92076e5ceafc6fe5594e300c3a273e Mon Sep 17 00:00:00 2001 From: broisnischal Date: Sun, 5 Jul 2026 00:10:05 +0545 Subject: [PATCH 09/20] polish(block-select): hide per-cell focus ring during range, uniform tint, single-cell Cmd+C, clear range on reload --- src/lib/components/DataTable.svelte | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte index 3101af62..59476d09 100644 --- a/src/lib/components/DataTable.svelte +++ b/src/lib/components/DataTable.svelte @@ -1900,6 +1900,8 @@ import FilterX from "@lucide/svelte/icons/filter-x"; if (pendingEdits.size) pendingEdits = new Map() if (pendingDeletes.size) pendingDeletes = new Set() clearPendingChanges(columnWidthsKey ?? '') + // A fresh row set also invalidates the row-index-keyed cell range. + if (selAnchor !== null) selAnchor = null }) }) @@ -2391,10 +2393,14 @@ import FilterX from "@lucide/svelte/icons/filter-x"; void copyColSelection(); return; } - // Ctrl+C: copy the rectangular cell range as TSV. - if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && (e.key === "c" || e.key === "C") && cellRange && !editingCell) { + // Ctrl+C: copy the rectangular cell range as TSV, or the single focused cell. + if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && (e.key === "c" || e.key === "C") && !editingCell && (cellRange || (focusedRow !== null && focusedCol !== null))) { e.preventDefault(); - void copyCellRange(); + if (cellRange) void copyCellRange(); + else { + const ai = visToActualColIdx(/** @type {number} */ (focusedCol)); + if (ai >= 0) void copyCellValue(/** @type {number} */ (focusedRow), ai); + } return; } // Undo / redo — active even while the cell input has focus @@ -2944,7 +2950,9 @@ import FilterX from "@lucide/svelte/icons/filter-x"; } if (isDirty) { ctx.fillStyle = withAlpha(c.AMBER, 0.15); ctx.fillRect(cellX, ry, w, rh) } else if (activeFk) { ctx.fillStyle = withAlpha(c.cAccent, 0.15); ctx.fillRect(cellX, ry, w, rh) } - else if (isFocusedCell) { ctx.fillStyle = withAlpha(c.cPrimary, 0.08); ctx.fillRect(cellX, ry, w, rh) } + // The lone-focused-cell tint is skipped while a range is active so every + // in-range cell (including the drag-end corner) shares one uniform block tint. + else if (isFocusedCell && !c.rangeColNames) { ctx.fillStyle = withAlpha(c.cPrimary, 0.08); ctx.fillRect(cellX, ry, w, rh) } else if (dir?.bgTint) { ctx.fillStyle = dir.bgTint; ctx.fillRect(cellX, ry, w, rh) } // Rectangular range selection — tint every in-range cell + stroke the outer border. @@ -3092,7 +3100,10 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // Focused-cell outline — primary border, fully inset (no bleed to neighbours). // +1.5 offset keeps the 2px stroke's outer edge 0.5px inside the cell boundary // on all four sides, so left=top=right=bottom are visually symmetric. - if (isFocusedCell) { + // Suppressed while a rectangular range is active — the range's own outline is + // the selection indicator, and a per-cell ring on the drag-end cell reads as a + // stray highlight inside the block. + if (isFocusedCell && !c.rangeColNames) { ctx.strokeStyle = withAlpha(c.cPrimary, 0.9) ctx.lineWidth = 2 ctx.strokeRect(cellX + 1.5, ry + 1.5, w - 3, rh - 3) From bdcbe546485069b3559a423ede84fe4de94a18c1 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Sun, 5 Jul 2026 00:12:49 +0545 Subject: [PATCH 10/20] chore(vite): relax dev-server fs allow-list (worktree parent node_modules fonts) --- vite.config.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/vite.config.js b/vite.config.js index c44b0826..cc71d70b 100644 --- a/vite.config.js +++ b/vite.config.js @@ -49,6 +49,11 @@ export default defineConfig({ }, server: { port: 8080, + // The dev server may resolve node_modules from a parent dir (e.g. when running + // from a git worktree that shares the repo's node_modules). Relax the fs + // allow-list so those assets (fonts, etc.) serve instead of being blocked. + // Dev-only — has no effect on `tauri build` / production. + fs: { strict: false }, }, build: { chunkSizeWarningLimit: 1500, From 18b23b3fa8e937d63deab5efb63b5cf8048401b9 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Sun, 5 Jul 2026 00:20:15 +0545 Subject: [PATCH 11/20] feat(split-panes): pointer-based tab drag with floating ghost + drop-zone hints Replace HTML5 drag-and-drop (unreliable in Tauri WebKit) with pointer-based dragging: - TabBar: pointerdown+threshold drag detection, click-suppression after drag - Floating tab ghost follows the cursor; live drop-zone hint highlights the target region (left/right/top/bottom half or center) via elementFromPoint hit-testing - PaneLayout: data-pane-group marker + translucent hint overlay driven by dropTarget --- src/lib/components/PaneLayout.svelte | 92 ++++++++------------------- src/lib/components/StudioShell.svelte | 61 ++++++++++++++++-- src/lib/components/TabBar.svelte | 45 +++++++++---- 3 files changed, 116 insertions(+), 82 deletions(-) diff --git a/src/lib/components/PaneLayout.svelte b/src/lib/components/PaneLayout.svelte index f41b6932..3d84d3f4 100644 --- a/src/lib/components/PaneLayout.svelte +++ b/src/lib/components/PaneLayout.svelte @@ -11,14 +11,12 @@ node, /** @type {string | null} */ focusedGroupId = null, - /** Whether a tab drag is currently in progress (shows drop targets). */ - dragActive = false, + /** Current pointer-drag drop target (group + edge), or null. Drives the hint. */ + dropTarget = /** @type {{ groupId: string, edge: DropEdge } | null} */ (null), /** @type {import('svelte').Snippet<[GroupNode, boolean]>} */ renderGroup, /** @type {(splitId: string, sizes: [number, number]) => void} */ onresize = () => {}, - /** @type {(groupId: string, edge: DropEdge) => void} */ - ondropzone = () => {}, /** @type {(groupId: string) => void} */ onfocusgroup = () => {}, } = $props() @@ -26,9 +24,6 @@ /** @type {HTMLElement | null} */ let splitEl = $state(null) - /** @type {DropEdge | null} */ - let hoverEdge = $state(null) - /** @param {PointerEvent} e */ function startResize(e) { if (!splitEl || node.type !== 'split') return @@ -57,27 +52,14 @@ window.addEventListener('pointerup', up) } - /** @param {DragEvent} e @param {string} groupId @param {DropEdge} edge */ - function onDrop(e, groupId, edge) { - e.preventDefault() - e.stopPropagation() - hoverEdge = null - ondropzone(groupId, edge) - } - - /** @param {DragEvent} e */ - function allowDrop(e) { - e.preventDefault() - if (e.dataTransfer) e.dataTransfer.dropEffect = 'move' - } - - const zones = /** @type {{ edge: DropEdge, cls: string }[]} */ ([ - { edge: 'left', cls: 'left-0 top-0 h-full w-1/4' }, - { edge: 'right', cls: 'right-0 top-0 h-full w-1/4' }, - { edge: 'top', cls: 'left-1/4 top-0 h-1/3 w-1/2' }, - { edge: 'bottom', cls: 'bottom-0 left-1/4 h-1/3 w-1/2' }, - { edge: 'center', cls: 'left-1/4 top-1/3 h-1/3 w-1/2' }, - ]) + /** Position/size of the drop-hint overlay for a given edge (translucent preview). */ + const HINT_CLASS = /** @type {Record} */ ({ + left: 'left-0 top-0 h-full w-1/2', + right: 'right-0 top-0 h-full w-1/2', + top: 'left-0 top-0 w-full h-1/2', + bottom: 'left-0 bottom-0 w-full h-1/2', + center: 'inset-0', + }) {#if node.type === 'split'} @@ -86,15 +68,7 @@ class={cn('flex min-h-0 min-w-0 flex-1', node.dir === 'row' ? 'flex-row' : 'flex-col')} >
- +
@@ -118,47 +92,31 @@
- +
{:else} {@const isFocused = node.id === focusedGroupId} + {@const hintEdge = dropTarget && dropTarget.groupId === node.id ? dropTarget.edge : null} +
onfocusgroup(node.id)} > {@render renderGroup(node, isFocused)} - {#if dragActive} + + {#if hintEdge}
- {#each zones as z (z.edge)} -
(hoverEdge = z.edge)} - ondragleave={() => { if (hoverEdge === z.edge) hoverEdge = null }} - ondrop={(e) => onDrop(e, node.id, z.edge)} - >
- {/each} +
{/if}
diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index 35649812..98277b71 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -319,6 +319,49 @@ let activeGroupId = $state(/** @type {string | null} */ (null)) /** Tab id currently being dragged (drives the split drop targets). */ let dragTabId = $state(/** @type {string | null} */ (null)) + /** Floating drag preview position + label (follows the cursor). */ + let dragGhost = $state(/** @type {{ x: number, y: number, title: string } | null} */ (null)) + /** Current drop target under the cursor: which pane + which edge. */ + let dropTarget = $state(/** @type {{ groupId: string, edge: import('$lib/pane-layout.js').DropEdge } | null} */ (null)) + + /** Which edge of a pane the cursor is over (drives the split direction / hint). */ + function edgeFromPoint(/** @type {number} */ x, /** @type {number} */ y, /** @type {DOMRect} */ r) { + const fx = (x - r.left) / r.width + const fy = (y - r.top) / r.height + const E = 0.25 + if (fx < E) return /** @type {const} */ ('left') + if (fx > 1 - E) return /** @type {const} */ ('right') + if (fy < E) return /** @type {const} */ ('top') + if (fy > 1 - E) return /** @type {const} */ ('bottom') + return /** @type {const} */ ('center') + } + + /** @param {string} id */ + function beginTabDrag(id) { + dragTabId = id + const t = tabs.find((tab) => tab.id === id) + dragGhost = { x: 0, y: 0, title: t?.title ?? 'Tab' } + dropTarget = null + } + /** @param {number} x @param {number} y */ + function moveTabDrag(x, y) { + if (!dragTabId) return + dragGhost = { x, y, title: dragGhost?.title ?? '' } + const el = /** @type {Element | null} */ (document.elementFromPoint(x, y)) + const group = el?.closest('[data-pane-group]') ?? null + if (!group) { dropTarget = null; return } + const groupId = group.getAttribute('data-pane-group') + if (!groupId) { dropTarget = null; return } + dropTarget = { groupId, edge: edgeFromPoint(x, y, group.getBoundingClientRect()) } + } + function endTabDrag() { + const target = dropTarget + // handleDropZone reads dragTabId, so call it before clearing. + if (target && dragTabId) handleDropZone(target.groupId, target.edge) + dragTabId = null + dragGhost = null + dropTarget = null + } // ── Tab navigation history (back/forward) ──────────────────────────────── /** @type {string[]} */ @@ -3992,10 +4035,9 @@ let rowSearch = $state('') void focusGroup(gid)} /> {:else} @@ -4029,8 +4071,9 @@ let rowSearch = $state('') onnew={() => { void focusGroup(group.id); openWelcomeTab() }} {recentTabs} onrecentselect={(schema, table) => { if (aiMode) exitAiMode(); void focusGroup(group.id).then(() => openTableTab(schema, table)) }} - ondragtabstart={(id) => { dragTabId = id }} - ondragtabend={() => { dragTabId = null }} + ondragtabstart={(id) => beginTabDrag(id)} + ondragtabmove={(x, y) => moveTabDrag(x, y)} + ondragtabend={() => endTabDrag()} /> {/if} {#if isFocused} @@ -4885,6 +4928,16 @@ let rowSearch = $state('') /> {/if} + +{#if dragGhost} +
+ {dragGhost.title} +
+{/if} + {#if confirmDialog}
void} */ (() => {}), - /** Fired when a tab drag begins — enables split-pane drop targets. */ + /** Pointer-based tab drag → split panes. Start/move/end drive the drop hints. */ ondragtabstart = /** @param {string} _id */ (_id) => {}, + ondragtabmove = /** @type {(clientX: number, clientY: number) => void} */ (() => {}), ondragtabend = () => {}, } = $props() + // ── Pointer-based tab drag (HTML5 DnD is unreliable in Tauri's WebKit) ────── + /** @type {{ id: string, startX: number, startY: number, active: boolean } | null} */ + let _tabDrag = null + /** Set true when a drag occurred, so the ensuing click doesn't also select the tab. */ + let _suppressTabClick = false + + /** @param {PointerEvent} e @param {string} id */ + function tabPointerDown(e, id) { + if (e.button !== 0) return + _tabDrag = { id, startX: e.clientX, startY: e.clientY, active: false } + window.addEventListener('pointermove', tabPointerMove) + window.addEventListener('pointerup', tabPointerUp) + } + /** @param {PointerEvent} e */ + function tabPointerMove(e) { + if (!_tabDrag) return + if (!_tabDrag.active) { + if (Math.abs(e.clientX - _tabDrag.startX) <= 5 && Math.abs(e.clientY - _tabDrag.startY) <= 5) return + _tabDrag.active = true + ondragtabstart(_tabDrag.id) + } + ondragtabmove(e.clientX, e.clientY) + } + function tabPointerUp() { + window.removeEventListener('pointermove', tabPointerMove) + window.removeEventListener('pointerup', tabPointerUp) + if (_tabDrag?.active) { _suppressTabClick = true; ondragtabend() } + _tabDrag = null + } + /** @type {HTMLElement | null} */ let scrollEl = $state(null) @@ -114,15 +145,7 @@ {#snippet child({ props: ctxProps })}
{ - if (e.dataTransfer) { - e.dataTransfer.effectAllowed = 'move' - e.dataTransfer.setData('text/plain', tab.id) - } - ondragtabstart(tab.id) - }} - ondragend={() => ondragtabend()} + onpointerdown={(e) => tabPointerDown(e, tab.id)} class={cn( 'group/tab relative flex min-w-0 max-w-[200px] shrink-0 items-stretch transition-colors duration-100', active ? 'bg-panel' : 'hover:bg-muted/20', @@ -141,7 +164,7 @@ 'flex min-w-0 flex-1 items-center gap-1.5 pl-3 pr-1 text-left text-xs font-medium leading-none transition-colors duration-100', active ? 'text-foreground' : 'text-muted-foreground/50 hover:text-muted-foreground/80', )} - onclick={() => onselect(tab.id)} + onclick={() => { if (_suppressTabClick) { _suppressTabClick = false; return } onselect(tab.id) }} > {tabDisplayTitle(tab)} From 53595ad71633dd3c6a5943d8c1e2dfa335f69dab Mon Sep 17 00:00:00 2001 From: broisnischal Date: Sun, 5 Jul 2026 00:25:58 +0545 Subject: [PATCH 12/20] feat(toolbar): container-query responsiveness for narrow (split) panes Make the table toolbar a @container and progressively hide non-essential controls as the pane narrows: row-range readout (<600px), page-count (<500px), Add label (<440px). Fixes the cramped/overlapping pagination when tables sit in split panes. --- src/lib/components/TableToolbar.svelte | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/lib/components/TableToolbar.svelte b/src/lib/components/TableToolbar.svelte index e75c7cf2..c898ac10 100644 --- a/src/lib/components/TableToolbar.svelte +++ b/src/lib/components/TableToolbar.svelte @@ -465,7 +465,7 @@
@@ -701,7 +701,7 @@ onclick={onaddrow} > - Add + Add {/if} @@ -712,7 +712,7 @@ {#if infiniteScroll} {#if total > 0} {to.toLocaleString("en-US")} @@ -722,7 +722,7 @@ {:else} {#if total > 0} {from.toLocaleString("en-US")}–{to.toLocaleString("en-US")} @@ -768,7 +768,7 @@ of {formatCompactCount(pageCount)} From 41cccd632d66598d69ffa3df1aae904ce45e0342 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Sun, 5 Jul 2026 00:33:12 +0545 Subject: [PATCH 13/20] polish(split-panes): grip splitter, focused-pane accent, toolbar spacing, narrow-pane icon collapse 1. Splitter: slim track + always-on hairline + hover grip bar + wider hit area 2. Focused-pane accent: top primary line on the active pane (only when >1 pane) 3. Toolbar: divider + gap before the pagination cluster for breathing room 4. Narrow panes: progressively hide Sort/Columns/Jump/Virtual/Open-in-SQL via container queries so ultra-thin panes stay uncluttered --- src/lib/components/PaneLayout.svelte | 33 +++++++++++++++++++++----- src/lib/components/StudioShell.svelte | 3 +++ src/lib/components/TableToolbar.svelte | 15 ++++++++---- 3 files changed, 40 insertions(+), 11 deletions(-) diff --git a/src/lib/components/PaneLayout.svelte b/src/lib/components/PaneLayout.svelte index 3d84d3f4..f01c07c7 100644 --- a/src/lib/components/PaneLayout.svelte +++ b/src/lib/components/PaneLayout.svelte @@ -11,6 +11,8 @@ node, /** @type {string | null} */ focusedGroupId = null, + /** True when >1 pane exists — enables the focused-pane accent (noise when single). */ + multiPane = false, /** Current pointer-drag drop target (group + edge), or null. Drives the hint. */ dropTarget = /** @type {{ groupId: string, edge: DropEdge } | null} */ (null), /** @type {import('svelte').Snippet<[GroupNode, boolean]>} */ @@ -68,31 +70,46 @@ class={cn('flex min-h-0 min-w-0 flex-1', node.dir === 'row' ? 'flex-row' : 'flex-col')} >
- +
- +
- +
{:else} @@ -106,6 +123,10 @@ role="group" onpointerdowncapture={() => onfocusgroup(node.id)} > + + {#if multiPane && isFocused} +
+ {/if} {@render renderGroup(node, isFocused)} diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index 98277b71..1d002c90 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -319,6 +319,8 @@ let activeGroupId = $state(/** @type {string | null} */ (null)) /** Tab id currently being dragged (drives the split drop targets). */ let dragTabId = $state(/** @type {string | null} */ (null)) + /** Number of panes (groups) in the layout — drives the focused-pane accent. */ + const paneCount = $derived(paneRoot ? PaneTree.allGroups(paneRoot).length : 0) /** Floating drag preview position + label (follows the cursor). */ let dragGhost = $state(/** @type {{ x: number, y: number, title: string } | null} */ (null)) /** Current drop target under the cursor: which pane + which edge. */ @@ -4035,6 +4037,7 @@ let rowSearch = $state('') 1} {dropTarget} renderGroup={groupPane} onresize={handlePaneResize} diff --git a/src/lib/components/TableToolbar.svelte b/src/lib/components/TableToolbar.svelte index c898ac10..0ddb40cc 100644 --- a/src/lib/components/TableToolbar.svelte +++ b/src/lib/components/TableToolbar.svelte @@ -555,7 +555,7 @@ {#snippet trigger(props)} +
{/if} {/if} From 699d70411434a4b25392687cb8f04763e3ef1130 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Sun, 5 Jul 2026 00:36:57 +0545 Subject: [PATCH 14/20] polish(sql-history-panel): cleaner segmented tab bar (spacing, active underline, icon states) --- src/lib/components/QueryHistoryPanel.svelte | 30 ++++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/src/lib/components/QueryHistoryPanel.svelte b/src/lib/components/QueryHistoryPanel.svelte index d99a6129..1127790a 100644 --- a/src/lib/components/QueryHistoryPanel.svelte +++ b/src/lib/components/QueryHistoryPanel.svelte @@ -113,8 +113,8 @@ {#if visible}