From 710caf88dea39fcdded3b7fcaba53ef1ee7a35b6 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Sat, 20 Jun 2026 12:22:23 +0545 Subject: [PATCH 1/6] feat:: jump to column --- package-lock.json | 17 ++----- src/lib/components/DataTable.svelte | 55 +++++++++++++++++++++ src/lib/components/StudioShell.svelte | 4 ++ src/lib/components/TableToolbar.svelte | 66 +++++++++++++++++++++----- 4 files changed, 116 insertions(+), 26 deletions(-) diff --git a/package-lock.json b/package-lock.json index c6d9463f..b6ac5db8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "stroke", - "version": "0.4.7", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "stroke", - "version": "0.4.7", + "version": "0.5.0", "dependencies": { "@dagrejs/dagre": "^3.0.0", "@fontsource-variable/geist": "^5.2.9", @@ -100,7 +100,6 @@ "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" @@ -112,7 +111,6 @@ "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -208,7 +206,6 @@ "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.1.tgz", "integrity": "sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@swc/helpers": "^0.5.0" } @@ -1664,7 +1661,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1854,7 +1850,6 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.4.tgz", "integrity": "sha512-HIN5Pmd9MrX9BkV7tDwnOcEJCSFvCpc8X97h3f508J6I5FsqAY65wKOCvgH2CuP42CaahWaz4tuh32SOOIH7ww==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10" } @@ -2264,7 +2259,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -3365,7 +3359,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3692,7 +3685,6 @@ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.9.tgz", "integrity": "sha512-fTjjT8cHLDwigcu2j3pv7Jq04LklXevPB8uBgyHNiTXv+RMNvVnrjS4UEYrLMkhuq1vpCodHjiW+z/95SDs/fg==", "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -3782,7 +3774,6 @@ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/dcastil" @@ -3811,8 +3802,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.3", @@ -4000,7 +3990,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", diff --git a/src/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte index 70e4078d..e32edf19 100644 --- a/src/lib/components/DataTable.svelte +++ b/src/lib/components/DataTable.svelte @@ -168,6 +168,10 @@ 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 (toolbar "Jump to column" menu) + * calls focusColumn(name) to scroll a column into view and briefly + * highlight it. */ + focusColumn = $bindable(/** @type {(name: string) => void} */ (() => {})), /** Assigned by this component so the parent can persist/restore scroll per * tab. getScroll() reads the live position; applyScroll() restores it once * layout settles. */ @@ -263,6 +267,11 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // ── Keyboard navigation / undo ──────────────────────────────────────────── /** Visible-column index of the focused cell (null = no cell focus). */ let focusedCol = $state(/** @type {number | null} */ (null)); + /** Column name briefly highlighted after the user picks it from the toolbar's + * "Jump to column" menu (header + body band). null = no highlight. */ + let focusColName = $state(/** @type {string | null} */ (null)); + /** @type {ReturnType | null} */ + let _focusColTimer = null; /** Scrollable container element for programmatic focus + scroll. */ let tableContainer = $state(/** @type {HTMLDivElement | null} */ (null)); /** Whether to select-all text when the edit input is focused. */ @@ -515,6 +524,41 @@ import FilterX from "@lucide/svelte/icons/filter-x"; } } + /** + * Scroll a visible column into view (if it's off-screen) and briefly highlight + * it. Pinned columns are always visible, so they only get the highlight. + * @param {string} name + */ + function focusColumnByName(name) { + const col = geom.cols.find((c) => c.name === name) + if (!col) return + if (tableContainer && !col.pinned) { + // Left edge of the scrollable area is occluded by the gutters + pinned cols. + const frozen = geom.frozenWidth + const PAD = 28 + const vLeft = col.contentX - _scrollLeft + const vRight = vLeft + col.w + let target = _scrollLeft + if (vLeft < frozen + PAD) { + target = col.contentX - frozen - PAD + } else if (vRight > _viewportWidth - PAD) { + target = col.contentX + col.w - _viewportWidth + PAD + } + target = Math.max(0, target) + if (Math.abs(target - _scrollLeft) > 1) { + tableContainer.scrollTo({ left: target, behavior: "smooth" }) + } + } + focusColName = name + if (_focusColTimer) clearTimeout(_focusColTimer) + _focusColTimer = setTimeout(() => { + focusColName = null + _focusColTimer = null + scheduleDraw() + }, 2200) + scheduleDraw() + } + const fkByColumn = $derived( Object.fromEntries(columns.map((c) => [c.name, findForeignKeyForColumn(foreignKeys, c.name)])), ); @@ -836,6 +880,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; $effect(() => { scrollToTop = () => tableContainer?.scrollTo({ top: 0 }); scrollToBottom = () => { if (tableContainer) tableContainer.scrollTo({ top: tableContainer.scrollHeight }); }; + focusColumn = focusColumnByName; getScroll = () => ({ left: _scrollLeft, top: _scrollTop }); applyScroll = (pos) => { // Wait for the new tab's columns/rows to lay out (spacer width) before @@ -2397,6 +2442,10 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // Cell background tints. if (!editing) { + // Focused-column band (drawn first so per-cell tints layer on top). + if (focusColName !== null && col.name === focusColName) { + ctx.fillStyle = withAlpha(c.cPrimary, 0.1); ctx.fillRect(cellX, ry, w, rh) + } 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) } @@ -2697,6 +2746,12 @@ import FilterX from "@lucide/svelte/icons/filter-x"; ctx.fillStyle = withAlpha(c.cMutedBg, 0.25); ctx.fillRect(x, 0, w, HEADER_H) } + // Focused-column highlight (toolbar "Jump to column") — tint + accent underline. + if (focusColName === col.name) { + ctx.fillStyle = withAlpha(c.cPrimary, 0.18); ctx.fillRect(x, 0, w, HEADER_H) + ctx.fillStyle = withAlpha(c.cPrimary, 0.9); ctx.fillRect(x, HEADER_H - 2, w, 2) + } + // Right grid separator. ctx.strokeStyle = withAlpha(c.cBorder, 0.25) ctx.lineWidth = 1 diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index 27253cdc..cc01f24b 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -492,6 +492,8 @@ let scrollTableTop = $state(() => {}) /** @type {() => void} */ let scrollTableBottom = $state(() => {}) + /** @type {(name: string) => void} */ + let focusTableColumn = $state(() => {}) // Read/restore the data grid's scroll so horizontal (+ vertical) position is // preserved across tab switches. /** @type {() => { left: number, top: number }} */ @@ -3945,6 +3947,7 @@ let rowSearch = $state('') hiddenColumns = next if (activeTable) saveHiddenCols(persistConnectionId, activeSchema, activeTable, next) }} + onfocuscolumn={(name) => focusTableColumn?.(name)} onprev={async () => { if (page <= 1) return await handlePageChange(page - 1) @@ -3985,6 +3988,7 @@ let rowSearch = $state('') bind:resetEdits bind:scrollToTop={scrollTableTop} bind:scrollToBottom={scrollTableBottom} + bind:focusColumn={focusTableColumn} bind:getScroll={tableGetScroll} bind:applyScroll={tableApplyScroll} bind:vcolPanelOpen diff --git a/src/lib/components/TableToolbar.svelte b/src/lib/components/TableToolbar.svelte index 32a43df6..865f8c3f 100644 --- a/src/lib/components/TableToolbar.svelte +++ b/src/lib/components/TableToolbar.svelte @@ -15,6 +15,7 @@ import Trash2 from "@lucide/svelte/icons/trash-2"; import FileDown from "@lucide/svelte/icons/file-down"; import Columns3 from "@lucide/svelte/icons/columns-3"; + import Crosshair from "@lucide/svelte/icons/crosshair"; import FunctionSquare from "@lucide/svelte/icons/function-square"; import Eye from "@lucide/svelte/icons/eye"; import EyeOff from "@lucide/svelte/icons/eye-off"; @@ -80,6 +81,9 @@ hiddenColumns = new Set(), /** @type {(next: Set) => void} */ onhiddencolumnschange = () => {}, + /** Called when user picks a column from the "Jump to column" menu — the table + * scrolls it into view and briefly highlights it. */ + onfocuscolumn = /** @type {(name: string) => void} */ (() => {}), /** Virtual FK relationship columns (reverse FK badge cols) to show in the hide/show dropdown. */ virtualRelColumns = /** @type {Array<{ label: string }>} */ ([]), /** User-defined virtual expression columns to show in the hide/show dropdown. */ @@ -151,6 +155,7 @@ let sortMenuOpen = $state(false); let columnsMenuOpen = $state(false); + let focusMenuOpen = $state(false); let limitOffsetOpen = $state(false); let moreMenuOpen = $state(false); let deleteConfirmPending = $state(false); @@ -258,6 +263,14 @@ ...virtualExprCols.map((vc) => ({ value: `__vexpr:${vc.id}`, label: `ƒ ${vc.name}`, kind: "vexpr", hidden: !vc.enabled })), ]); + // Jump-to-column: only currently-visible real columns (hidden columns aren't + // rendered on the canvas, so there's nothing to scroll to). + const focusColumnItems = $derived( + columns + .filter((c) => !hiddenColumns.has(c.name)) + .map((c) => ({ value: c.name, label: c.name })), + ); + function toggleColumnItem(/** @type {any} */ it) { if (it.kind === "vexpr") { ontogglevexpr(it.value.slice(8)); @@ -453,8 +466,8 @@ class="studio-chrome studio-table-toolbar flex h-9 shrink-0 items-center gap-1 border-b border-border bg-panel px-2" data-studio-chrome > - -
+ +
{#if tableViewMode === "structure"} {#if tableViewMode !== "structure"} + +
+ + {/snippet} + {#snippet header()} +
+ Jump to column +
+ {/snippet} + {#snippet item(it)} + + {it.label} + {/snippet} + +
+ @@ -653,11 +700,10 @@ {#if total > 0} {to.toLocaleString("en-US")} of {total.toLocaleString("en-US")} loaded - {#if queryMs > 0}· {queryMs}ms{/if} {/if} {:else} @@ -672,14 +718,9 @@ {:else} of {total.toLocaleString("en-US")} {/if} - {#if queryMs > 0}· {queryMs}ms{/if} - {:else if queryMs > 0} - {queryMs}ms {/if} - - - + { if (open) { @@ -840,6 +881,7 @@ + { From 56c5bc4bfbf3dcebb94a7bf76011bf0ee1da1907 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Mon, 22 Jun 2026 08:23:07 +0545 Subject: [PATCH 2/6] feat:: jump to column --- .env.example | 8 + index.html | 5 +- package.json | 1 + src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/build.rs | 26 +- src-tauri/src/github_sync.rs | 288 +++++++++++++++++++++ src-tauri/src/lib.rs | 8 + src/lib/components/DataTable.svelte | 4 +- src/lib/components/GitHubSyncDialog.svelte | 265 +++++++++++++++++++ src/lib/components/SettingsDialog.svelte | 13 + src/lib/components/StatusBar.svelte | 15 ++ src/lib/components/StudioShell.svelte | 17 ++ src/lib/stores/github-sync.svelte.js | 149 +++++++++++ src/main.js | 13 + 15 files changed, 809 insertions(+), 5 deletions(-) create mode 100644 .env.example create mode 100644 src-tauri/src/github_sync.rs create mode 100644 src/lib/components/GitHubSyncDialog.svelte create mode 100644 src/lib/stores/github-sync.svelte.js diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..101ef510 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# GitHub OAuth App credentials for the device-flow login feature. +# Copy this file to .env and fill in the values. +# Get these from: https://github.com/settings/developers → OAuth Apps → Stroke +# +# Both vars are required at build time — the build will fail loudly if missing. +# In CI (GitHub Actions), set these as repository secrets instead of a .env file. +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= diff --git a/index.html b/index.html index 84ee77c5..7652a17f 100644 --- a/index.html +++ b/index.html @@ -3,12 +3,13 @@ + + + + + + + GitHub Sync + + + Save and restore your connections via GitHub + + + +
+ + {#if loggedIn} + +
+ {githubSync.user?.login} +
+

{githubSync.user?.name ?? githubSync.user?.login}

+

@{githubSync.user?.login}

+
+ +
+ + {#if lastSynced} +

Last synced at {lastSynced}

+ {/if} + + {#if githubSync.error} +

{githubSync.error}

+ {/if} + +
+ + +
+ +

+ Push saves your connections to GitHub. + Pull restores them on this machine. +

+ + {:else if flowStep === 'polling'} + +
+

Enter this code on GitHub:

+
+ + {userCode} + + +
+ +
+ + Waiting for authorization… +
+ +
+ + {:else if flowStep === 'done'} +
+
+ +
+

Signed in successfully

+
+ + {:else if flowStep === 'error'} +

{flowError}

+ + + {:else} + +

+ Sign in with GitHub to sync your saved connections across machines. +

+ + {/if} + +
+
+
diff --git a/src/lib/components/SettingsDialog.svelte b/src/lib/components/SettingsDialog.svelte index 9ea19634..ea0875d6 100644 --- a/src/lib/components/SettingsDialog.svelte +++ b/src/lib/components/SettingsDialog.svelte @@ -2,6 +2,7 @@ import Minus from "@lucide/svelte/icons/minus"; import Plus from "@lucide/svelte/icons/plus"; import ChevronRight from "@lucide/svelte/icons/chevron-right"; + import Cloud from "@lucide/svelte/icons/cloud"; import { Button } from "$lib/components/ui/button/index.js"; import * as Dialog from "$lib/components/ui/dialog/index.js"; import * as Select from "$lib/components/ui/select/index.js"; @@ -31,6 +32,7 @@ onopenmodelconfiguration = () => {}, onopenabout = () => {}, onopenextensions = () => {}, + onopengithubsync = /** @type {() => void} */ (() => {}), } = $props(); let settings = $state(loadSettings()); @@ -356,6 +358,17 @@
+ + -
- - {#if lastSynced} -

Last synced at {lastSynced}

- {/if} - - {#if githubSync.error} -

{githubSync.error}

- {/if} - -
- - -
- -

- Push saves your connections to GitHub. - Pull restores them on this machine. -

- - {:else if flowStep === 'polling'} - -
-

Enter this code on GitHub:

-
- - {userCode} - - -
- -
- - Waiting for authorization… -
- -
- - {:else if flowStep === 'done'} -
-
- -
-

Signed in successfully

-
- - {:else if flowStep === 'error'} -

{flowError}

- - - {:else} - -

- Sign in with GitHub to sync your saved connections across machines. -

- - {/if} - - - - diff --git a/src/lib/components/SettingsDialog.svelte b/src/lib/components/SettingsDialog.svelte index ea0875d6..9ea19634 100644 --- a/src/lib/components/SettingsDialog.svelte +++ b/src/lib/components/SettingsDialog.svelte @@ -2,7 +2,6 @@ import Minus from "@lucide/svelte/icons/minus"; import Plus from "@lucide/svelte/icons/plus"; import ChevronRight from "@lucide/svelte/icons/chevron-right"; - import Cloud from "@lucide/svelte/icons/cloud"; import { Button } from "$lib/components/ui/button/index.js"; import * as Dialog from "$lib/components/ui/dialog/index.js"; import * as Select from "$lib/components/ui/select/index.js"; @@ -32,7 +31,6 @@ onopenmodelconfiguration = () => {}, onopenabout = () => {}, onopenextensions = () => {}, - onopengithubsync = /** @type {() => void} */ (() => {}), } = $props(); let settings = $state(loadSettings()); @@ -358,17 +356,6 @@ - - - {:else if $licenseStatus.status === 'Trial'} - -
-
-
- -
-
-

Free Trial

-

- {$licenseStatus.days_remaining} {$licenseStatus.days_remaining === 1 ? 'day' : 'days'} remaining -

+ {:else if isTrial} + +
+
+
+
+ +
+ Free trial
+ + {$licenseStatus.days_remaining}d left +
- {#if !showActivate} + {#if showActivate} + { showActivate = false; open = false }} /> + onclick={() => (showActivate = false)} + class="text-[11px] text-muted-foreground/50 hover:text-muted-foreground transition-colors text-center" + >Cancel {:else} -
- { showActivate = false; open = false }} /> -
+ onclick={() => (showActivate = true)} + class="flex h-8 w-full items-center justify-center gap-1.5 rounded-lg border border-border/60 bg-muted/30 text-[11px] font-medium text-foreground/70 transition-colors hover:bg-muted/60 hover:text-foreground" + > + + Enter license key + {/if}
{:else} -
-
-
- +
+
+
+
-

Trial Expired

-

Activate a license to continue

+

No active license

+

Free features only

- {#if !showActivate} + {#if showActivate} + { showActivate = false; open = false }} /> + onclick={() => (showActivate = false)} + class="text-[11px] text-muted-foreground/50 hover:text-muted-foreground transition-colors text-center" + >Cancel {:else} -
- { showActivate = false; open = false }} /> -
+ onclick={() => (showActivate = true)} + class="flex h-8 w-full items-center justify-center gap-1.5 rounded-lg border border-border/60 bg-muted/30 text-[11px] font-medium text-foreground/70 transition-colors hover:bg-muted/60 hover:text-foreground" + > + + Enter license key + {/if}
{/if}
- -
+ +
-
+
- @@ -207,7 +200,7 @@ href="https://stroke.click" target="_blank" rel="noopener noreferrer" - class="inline-flex items-center gap-1 text-[11px] text-muted-foreground/60 transition-colors hover:text-foreground" + class="inline-flex items-center gap-1 text-[10px] text-muted-foreground/50 transition-colors hover:text-foreground" > Website diff --git a/src/lib/components/ConnectionModal.svelte b/src/lib/components/ConnectionModal.svelte index 05886699..5720d667 100644 --- a/src/lib/components/ConnectionModal.svelte +++ b/src/lib/components/ConnectionModal.svelte @@ -46,6 +46,7 @@ let { open = $bindable(false), onconnected = (conn, id) => {}, + maxConnections = Infinity, } = $props() const CATEGORIES = [ @@ -328,6 +329,10 @@ } async function handleConnect() { + if (!editingId && saved.length >= maxConnections) { + error = `Free plan allows ${maxConnections} saved connections. Upgrade to Stroke Pro for unlimited.` + return + } const myOp = ++opId connecting = editingId ?? '__new__'; error = '' try { diff --git a/src/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte index d7e66e1c..fe54cdab 100644 --- a/src/lib/components/DataTable.svelte +++ b/src/lib/components/DataTable.svelte @@ -270,6 +270,20 @@ import FilterX from "@lucide/svelte/icons/filter-x"; /** Column name briefly highlighted after the user picks it from the toolbar's * "Jump to column" menu (header + body band). null = no highlight. */ let focusColName = $state(/** @type {string | null} */ (null)); + /** Column names selected via click / shift+click on column headers. */ + let selectedCols = $state(/** @type {Set} */ (new Set())); + /** Anchor column for shift+click range selection (plain var — not reactive). */ + let _lastHeaderClickedCol = /** @type {string | null} */ (null); + + /** Extend column selection from anchor to `toColName`, using geom.cols order. */ + function extendColSelection(toColName) { + if (!_lastHeaderClickedCol) { selectedCols = new Set([toColName]); _lastHeaderClickedCol = toColName; return } + const startIdx = geom.cols.findIndex((c) => c.name === _lastHeaderClickedCol) + const endIdx = geom.cols.findIndex((c) => c.name === toColName) + if (startIdx < 0 || endIdx < 0) return + const [lo, hi] = [Math.min(startIdx, endIdx), Math.max(startIdx, endIdx)] + selectedCols = new Set(geom.cols.slice(lo, hi + 1).map((c) => c.name)) + } /** @type {ReturnType | null} */ let _focusColTimer = null; /** Scrollable container element for programmatic focus + scroll. */ @@ -1043,6 +1057,30 @@ import FilterX from "@lucide/svelte/icons/filter-x"; return s.replace(/\|/g, '\\|').replace(/\n/g, ' '); } + async function copyColSelection() { + const activeCols = columns.filter((c) => selectedCols.has(c.name)) + if (!activeCols.length) return + const rowIndices = selected.size > 0 + ? [...selected].sort((a, b) => a - b) + : rows.map((_, i) => i) + const header = activeCols.map((c) => csvCell(c.name)).join('\t') + const body = rowIndices + .map((i) => activeCols.map((c) => { + const ci = columns.indexOf(c) + const v = rows[i]?.[ci] + return v === null || v === undefined ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v) + }).join('\t')) + .join('\n') + const text = header + '\n' + body + try { + await navigator.clipboard.writeText(text) + const colLabel = activeCols.length === 1 ? activeCols[0].name : `${activeCols.length} columns` + toast.success(`Copied ${colLabel} (${rowIndices.length} rows)`) + } catch { + toast.error('Could not copy to clipboard') + } + } + async function copyAs(rowIdx, format) { const indices = copyTargetIndices(rowIdx); const colNames = columns.map((c) => c.name); @@ -1786,6 +1824,8 @@ import FilterX from "@lucide/svelte/icons/filter-x"; focusedCol = null pastEdits = [] futureEdits = [] + selectedCols = new Set() + _lastHeaderClickedCol = null _lastTabKey = newKey }) }); @@ -1964,6 +2004,13 @@ import FilterX from "@lucide/svelte/icons/filter-x"; if (!editingCell) selected = new Set(rows.map((_, i) => i)); return; } + + // Ctrl+C: copy selected columns (all rows, or row-selection intersection) + if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && (e.key === "c" || e.key === "C") && selectedCols.size && !editingCell) { + e.preventDefault(); + void copyColSelection(); + 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) { @@ -2060,8 +2107,9 @@ import FilterX from "@lucide/svelte/icons/filter-x"; } case "Escape": { e.preventDefault(); - // Priority: close FK sub-view first, then clear cell focus + // Priority: close FK sub-view → clear col selection → clear cell focus if (fkSubview !== null) { fkSubview = null; break; } + if (selectedCols.size) { selectedCols = new Set(); _lastHeaderClickedCol = null; scheduleDraw(); break; } focusedRow = null; focusedCol = null; break; } @@ -2442,6 +2490,10 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // Cell background tints. if (!editing) { + // Column-selection band (drawn first so other tints layer on top). + if (selectedCols.has(col.name)) { + ctx.fillStyle = withAlpha(c.cPrimary, 0.08); ctx.fillRect(cellX, ry, w, rh) + } // Focused-column band (drawn first so per-cell tints layer on top). if (focusColName !== null && col.name === focusColName) { ctx.fillStyle = withAlpha(c.cPrimary, 0.1); ctx.fillRect(cellX, ry, w, rh) @@ -2752,6 +2804,12 @@ import FilterX from "@lucide/svelte/icons/filter-x"; ctx.fillStyle = withAlpha(c.cPrimary, 0.9); ctx.fillRect(x, HEADER_H - 2, w, 2) } + // Column-selection highlight (shift+click range) — stronger tint + thick underline. + if (selectedCols.has(col.name)) { + ctx.fillStyle = withAlpha(c.cPrimary, 0.22); ctx.fillRect(x, 0, w, HEADER_H) + ctx.fillStyle = c.cPrimary; ctx.fillRect(x, HEADER_H - 3, w, 3) + } + // Right grid separator. ctx.strokeStyle = withAlpha(c.cBorder, 0.25) ctx.lineWidth = 1 @@ -3029,7 +3087,16 @@ import FilterX from "@lucide/svelte/icons/filter-x"; case 'header-expand-all': collapseAllRows(); return case 'header-select-all': toggleAll(!allSelected); return case 'header': { - handleHeaderSort(t.col.name) + if (e.shiftKey && _lastHeaderClickedCol) { + // Shift+click with anchor: range-select. + extendColSelection(t.col.name) + } else { + // Plain click (or first shift+click with no anchor): set anchor + sort. + selectedCols = new Set([t.col.name]) + _lastHeaderClickedCol = t.col.name + if (!e.shiftKey) handleHeaderSort(t.col.name) + } + scheduleDraw() return } case 'row-expand': toggleRowExpand(/** @type {number} */ (t.idx)); return @@ -3038,6 +3105,17 @@ import FilterX from "@lucide/svelte/icons/filter-x"; const idx = /** @type {number} */ (t.idx) const actualIdx = /** @type {number} */ (t.actualIdx) + if (e.shiftKey && selectedCols.size && t.col) { + // Shift+click body cell while cols selected → extend column range. + extendColSelection(t.col.name) + scheduleDraw() + return + } + if (selectedCols.size) { + selectedCols = new Set() + _lastHeaderClickedCol = null + scheduleDraw() + } if (editingCell) cancelEdit() focusedRow = idx const vi = actualToVisColIdx(actualIdx) diff --git a/src/lib/components/LicenseActivation.svelte b/src/lib/components/LicenseActivation.svelte index 8ea2ec00..f8f9174c 100644 --- a/src/lib/components/LicenseActivation.svelte +++ b/src/lib/components/LicenseActivation.svelte @@ -89,7 +89,7 @@ type="button" disabled={!key.trim() || loading || success} onclick={() => void submit()} - class="inline-flex h-10 w-full items-center justify-center gap-2 rounded-lg bg-foreground text-sm font-semibold text-background transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40" + class="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-foreground text-background transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40 {naked ? 'h-9 text-[12px] font-medium' : 'h-10 text-sm font-semibold'}" > {#if loading} diff --git a/src/lib/components/LicenseGate.svelte b/src/lib/components/LicenseGate.svelte index 7bf19375..5b2e94bb 100644 --- a/src/lib/components/LicenseGate.svelte +++ b/src/lib/components/LicenseGate.svelte @@ -1,153 +1,11 @@ - -{#if $licenseStatus === null} - {@render children()} - - -{:else if $isBlocked} - - - -{:else} - {@render children()} -{/if} + +{@render children()} diff --git a/src/lib/components/SqlConsole.svelte b/src/lib/components/SqlConsole.svelte index e1d3c242..69f6cb55 100644 --- a/src/lib/components/SqlConsole.svelte +++ b/src/lib/components/SqlConsole.svelte @@ -15,6 +15,7 @@ import Table2 from "@lucide/svelte/icons/table-2"; import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js"; import { cn } from "$lib/utils.js"; + import { hasPro } from '$lib/stores/license.js' import SqlEditor from "./SqlEditor.svelte"; import { sqlToDrizzle, sqlToPrisma } from "$lib/orm-builder.js"; import QueryHistoryPanel from "./QueryHistoryPanel.svelte"; @@ -85,6 +86,7 @@ /** Called when user clicks "Fix with AI" — parent opens sidebar and sends the message */ /** @param {{ error: string, sql: string }} detail */ onfixwithai = /** @type {((detail: { error: string, sql: string }) => void) | undefined} */ (undefined), + onprorequired = /** @type {() => void} */ (() => {}), } = $props(); /** @@ -759,24 +761,28 @@
{#each [ - { id: 'table', label: 'Table', Icon: Table2 }, - { id: 'chart', label: 'Chart', Icon: BarChart2 }, - { id: 'json', label: 'JSON', Icon: Braces }, - { id: 'explain', label: 'Explain', Icon: ScanSearch }, + { id: 'table', label: 'Table', Icon: Table2, pro: false }, + { id: 'chart', label: 'Chart', Icon: BarChart2, pro: true }, + { id: 'json', label: 'JSON', Icon: Braces, pro: true }, + { id: 'explain', label: 'Explain', Icon: ScanSearch, pro: true }, ] as tab (tab.id)} - {@const active = outputVisible && (tab.id === 'explain' ? activeResult.outputView === 'explain' : currentDisplay.outputView === tab.id)} + {@const locked = tab.pro && !$hasPro} + {@const active = !locked && outputVisible && (tab.id === 'explain' ? activeResult.outputView === 'explain' : currentDisplay.outputView === tab.id)} {@const Icon = tab.Icon} diff --git a/src/lib/components/StatusBar.svelte b/src/lib/components/StatusBar.svelte index 7114051f..00f5192d 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -93,6 +93,7 @@ ontoggletabbar = /** @type {() => void} */ (() => {}), ontoggletabletoolbar = /** @type {() => void} */ (() => {}), ontogglestatusbar = /** @type {() => void} */ (() => {}), + hasPro = true, } = $props() const activeProfile = $derived($aiProfiles.find((p) => p.id === $activeProfileId) ?? $aiProfiles[0]) @@ -457,30 +458,38 @@ {#if dbT === 'postgres' || dbT === 'mysql'} Schema Explorer + {#if !hasPro}PRO{/if} {/if} Activity Log + {#if !hasPro}PRO{/if} {#if dbT === 'postgres'} Security + {#if !hasPro}PRO{/if} {/if} ORM Runner + {#if !hasPro}PRO{/if} Backup & Restore + {#if !hasPro}PRO{/if} Charts + {#if !hasPro}PRO{/if} Dashboard + {#if !hasPro}PRO{/if} Diagrams + {#if !hasPro}PRO{/if} diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index cc01f24b..8ef93414 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -66,6 +66,7 @@ import { Button } from '$lib/components/ui/button/index.js' import AlertTriangle from '@lucide/svelte/icons/triangle-alert' import X from '@lucide/svelte/icons/x' + import Lock from '@lucide/svelte/icons/lock' import { disconnectPostgres, listSchemas, @@ -155,6 +156,9 @@ upsertConnection, engineFamily, } from '$lib/stores/connections.js' + import { hasPro, FREE_CONNECTION_LIMIT } from '$lib/stores/license.js' + import * as Dialog from '$lib/components/ui/dialog/index.js' + import ExternalLink from '@lucide/svelte/icons/external-link' import { connectPostgres, connectSqlite, @@ -240,6 +244,7 @@ let showReportIssueDialog = $state(false) let showDisconnectDialog = $state(false) let showAiModelSettings = $state(false) + let showProGate = $state(false) let ddlDialogOpen = $state(false) let ddlDialogTable = $state('') let ddlDialogSql = $state('') @@ -959,6 +964,7 @@ let rowSearch = $state('') createHotkey('Mod+Shift+E', (e) => { if (!connection) return e.preventDefault() + if (!$hasPro) { showProGate = true; return } if (aiMode) exitAiMode() else enterAiMode() }) @@ -1476,10 +1482,12 @@ let rowSearch = $state('') } function openAiTab() { + if (!$hasPro) { showProGate = true; return } enterAiMode() } function openSchemaTab() { + if (!$hasPro) { showProGate = true; return } if (!hasSchemaExplorer) return const existing = findSchemaTab(tabs) if (existing) { @@ -1495,6 +1503,7 @@ let rowSearch = $state('') } function openOrmTab() { + if (!$hasPro) { showProGate = true; return } const existing = findOrmTab(tabs) if (existing) { void activateTab(existing.id) @@ -1510,6 +1519,7 @@ let rowSearch = $state('') function openSecurityTab() { if (!hasSecurity) return + if (!$hasPro) { showProGate = true; return } const existing = findSecurityTab(tabs) if (existing) { void activateTab(existing.id) @@ -1524,6 +1534,7 @@ let rowSearch = $state('') } function openBackupTab() { + if (!$hasPro) { showProGate = true; return } const existing = findBackupTab(tabs) if (existing) { void activateTab(existing.id); return } saveActiveTabState() @@ -1535,6 +1546,7 @@ let rowSearch = $state('') } function openLogsTab() { + if (!$hasPro) { showProGate = true; return } const existing = findLogsTab(tabs) if (existing) { void activateTab(existing.id) @@ -1549,6 +1561,7 @@ let rowSearch = $state('') } function openExtensionsTab() { + if (!$hasPro) { showProGate = true; return } const existing = findExtensionsTab(tabs) if (existing) { void activateTab(existing.id) @@ -1563,6 +1576,7 @@ let rowSearch = $state('') } function openJsonTab() { + if (!$hasPro) { showProGate = true; return } const existing = findJsonTab(tabs) if (existing) { void activateTab(existing.id) @@ -1577,6 +1591,7 @@ let rowSearch = $state('') } function openChartsTab() { + if (!$hasPro) { showProGate = true; return } const existing = findChartsTab(tabs) if (existing) { void activateTab(existing.id) @@ -1591,6 +1606,7 @@ let rowSearch = $state('') } function openDashboardTab() { + if (!$hasPro) { showProGate = true; return } const existing = findDashboardTab(tabs) if (existing) { void activateTab(existing.id) @@ -1605,6 +1621,7 @@ let rowSearch = $state('') } function openErdTab() { + if (!$hasPro) { showProGate = true; return } const existing = findErdTab(tabs) if (existing) { void activateTab(existing.id); return } saveActiveTabState() @@ -1616,6 +1633,7 @@ let rowSearch = $state('') } function openDiagramsTab() { + if (!$hasPro) { showProGate = true; return } const existing = findDiagramsTab(tabs) if (existing) { activateTab(existing.id); return } const tab = createDiagramsTab() @@ -1624,6 +1642,7 @@ let rowSearch = $state('') } function openSearchTab() { + if (!$hasPro) { showProGate = true; return } const existing = findSearchTab(tabs) if (existing) { void activateTab(existing.id) @@ -1638,6 +1657,7 @@ let rowSearch = $state('') } function openNewNotebookTab() { + if (!$hasPro) { showProGate = true; return } saveActiveTabState() dropWelcomeTabs() const nb = createNotebook() @@ -1648,6 +1668,7 @@ let rowSearch = $state('') } async function openNotebookFromFile() { + if (!$hasPro) { showProGate = true; return } try { const result = await openNotebookFile() if (!result) return @@ -1664,6 +1685,7 @@ let rowSearch = $state('') } function openSchemaTimelineTab() { + if (!$hasPro) { showProGate = true; return } const existing = findSchemaTimelineTab(tabs) if (existing) { void activateTab(existing.id); return } saveActiveTabState() @@ -1675,6 +1697,7 @@ let rowSearch = $state('') } function openDataDiffTab() { + if (!$hasPro) { showProGate = true; return } const existing = findDataDiffTab(tabs) if (existing) { void activateTab(existing.id); return } saveActiveTabState() @@ -3174,7 +3197,7 @@ let rowSearch = $state('') (showConnectionModal = true)} onsample={handleSampleConnect} /> - onConnected(conn, id)} /> + onConnected(conn, id)} maxConnections={$hasPro ? Infinity : FREE_CONNECTION_LIMIT} /> (showReportIssueDialog = true)} /> + + + + +
+ +
+

Stroke Pro required

+

This feature is not available on the free plan. Upgrade to Stroke Pro to unlock AI, dashboards, ORM runner, schema explorer, and more.

+
+ + +
+
+
+
+ (statusBarHasUpdate = true)} /> @@ -3277,55 +3336,25 @@ let rowSearch = $state('') {#if autoConnecting}
- -
-
-
-
- -
- -
-
-
- {#each [80, 60, 95, 70, 55, 85, 65] as w} -
- {/each} -
-
- - -
- -
-
-
-
- -
-
-
-
-
- -
- - - - - -

Connecting…

-
+ +
+ +
+
- -
-
-
+ +
+

Reconnecting

+

Establishing database connection…

{/if} @@ -3820,6 +3849,7 @@ let rowSearch = $state('') onhistoryselect={(sql) => void openQueryInEditor(sql)} onsavequery={handleSaveQuery} onfixwithai={handleFixWithAi} + onprorequired={() => (showProGate = true)} /> {/await} @@ -4044,9 +4074,12 @@ let rowSearch = $state('') {#if !activeTab || activeTab.kind === 'welcome'} {@const isMac = navigator.platform.toUpperCase().includes('MAC')} {@const mod = isMac ? '⌘' : 'Ctrl'} - {@const cell = 'group flex flex-col gap-3 rounded-lg border border-border/60 bg-card p-3 text-left transition-colors hover:border-border hover:bg-accent/40'} + {@const cell = 'group relative flex flex-col gap-3 rounded-lg border border-border/60 bg-card p-3 text-left transition-colors hover:border-border hover:bg-accent/40'} + {@const proCell = 'group relative flex flex-col gap-3 rounded-lg border border-border/40 bg-card/70 p-3 text-left cursor-not-allowed transition-colors hover:border-amber-500/30 hover:bg-amber-500/[0.03]'} {@const iconCls = 'size-3.5 text-muted-foreground transition-colors group-hover:text-foreground'} + {@const proIconCls = 'size-3.5 text-muted-foreground/40'} {@const labelCls = 'text-[11px] font-medium leading-none text-foreground/70 transition-colors group-hover:text-foreground'} + {@const proLabelCls = 'text-[11px] font-medium leading-none text-foreground/35'} {@const hotkeyCls = 'text-[9px] tabular-nums text-muted-foreground/50 group-hover:text-muted-foreground transition-colors self-end'} +{#snippet proBadge()} + PRO +{/snippet} + {#snippet sep()} @@ -203,29 +256,40 @@ {#if savedConnections.length > 0} - + + Connections {#each savedConnections as conn (conn.id)} {@const isCurrent = conn.id === activeConnectionId} {@const Icon = connIcon(conn)} + {@const subtitle = conn.database && conn.database !== (conn.name ?? conn.host) ? conn.database : (conn.host ?? '')} { if (!isCurrent) onswitchconnection(conn); connOpen = false }} > - + + +
-
{conn.name ?? conn.host ?? conn.filePath ?? 'Connection'}
- {#if conn.database && conn.database !== (conn.name ?? conn.host)} -
{conn.database}
+
+ {conn.name ?? conn.host ?? conn.filePath ?? 'Connection'} +
+ {#if subtitle} +
{subtitle}
{/if}
- {#if isCurrent}{/if} + {#if isCurrent}{/if}
{/each} - { connOpen = false; onconnect() }}> - + { connOpen = false; onconnect() }}> + + + Manage connections…
@@ -238,7 +302,7 @@
{ if (o && databases.length === 0) void fetchDatabases(); if (!o) dbSearch = '' }} + onOpenChange={(o) => { if (o && dbList.length === 0) void fetchDatabases(); if (!o) dbSearch = '' }} > {#if connection?.type === 'sqlite'} + {:else if isD1} + {:else} {/if} - {currentDb || 'No database'} - {#if isPostgres} + {currentDbLabel || 'No database'} + {#if canSwitchDb} {/if} - {#if databases.length > 5} + {#if dbList.length > 5}
{ if (e.key === 'Escape') { dbSearch = ''; dbOpen = false } }} @@ -279,24 +345,28 @@ {dbSearch ? 'No match' : 'No databases found'}
{:else} - {#each dbFiltered.slice(0, 200) as db (db)} - {@const isCurrent = db === currentDb} + {#each dbFiltered.slice(0, 200) as db (db.key)} + {@const isCurrent = db.key === currentDbKey} switchDb(db)} > - - {db} + {#if isD1} + + {:else} + + {/if} + {db.label} {#if isCurrent}{/if} {/each} {/if}
- {#if isPostgres} + {#if canSwitchDb}
- {databases.length} database{databases.length === 1 ? '' : 's'} + {dbList.length} database{dbList.length === 1 ? '' : 's'}
- + {#if isPostgres} + + {/if}
{/if} @@ -448,49 +520,20 @@ {#if connection} - {@const dbT = connection.type ?? 'postgres'} - + Tools - {#if dbT === 'postgres' || dbT === 'mysql'} - - Schema Explorer - {#if !hasPro}PRO{/if} + {#each toolItems as item (item.label)} + {@const Icon = item.icon} + + + {item.label} + {#if !hasPro}{@render proBadge()}{/if} - {/if} - - Activity Log - {#if !hasPro}PRO{/if} - - {#if dbT === 'postgres'} - - Security - {#if !hasPro}PRO{/if} - - {/if} - - ORM Runner - {#if !hasPro}PRO{/if} - - - Backup & Restore - {#if !hasPro}PRO{/if} - - - Charts - {#if !hasPro}PRO{/if} - - - Dashboard - {#if !hasPro}PRO{/if} - - - Diagrams - {#if !hasPro}PRO{/if} - + {/each} {@render sep()} @@ -641,6 +684,6 @@ connType={connection?.type ?? 'postgres'} oncreate={async (opts) => { await oncreatedatabase(opts) - databases = [] + dbList = [] }} /> diff --git a/src/lib/components/StructureView.svelte b/src/lib/components/StructureView.svelte index 3de77ebc..19d80efc 100644 --- a/src/lib/components/StructureView.svelte +++ b/src/lib/components/StructureView.svelte @@ -1,6 +1,6 @@ {#if isTauri && !fullscreen} @@ -95,33 +100,38 @@
- -
- - - + -
+ + +
- -
+ +
+ {:else} + +
+ {/if}
-
+
{#if $isTrialActive} @@ -216,6 +226,25 @@
+ {#if !isMac} + +
+ + + +
+ {/if} +
{/if} diff --git a/src/lib/components/ui/sonner/index.js b/src/lib/components/ui/sonner/index.js index 1f016b4e..ca2daed6 100644 --- a/src/lib/components/ui/sonner/index.js +++ b/src/lib/components/ui/sonner/index.js @@ -1 +1,2 @@ -export { default as Toaster } from "./sonner.svelte"; \ No newline at end of file +export { default as Toaster } from './sonner.svelte' +export { toast } from './toast.svelte.js' diff --git a/src/lib/components/ui/sonner/sonner.svelte b/src/lib/components/ui/sonner/sonner.svelte index d8248add..bd48d644 100644 --- a/src/lib/components/ui/sonner/sonner.svelte +++ b/src/lib/components/ui/sonner/sonner.svelte @@ -1,52 +1,41 @@
{#if showClearAll} {/if} - - {#snippet loadingIcon()} - - {/snippet} - {#snippet successIcon()} - - {/snippet} - {#snippet errorIcon()} - - {/snippet} - {#snippet infoIcon()} - - {/snippet} - {#snippet warningIcon()} -
+ {/each} diff --git a/src/lib/components/ui/sonner/toast.svelte.js b/src/lib/components/ui/sonner/toast.svelte.js new file mode 100644 index 00000000..e1fe0a40 --- /dev/null +++ b/src/lib/components/ui/sonner/toast.svelte.js @@ -0,0 +1,105 @@ +/** + * Self-contained toast store (replaces svelte-sonner). + * + * svelte-sonner tracks per-toast heights in a parallel array that diverges from + * the toast list, which crashes / infinite-loops (`effect_update_depth_exceeded`) + * when several toasts dismiss at once. This implementation keeps a single plain + * reactive list and lets Svelte transitions handle enter/exit animation, so there + * is no height bookkeeping and no way to form a read-write reactive cycle. + * + * API surface mirrors the parts of svelte-sonner the app uses: + * toast.success/error/info/warning/message(title, { description?, duration? }) + * toast.dismiss(id?) — dismiss one, or all when id is omitted + * toast.getActiveToasts() — current toasts (reactive read) + */ + +let counter = 0 +const DEFAULT_DURATION = 4500 + +/** + * @typedef {'success' | 'error' | 'info' | 'warning' | 'message'} ToastType + * @typedef {{ id: number, type: ToastType, title: string, description: string }} Toast + * @typedef {{ description?: string, duration?: number }} ToastOptions + */ + +class ToastStore { + /** @type {Toast[]} */ + toasts = $state([]) + + /** @type {Map }>} */ + #timers = new Map() + + /** + * @param {number} id + * @param {number} duration + */ + #schedule(id, duration) { + if (!Number.isFinite(duration) || duration <= 0) return + const handle = setTimeout(() => this.dismiss(id), duration) + this.#timers.set(id, { remaining: duration, start: Date.now(), handle }) + } + + /** + * @param {ToastType} type + * @param {unknown} title + * @param {ToastOptions} [opts] + */ + #add(type, title, opts = {}) { + const id = ++counter + const duration = opts.duration ?? DEFAULT_DURATION + // newest first; immutable reassignment keeps the read-side reactive + this.toasts = [ + { id, type, title: String(title ?? ''), description: opts.description ?? '' }, + ...this.toasts, + ] + this.#schedule(id, duration) + return id + } + + /** @param {unknown} title @param {ToastOptions} [opts] */ + success = (title, opts) => this.#add('success', title, opts) + /** @param {unknown} title @param {ToastOptions} [opts] */ + error = (title, opts) => this.#add('error', title, opts) + /** @param {unknown} title @param {ToastOptions} [opts] */ + info = (title, opts) => this.#add('info', title, opts) + /** @param {unknown} title @param {ToastOptions} [opts] */ + warning = (title, opts) => this.#add('warning', title, opts) + /** @param {unknown} title @param {ToastOptions} [opts] */ + message = (title, opts) => this.#add('message', title, opts) + + /** @param {number} [id] */ + dismiss = (id) => { + if (id === undefined) { + for (const t of this.#timers.values()) clearTimeout(t.handle) + this.#timers.clear() + this.toasts = [] + return + } + const t = this.#timers.get(id) + if (t) { + clearTimeout(t.handle) + this.#timers.delete(id) + } + this.toasts = this.toasts.filter((x) => x.id !== id) + } + + getActiveToasts = () => this.toasts + + /** Pause every auto-dismiss timer (e.g. while the pointer is over the stack). */ + pauseAll = () => { + for (const t of this.#timers.values()) { + clearTimeout(t.handle) + t.remaining -= Date.now() - t.start + } + } + + /** Resume previously-paused auto-dismiss timers. */ + resumeAll = () => { + for (const [id, t] of this.#timers) { + t.start = Date.now() + t.handle = setTimeout(() => this.dismiss(id), Math.max(0, t.remaining)) + } + } +} + +export const toast = new ToastStore() diff --git a/src/lib/table-query.js b/src/lib/table-query.js index 356e81ab..4b0c6b07 100644 --- a/src/lib/table-query.js +++ b/src/lib/table-query.js @@ -112,3 +112,92 @@ export function hasTableQuery(search, filters, sort) { Boolean(sort?.column) ) } + +/** + * Build a SELECT statement that reflects the current table view (columns, + * filters, search, sort). Meant as an editable starting point in the SQL editor, + * NOT a parameterized query — values are inlined and escaped for display. + * + * @param {object} opts + * @param {string} [opts.schema] + * @param {string} opts.table + * @param {{ name: string }[]} [opts.columns] — when given, emits an explicit column list + * @param {TableFilter[]} [opts.filters] + * @param {string} [opts.search] + * @param {TableSort | null} [opts.sort] + * @param {number} [opts.limit] + * @param {string} [opts.engine] — connection type, for quoting/casing + * @returns {string} + */ +export function buildSelectSql({ schema, table, columns = [], filters = [], search = '', sort = null, limit = 100, engine = 'postgres' }) { + const mysql = engine === 'mysql' || engine === 'mariadb' + const pg = !mysql // postgres/sqlite/etc. use double-quote identifiers + ILIKE-ish + /** quote identifier */ + const q = (/** @type {string} */ id) => + mysql ? '`' + id.replace(/`/g, '``') + '`' : '"' + id.replace(/"/g, '""') + '"' + /** quote a string literal */ + const lit = (/** @type {string} */ v) => `'${String(v).replace(/'/g, "''")}'` + const like = pg ? 'ILIKE' : 'LIKE' + /** text-cast a column for substring search */ + const asText = (/** @type {string} */ col) => (engine === 'postgres' ? `${col}::text` : col) + + const target = schema ? `${q(schema)}.${q(table)}` : q(table) + // SELECT * keeps the generated query clean and avoids referencing virtual / + // hidden columns. `columns` is still used below to expand the search clause. + const cols = '*' + + /** @param {TableFilter} f */ + const cond = (f) => { + const col = q(f.column) + const v = f.value.trim() + switch (f.op) { + case 'eq': return `${col} = ${lit(v)}` + case 'neq': return `${col} <> ${lit(v)}` + case 'gt': return `${col} > ${lit(v)}` + case 'gte': return `${col} >= ${lit(v)}` + case 'lt': return `${col} < ${lit(v)}` + case 'lte': return `${col} <= ${lit(v)}` + case 'contains': return `${asText(col)} ${like} ${lit('%' + v + '%')}` + case 'not_contains': return `${asText(col)} NOT ${like} ${lit('%' + v + '%')}` + case 'starts_with': return `${asText(col)} ${like} ${lit(v + '%')}` + case 'ends_with': return `${asText(col)} ${like} ${lit('%' + v)}` + case 'is_null': return `${col} IS NULL` + case 'is_not_null': return `${col} IS NOT NULL` + case 'between': { + const [a = '', b = ''] = v.split(',').map((s) => s.trim()) + return `${col} BETWEEN ${lit(a)} AND ${lit(b)}` + } + default: return '' + } + } + + /** @type {string[]} */ + const where = [] + + // Cross-column search (only when we know the columns) + const s = search.trim() + if (s && columns.length) { + const ors = columns.map((c) => `${asText(q(c.name))} ${like} ${lit('%' + s + '%')}`) + where.push(`(${ors.join(' OR ')})`) + } + + // Column filters, chained with their and/or conjunct + const fs = activeFilters(filters).filter((f) => f.column !== ANY_COLUMN) + fs.forEach((f, i) => { + const c = cond(f) + if (!c) return + where.push(i === 0 && where.length === 0 ? c : `${(f.conjunct ?? 'and').toUpperCase()} ${c}`) + }) + + let sql = `SELECT ${cols}\nFROM ${target}` + if (where.length) { + // First clause has no leading AND/OR; the search group (if present) leads. + sql += `\nWHERE ${where[0].replace(/^(AND|OR)\s+/, '')}` + for (let i = 1; i < where.length; i++) { + sql += /^(AND|OR)\s/.test(where[i]) ? `\n ${where[i]}` : `\n AND ${where[i]}` + } + } + if (sort?.column) sql += `\nORDER BY ${q(sort.column)} ${sort.direction === 'desc' ? 'DESC' : 'ASC'}` + if (limit && limit > 0 && limit !== MAX_PAGE_SIZE) sql += `\nLIMIT ${limit}` + return sql + ';' +}