From 672087336519c1ab674b0988bd0f90ebc5a57cb2 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Thu, 2 Jul 2026 22:13:56 +0545 Subject: [PATCH 1/5] feat::working on sql editor --- .github/workflows/release.yml | 51 +- README.md | 7 +- src-tauri/src/db/query.rs | 212 +++++-- .../components/KeyboardShortcutsDialog.svelte | 10 +- src/lib/components/SqlConsole.svelte | 523 +++++------------- src/lib/components/SqlEditor.svelte | 105 +++- src/lib/components/StudioShell.svelte | 20 +- src/lib/monaco-sql-complete.js | 412 +++++++------- src/lib/sql-statements.js | 100 ++++ 9 files changed, 775 insertions(+), 665 deletions(-) create mode 100644 src/lib/sql-statements.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a7b311ec..55d720e0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,12 @@ # Build Stroke for Linux, Windows, and macOS and publish to GitHub Releases. # +# The source repo is PRIVATE, so releases are published to a separate PUBLIC +# repo (RELEASES_REPO below) that anonymous users, the in-app updater, Scoop, +# and Homebrew can all reach. Publishing cross-repo requires a fine-grained +# PAT with "Contents: read/write" on RELEASES_REPO, stored as the +# RELEASES_TOKEN secret in this repo (the default GITHUB_TOKEN only works +# within this repo). +# # Create a release: # git tag v0.1.0 # git push origin v0.1.0 @@ -24,6 +31,10 @@ permissions: env: RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }} + # Public repo that hosts release assets, latest.json, and the Scoop bucket. + # Keeping the old "stroke" name means updater endpoints and package-manager + # URLs baked into already-shipped versions continue to resolve. + RELEASES_REPO: broisnischal/stroke # ── 1. Create the release before any build starts ──────────────────────────── jobs: @@ -34,7 +45,7 @@ jobs: - name: Create GitHub release env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.RELEASES_TOKEN }} TAG: ${{ env.RELEASE_TAG }} SHA: ${{ github.sha }} run: | @@ -89,9 +100,12 @@ jobs: Built from \`${SHA}\`. EOF + # No --target: the source SHA lives in the private repo, not in + # RELEASES_REPO. The tag there just points at its default branch; + # the real source commit is recorded in the notes ("Built from …"). gh release create "$TAG" \ + --repo "$RELEASES_REPO" \ --title "Stroke $TAG" \ - --target "$SHA" \ --notes-file /tmp/notes.md \ $PRERELEASE @@ -171,7 +185,7 @@ jobs: if: matrix.name == 'macos-aarch64' shell: bash env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.RELEASES_TOKEN }} TAG: ${{ env.RELEASE_TAG }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} @@ -187,7 +201,7 @@ jobs: cp "$DIR/macos/Stroke.app.tar.gz" stroke_aarch64.app.tar.gz cp "$DIR/macos/Stroke.app.tar.gz.sig" updater.sig - gh release upload "$TAG" \ + gh release upload "$TAG" --repo "$RELEASES_REPO" \ "$DIR/dmg/stroke_${VERSION}_aarch64.dmg" \ stroke_aarch64.app.tar.gz \ --clobber @@ -197,7 +211,7 @@ jobs: if: matrix.name == 'macos-x64' shell: bash env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.RELEASES_TOKEN }} TAG: ${{ env.RELEASE_TAG }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} @@ -213,7 +227,7 @@ jobs: cp "$DIR/macos/Stroke.app.tar.gz" stroke_x64.app.tar.gz cp "$DIR/macos/Stroke.app.tar.gz.sig" updater.sig - gh release upload "$TAG" \ + gh release upload "$TAG" --repo "$RELEASES_REPO" \ "$DIR/dmg/stroke_${VERSION}_x64.dmg" \ stroke_x64.app.tar.gz \ --clobber @@ -223,7 +237,7 @@ jobs: if: matrix.name == 'linux' shell: bash env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.RELEASES_TOKEN }} TAG: ${{ env.RELEASE_TAG }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} @@ -249,7 +263,7 @@ jobs: cp "stroke_${VERSION}_amd64.AppImage.tar.gz" stroke_amd64.AppImage.tar.gz cp "stroke_${VERSION}_amd64.AppImage.tar.gz.sig" updater.sig - gh release upload "$TAG" \ + gh release upload "$TAG" --repo "$RELEASES_REPO" \ "stroke_${VERSION}_amd64.deb" \ "stroke-${VERSION}-1.x86_64.rpm" \ "stroke_${VERSION}_amd64.AppImage" \ @@ -261,7 +275,7 @@ jobs: if: matrix.name == 'windows' shell: pwsh env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.RELEASES_TOKEN }} TAG: ${{ env.RELEASE_TAG }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} @@ -304,7 +318,7 @@ jobs: Copy-Item $zip "stroke_x64-setup.nsis.zip" Copy-Item "$zip.sig" "updater.sig" - gh release upload "$env:TAG" ` + gh release upload "$env:TAG" --repo "$env:RELEASES_REPO" ` "$dir/msi/stroke_${version}_x64_en-US.msi" ` "$dir/nsis/stroke_${version}_x64-setup.exe" ` "stroke_x64-setup.nsis.zip" ` @@ -334,7 +348,7 @@ jobs: TAG: ${{ env.RELEASE_TAG }} run: | export VERSION=$(node -p "require('./src-tauri/tauri.conf.json').version") - export BASE="https://github.com/broisnischal/stroke/releases/download/${TAG}" + export BASE="https://github.com/${RELEASES_REPO}/releases/download/${TAG}" node - <<'JSEOF' const fs = require('fs') @@ -387,22 +401,25 @@ jobs: - name: Upload latest.json env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.RELEASES_TOKEN }} TAG: ${{ env.RELEASE_TAG }} - run: gh release upload "$TAG" latest.json --clobber + run: gh release upload "$TAG" --repo "$RELEASES_REPO" latest.json --clobber -# ── 4. Refresh the Scoop manifest in this repo (Windows clean-install path) ─── +# ── 4. Refresh the Scoop manifest in the public releases repo ──────────────── +# Scoop clones the bucket repo anonymously, so the manifest must live in +# RELEASES_REPO, not in this private source repo. update-scoop: needs: build runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 with: - ref: master + repository: broisnischal/stroke + token: ${{ secrets.RELEASES_TOKEN }} - name: Update bucket/stroke.json env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.RELEASES_TOKEN }} TAG: ${{ env.RELEASE_TAG }} run: | VERSION="${TAG#v}" @@ -448,7 +465,7 @@ jobs: echo "Scoop manifest already current." else git commit -m "chore(scoop): stroke v${VERSION}" - git push origin master + git push origin HEAD fi # Optional instant Homebrew tap refresh. Without a PAT this is skipped and diff --git a/README.md b/README.md index 921e95bb..370cf4ca 100644 --- a/README.md +++ b/README.md @@ -266,11 +266,14 @@ Connections are saved locally. Stroke reopens your last connection on launch. ## Build from source +The source repo is private — `github.com/broisnischal/stroke` is the public +releases/bucket repo. Collaborators clone the source repo: + Requires [Node.js](https://nodejs.org) 18+ and the [Rust toolchain](https://rustup.rs). ```bash -git clone https://github.com/broisnischal/stroke -cd stroke +git clone https://github.com/broisnischal/stroke-app +cd stroke-app npm install npm run tauri # dev npm run tauri:build # release binary diff --git a/src-tauri/src/db/query.rs b/src-tauri/src/db/query.rs index 87efa1cb..553a3d3a 100644 --- a/src-tauri/src/db/query.rs +++ b/src-tauri/src/db/query.rs @@ -1852,22 +1852,109 @@ async fn execute_sql_pg( }) } -async fn execute_sql_multi_pg(pool: &sqlx::PgPool, sql: &str) -> Result, String> { - let started = std::time::Instant::now(); - - let stmts: Vec<&str> = sql - .split(';') - .map(str::trim) - .filter(|s| !s.is_empty()) - .collect(); +/// True if `s` contains anything besides whitespace, semicolons, and comments. +fn sql_fragment_is_meaningful(s: &str) -> bool { + let b = s.as_bytes(); + let mut i = 0; + while i < b.len() { + match b[i] { + b'-' if i + 1 < b.len() && b[i + 1] == b'-' => { + while i < b.len() && b[i] != b'\n' { + i += 1; + } + } + b'/' if i + 1 < b.len() && b[i + 1] == b'*' => match s[i + 2..].find("*/") { + Some(p) => i = i + 2 + p + 2, + None => i = b.len(), + }, + b';' | b' ' | b'\t' | b'\r' | b'\n' => i += 1, + _ => return true, + } + } + false +} - if stmts.is_empty() { - return Err("Query is empty".into()); +/// Split a SQL script into individual statements on `;`, without splitting +/// inside quoted strings (`'…'` with `''`/`\'` escapes, `"…"`, backticks), +/// line/block comments, or Postgres dollar-quoted bodies (`$$…$$`, `$tag$…$tag$`). +/// Comment-only fragments are dropped. Mirrors `src/lib/sql-statements.js`. +fn split_sql_statements(sql: &str) -> Vec { + let b = sql.as_bytes(); + let n = b.len(); + let mut out: Vec = Vec::new(); + let mut i = 0usize; + let mut start = 0usize; + + fn flush(sql: &str, start: &mut usize, end: usize, out: &mut Vec) { + let frag = sql[*start..end].trim(); + if sql_fragment_is_meaningful(frag) { + out.push(frag.to_string()); + } + *start = end; + } + + while i < n { + match b[i] { + b'-' if i + 1 < n && b[i + 1] == b'-' => { + while i < n && b[i] != b'\n' { + i += 1; + } + } + b'/' if i + 1 < n && b[i + 1] == b'*' => match sql[i + 2..].find("*/") { + Some(p) => i = i + 2 + p + 2, + None => i = n, + }, + quote @ (b'\'' | b'"' | b'`') => { + i += 1; + while i < n { + if quote == b'\'' && b[i] == b'\\' { + i += 2; + continue; + } + if b[i] == quote { + // '' inside a single-quoted string is an escaped quote + if quote == b'\'' && i + 1 < n && b[i + 1] == b'\'' { + i += 2; + continue; + } + i += 1; + break; + } + i += 1; + } + } + b'$' => { + // Dollar-quote opener: `$$` or `$tag$` where tag starts with alpha/_ + let mut j = i + 1; + while j < n && (b[j] == b'_' || b[j].is_ascii_alphanumeric()) { + j += 1; + } + let tag_ok = j == i + 1 || b[i + 1] == b'_' || b[i + 1].is_ascii_alphabetic(); + if tag_ok && j < n && b[j] == b'$' { + let tag = &sql[i..=j]; + match sql[j + 1..].find(tag) { + Some(p) => i = j + 1 + p + tag.len(), + None => i = n, + } + } else { + i += 1; + } + } + b';' => { + i += 1; + flush(sql, &mut start, i, &mut out); + } + _ => i += 1, + } } + flush(sql, &mut start, n, &mut out); + out +} +async fn execute_sql_multi_pg(pool: &sqlx::PgPool, stmts: &[String]) -> Result, String> { // Single statement — delegate to existing path (avoids code duplication) if stmts.len() == 1 { - return execute_sql_pg(pool, stmts[0], None).await.map(|r| vec![r]); + return execute_sql_pg(pool, &stmts[0], None).await.map(|r| vec![r]); } let mut tx = pool @@ -1879,10 +1966,9 @@ async fn execute_sql_multi_pg(pool: &sqlx::PgPool, sql: &str) -> Result = Vec::new(); - for stmt in &stmts { + for stmt in stmts { let stmt_started = std::time::Instant::now(); let stmt_ms = || stmt_started.elapsed().as_millis() as u64; @@ -1969,32 +2055,40 @@ pub async fn execute_sql_multi(state: State<'_, DbState>, sql: String) -> Result let conn = require_conn(&state)?; let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<()>(); *state.cancel_tx.lock().map_err(|e| e.to_string())? = Some(cancel_tx); + let stmts = split_sql_statements(&sql_str); + if stmts.is_empty() { + return Err("Query is empty".into()); + } let result = tokio::select! { r = async move { - match conn { - ActiveConnection::Postgres(pool) => execute_sql_multi_pg(&pool, &sql_str).await, - ActiveConnection::Sqlite(pool) => { - super::sqlite::execute_sql(&pool, &sql_str).await.map(|r| vec![r]) - } - ActiveConnection::D1(cfg) => { - super::d1::query(&cfg, &sql_str, vec![]).await.map(|r| vec![r]) - } - ActiveConnection::LibSql(cfg) => { - super::libsql::query(&cfg, &sql_str, vec![]).await.map(|r| vec![r]) - } - ActiveConnection::Mysql(pool) => { - super::mysql::execute_sql(&pool, &sql_str, None).await.map(|r| vec![r]) - } - ActiveConnection::Clickhouse(cfg) => { - super::clickhouse::query(&cfg, &sql_str).await.map(|r| vec![r]) - } - ActiveConnection::Duckdb(h) => { - super::duckdb::execute_sql(&h, &sql_str).await.map(|r| vec![r]) - } - ActiveConnection::Mssql(h) => { - super::mssql::execute_sql(&h, &sql_str).await.map(|r| vec![r]) + // Postgres runs multi-statement scripts inside a single transaction + if let ActiveConnection::Postgres(pool) = &conn { + return execute_sql_multi_pg(pool, &stmts).await; + } + // Other engines: execute sequentially, one result set per statement. + // Cancellation happens at the outer select! (the future is dropped), + // so per-statement executors get no cancel receiver — same as the + // other diff/multi callers. + let multi = stmts.len() > 1; + let mut results: Vec = Vec::with_capacity(stmts.len()); + for (idx, stmt) in stmts.iter().enumerate() { + let r = match &conn { + ActiveConnection::Postgres(_) => unreachable!("handled above"), + ActiveConnection::Sqlite(pool) => super::sqlite::execute_sql(pool, stmt).await, + ActiveConnection::D1(cfg) => super::d1::query(cfg, stmt, vec![]).await, + ActiveConnection::LibSql(cfg) => super::libsql::query(cfg, stmt, vec![]).await, + ActiveConnection::Mysql(pool) => super::mysql::execute_sql(pool, stmt, None).await, + ActiveConnection::Clickhouse(cfg) => super::clickhouse::query(cfg, stmt).await, + ActiveConnection::Duckdb(h) => super::duckdb::execute_sql(h, stmt).await, + ActiveConnection::Mssql(h) => super::mssql::execute_sql(h, stmt).await, + }; + match r { + Ok(res) => results.push(res), + Err(e) if multi => return Err(format!("Statement {} failed: {e}", idx + 1)), + Err(e) => return Err(e), } } + Ok(results) } => r, _ = async { let _ = cancel_rx.await; } => Err("Query cancelled".to_string()), }; @@ -2438,3 +2532,51 @@ pub async fn ping_connection(state: State<'_, DbState>) -> Result<(), String> { ActiveConnection::Mssql(h) => super::mssql::execute_sql(&h, "SELECT 1").await.map(|_| ()), } } + +#[cfg(test)] +mod split_sql_tests { + use super::split_sql_statements; + + #[test] + fn splits_on_semicolons() { + let s = split_sql_statements("select 1; select 2;"); + assert_eq!(s, vec!["select 1;", "select 2;"]); + } + + #[test] + fn ignores_semicolons_in_strings_and_comments() { + let s = split_sql_statements( + "select 'a;b', \"c;d\" -- not; here\nfrom t; /* nor; here */ select `e;f`;", + ); + assert_eq!(s.len(), 2); + assert!(s[0].starts_with("select 'a;b'")); + } + + #[test] + fn ignores_semicolons_in_dollar_quotes() { + let s = split_sql_statements( + "create function f() returns void as $body$ begin; end; $body$ language plpgsql; select 1;", + ); + assert_eq!(s.len(), 2); + assert!(s[1].starts_with("select 1")); + } + + #[test] + fn handles_escaped_quotes() { + let s = split_sql_statements("select 'it''s; fine'; select 'a\\'; b';"); + assert_eq!(s.len(), 2); + } + + #[test] + fn drops_comment_only_fragments_and_empty_input() { + assert_eq!(split_sql_statements("select 1; -- trailing").len(), 1); + assert_eq!(split_sql_statements(" ;; -- nothing\n").len(), 0); + assert_eq!(split_sql_statements("").len(), 0); + } + + #[test] + fn statement_without_trailing_semicolon() { + let s = split_sql_statements("select 1;\nselect 2"); + assert_eq!(s, vec!["select 1;", "select 2"]); + } +} diff --git a/src/lib/components/KeyboardShortcutsDialog.svelte b/src/lib/components/KeyboardShortcutsDialog.svelte index 3fad5c3f..0d7f111d 100644 --- a/src/lib/components/KeyboardShortcutsDialog.svelte +++ b/src/lib/components/KeyboardShortcutsDialog.svelte @@ -58,10 +58,12 @@ label: "SQL Editor", icon: Terminal, shortcuts: [ - { keys: [mod, "↵"], desc: "Run query" }, - { keys: [mod, "S"], desc: "Format SQL" }, - { keys: [mod, "R"], desc: "Re-run last query" }, - { keys: [mod, "K"], desc: "Search query history" }, + { keys: [mod, "↵"], desc: "Run all statements" }, + { keys: [mod, "R"], desc: "Run statement at cursor" }, + { keys: [mod, "L"], desc: "Select current statement" }, + { keys: [mod, "S"], desc: "Save query" }, + { keys: [mod, "J"], desc: "Toggle output panel" }, + { keys: [mod, "⇧", "B"], desc: "Query history" }, ], }, { diff --git a/src/lib/components/SqlConsole.svelte b/src/lib/components/SqlConsole.svelte index 68720af2..1e1e0deb 100644 --- a/src/lib/components/SqlConsole.svelte +++ b/src/lib/components/SqlConsole.svelte @@ -1,16 +1,13 @@ +{#snippet kbd(/** @type {string} */ k)} + {k} +{/snippet} + +{#snippet hint(/** @type {string} */ label, /** @type {string} */ desc, /** @type {string[]} */ keys = [])} +
+
+ {label} + {#if keys.length} + + {#each keys as k (k)}{@render kbd(k)}{/each} + + {/if} +
+ {#if desc} +

{desc}

+ {/if} +
+{/snippet} + +
{#if loading} - + + + {#snippet child({ props })} + + {/snippet} + + + {@render hint('Stop', 'Cancel the running query.')} + + {:else} - + + + {#snippet child({ props })} + + {/snippet} + + + {@render hint( + 'Run query', + `Runs every statement — each gets its own result tab. ${mod}R runs only the statement under the cursor, ${mod}L selects it.`, + [mod, '↵'], + )} + + {/if}
- - - - + + + {#snippet child({ props })} + + {/snippet} + + + {@render hint('Format SQL', 'Reformat the whole editor with consistent casing and indentation.')} + + + + + + {#snippet child({ props })} + + {/snippet} + + + {@render hint('Save query', 'Keep this query for later — saved queries live in History → Saved, per connection.', [mod, 'S'])} + + + + + + {#snippet child({ props })} + + {/snippet} + + + {@render hint('Query history', 'Browse and re-run everything you have executed, plus your saved queries.', [mod, '⇧', 'B'])} + + + + + + {#snippet child({ props })} + + {/snippet} + + + {@render hint('Explain plan', 'Visualize how the database executes this query — spot slow scans and missing indexes.')} + + {#if ormCopied} @@ -439,10 +540,6 @@
- -
{/if} - + + + {#snippet child({ props })} + + {/snippet} + + + {@render hint(outputVisible ? 'Hide results' : 'Show results', 'Collapse the results panel to give the editor the full height.', [mod, 'J'])} + +
@@ -669,6 +775,7 @@ +
diff --git a/src/lib/components/SqlEditor.svelte b/src/lib/components/SqlEditor.svelte index 1c593520..d4f52570 100644 --- a/src/lib/components/SqlEditor.svelte +++ b/src/lib/components/SqlEditor.svelte @@ -10,7 +10,7 @@ readEditorFontOptions, } from '$lib/monaco-themes.js' import { normalizeThemeId } from '$lib/themes/registry.js' - import { splitSqlStatements, statementAtOffset } from '$lib/sql-statements.js' + import { splitSqlStatements, statementAtOffset, lintSql } from '$lib/sql-statements.js' import { cn } from '$lib/utils.js' /** @typedef {import('$lib/monaco-sql-complete.js').SqlSchemaHints} SqlSchemaHints */ @@ -50,6 +50,33 @@ let container = $state(null) /** @type {monaco.editor.IStandaloneCodeEditor | null} */ let editor = null + /** @type {monaco.editor.IEditorDecorationsCollection | null} */ + let execDecorations = null + + /** + * Mark statement(s) as successfully executed with a ✓ in the glyph margin. + * Pass the single statement that ran (⌘R), or null to mark every statement + * (run all). Marks clear automatically on the next edit. + * @param {string | null} [ranStatement] + */ + export function markExecuted(ranStatement = null) { + const model = editor?.getModel() + if (!model || !execDecorations) return + const target = typeof ranStatement === 'string' ? ranStatement.trim().replace(/;+\s*$/, '') : null + const marks = [] + for (const stmt of splitSqlStatements(model.getValue())) { + if (target !== null && stmt.text.replace(/;+\s*$/, '') !== target) continue + const pos = model.getPositionAt(stmt.start) + marks.push({ + range: new monaco.Range(pos.lineNumber, 1, pos.lineNumber, 1), + options: { + glyphMarginClassName: 'sql-glyph-ok', + glyphMarginHoverMessage: { value: 'Ran successfully' }, + }, + }) + } + execDecorations.set(marks) + } /** Reads current app theme from . */ function currentTheme() { @@ -181,7 +208,8 @@ // strip between the numbers and the code. lineNumbersMinChars: 2, lineDecorationsWidth: 6, - glyphMargin: false, + // Glyph margin hosts the executed-✓ and lint error/warning dots + glyphMargin: true, folding: false, scrollbar: { verticalScrollbarSize: 6, horizontalScrollbarSize: 6 }, overviewRulerLanes: 0, @@ -252,6 +280,41 @@ } editor.onDidChangeCursorPosition(refreshActiveStatement) + // ── Lint: squiggles + gutter dots for lexical SQL problems ────────────── + execDecorations = editor.createDecorationsCollection() + const lintDecorations = editor.createDecorationsCollection() + /** @type {ReturnType | null} */ + let lintTimer = null + + function runLint() { + const model = editor?.getModel() + if (!model) return + const diags = readOnly ? [] : lintSql(model.getValue()) + monaco.editor.setModelMarkers(model, 'stroke-sql', diags.map((d) => { + const s = model.getPositionAt(d.start) + const e = model.getPositionAt(d.end) + return { + message: d.message, + severity: d.severity === 'error' ? monaco.MarkerSeverity.Error : monaco.MarkerSeverity.Warning, + startLineNumber: s.lineNumber, + startColumn: s.column, + endLineNumber: e.lineNumber, + endColumn: e.column, + } + })) + lintDecorations.set(diags.map((d) => { + const s = model.getPositionAt(d.start) + return { + range: new monaco.Range(s.lineNumber, 1, s.lineNumber, 1), + options: { + glyphMarginClassName: d.severity === 'error' ? 'sql-glyph-error' : 'sql-glyph-warn', + glyphMarginHoverMessage: { value: d.message }, + }, + } + })) + } + runLint() + // Replaces automaticLayout's polling loop: relayout only when the container // actually resizes. const ro = new ResizeObserver(() => editor?.layout()) @@ -299,6 +362,10 @@ onchange?.(next) } refreshActiveStatement() + // Executed-✓ marks describe a previous buffer state — drop them on edit + execDecorations?.clear() + if (lintTimer) clearTimeout(lintTimer) + lintTimer = setTimeout(runLint, 350) }) // Watch changes — reliable regardless of mode-watcher internals @@ -312,9 +379,13 @@ return () => { document.removeEventListener('keydown', docRunHandler, { capture: true }) + if (lintTimer) clearTimeout(lintTimer) + const model = editor?.getModel() + if (model) monaco.editor.setModelMarkers(model, 'stroke-sql', []) ro.disconnect() editor?.dispose() editor = null + execDecorations = null themeObserver.disconnect() } }) @@ -366,6 +437,39 @@ background: color-mix(in srgb, var(--primary) 45%, transparent); } + /* ── Glyph margin: executed ✓ and lint dots ─────────────────────────── */ + + .sql-editor-host :global(.sql-glyph-ok), + .sql-editor-host :global(.sql-glyph-error), + .sql-editor-host :global(.sql-glyph-warn) { + display: flex !important; + align-items: center; + justify-content: center; + } + + .sql-editor-host :global(.sql-glyph-ok)::after { + content: '✓'; + font-size: 11px; + font-weight: 700; + color: var(--color-green-500, #22c55e); + } + + .sql-editor-host :global(.sql-glyph-error)::after, + .sql-editor-host :global(.sql-glyph-warn)::after { + content: ''; + width: 6px; + height: 6px; + border-radius: 9999px; + } + + .sql-editor-host :global(.sql-glyph-error)::after { + background: var(--destructive, #ef4444); + } + + .sql-editor-host :global(.sql-glyph-warn)::after { + background: color-mix(in srgb, var(--color-amber-500, #f59e0b) 80%, transparent); + } + /* ── Suggestion widget ──────────────────────────────────────────────── */ /* fixedOverflowWidgets:true moves these to , so no host ancestor. Widgets lose the editor's inherited font — set it explicitly. */ diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index 265da501..906ddffa 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -1773,18 +1773,43 @@ let rowSearch = $state('') } } - /** @param {string} id — keep only this tab, close everything else */ + /** @param {string} id — keep this tab (and pinned tabs), close everything else */ async function closeOtherTabs(id) { const keep = tabs.find((t) => t.id === id) if (!keep) return - tabs = [keep] + tabs = tabs.filter((t) => t.id === id || t.pinned) await activateTab(keep.id) } async function closeAllTabs() { - tabs = [createWelcomeTab()] - activeTabId = tabs[0].id - clearTableEditor() + const pinned = tabs.filter((t) => t.pinned) + if (pinned.length === 0) { + tabs = [createWelcomeTab()] + activeTabId = tabs[0].id + clearTableEditor() + return + } + tabs = pinned + if (!pinned.some((t) => t.id === activeTabId)) await activateTab(pinned[0].id) + } + + /** + * "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). + * @param {string} table + */ + async function openTableStructure(table) { + if (aiMode) exitAiMode() + await openTableTab(activeSchema, table) + tableViewMode = 'structure' + } + + /** @param {string} id — pin/unpin a tab; pinned tabs group at the front */ + function toggleTabPin(id) { + const tab = tabs.find((t) => t.id === id) + if (!tab) return + tab.pinned = !tab.pinned + tabs = [...tabs.filter((t) => t.pinned), ...tabs.filter((t) => !t.pinned)] } /** @@ -3451,6 +3476,7 @@ let rowSearch = $state('') ontruncatetable={handleTruncateTable} ondroptable={(t, c) => void handleDropTable(t, c)} onviewddl={(t) => void handleViewDdl(t)} + onviewstructure={(t) => void openTableStructure(t)} onexportsql={(t) => void handleExportSql(t)} onexportdata={(t) => void handleExportData(t)} ongeneratetestdata={(t) => void handleGenerateTestData(t)} @@ -3579,6 +3605,7 @@ let rowSearch = $state('') onclose={closeTab} oncloseothers={closeOtherTabs} oncloseall={closeAllTabs} + onpintoggle={toggleTabPin} onnew={openWelcomeTab} {recentTabs} onrecentselect={(schema, table) => { if (aiMode) exitAiMode(); void openTableTab(schema, table) }} diff --git a/src/lib/components/TabBar.svelte b/src/lib/components/TabBar.svelte index cb7702d8..5d8fa1e4 100644 --- a/src/lib/components/TabBar.svelte +++ b/src/lib/components/TabBar.svelte @@ -13,6 +13,8 @@ import Blocks from '@lucide/svelte/icons/blocks' import X from '@lucide/svelte/icons/x' import Plus from '@lucide/svelte/icons/plus' + import Pin from '@lucide/svelte/icons/pin' + import PinOff from '@lucide/svelte/icons/pin-off' import Clock from '@lucide/svelte/icons/clock' import ChevronDown from '@lucide/svelte/icons/chevron-down' import { cn } from '$lib/utils.js' @@ -42,6 +44,7 @@ onclose = () => {}, oncloseothers = /** @param {string} _id */ (_id) => {}, oncloseall = () => {}, + onpintoggle = /** @param {string} _id */ (_id) => {}, onnew = () => {}, /** @type {import('$lib/stores/recent-tabs.js').RecentTab[]} */ recentTabs = [], @@ -125,22 +128,39 @@ {tabDisplayTitle(tab)} - - + {#if tab.pinned} + + + {:else} + + + {/if} {#if !active && !nextActive} @@ -151,6 +171,16 @@ + onpintoggle(tab.id)}> + {#if tab.pinned} + + Unpin Tab + {:else} + + Pin Tab + {/if} + + onclose(tab.id)}>Close oncloseothers(tab.id)}> Close Others diff --git a/src/lib/components/ui/tooltip/index.js b/src/lib/components/ui/tooltip/index.js new file mode 100644 index 00000000..6d97ad99 --- /dev/null +++ b/src/lib/components/ui/tooltip/index.js @@ -0,0 +1,18 @@ +import { Tooltip as TooltipPrimitive } from "bits-ui"; +import Content from "./tooltip-content.svelte"; + +const Root = TooltipPrimitive.Root; +const Trigger = TooltipPrimitive.Trigger; +const Provider = TooltipPrimitive.Provider; + +export { + Root, + Trigger, + Content, + Provider, + // + Root as Tooltip, + Trigger as TooltipTrigger, + Content as TooltipContent, + Provider as TooltipProvider, +}; diff --git a/src/lib/components/ui/tooltip/tooltip-content.svelte b/src/lib/components/ui/tooltip/tooltip-content.svelte new file mode 100644 index 00000000..001b3e70 --- /dev/null +++ b/src/lib/components/ui/tooltip/tooltip-content.svelte @@ -0,0 +1,25 @@ + + + + + diff --git a/src/lib/monaco-sql-complete.js b/src/lib/monaco-sql-complete.js index 2bd8d191..0447a675 100644 --- a/src/lib/monaco-sql-complete.js +++ b/src/lib/monaco-sql-complete.js @@ -276,6 +276,9 @@ function quoteIdent(name) { // registers its model's hints getter here; the provider looks up the model it // is completing for, so hints never depend on which editor mounted first. +/** Stable empty-hints object so the template cache isn't invalidated per request. */ +const EMPTY_HINTS = /** @type {SqlSchemaHints} */ ({}) + /** @type {WeakMap SqlSchemaHints>} */ const hintsByModel = new WeakMap() /** @type {(() => SqlSchemaHints) | null} */ @@ -462,7 +465,7 @@ export function registerMonacoSqlCompletion(monaco, getHints) { // popped the widget when it wasn't wanted. triggerCharacters: ['.'], provideCompletionItems(model, position) { - const hints = (hintsByModel.get(model) ?? fallbackGetHints)?.() ?? {} + const hints = (hintsByModel.get(model) ?? fallbackGetHints)?.() ?? EMPTY_HINTS const S = getStaticTemplates(monaco) const H = getHintTemplates(monaco, hints) @@ -516,31 +519,61 @@ export function registerMonacoSqlCompletion(monaco, getHints) { } } + // ── Next-action detection ────────────────────────────────────────── + const upperStmt = stmtBeforeCursor.toUpperCase() + // "SELECT …" with no FROM yet → the most likely next token is FROM + const wantsFrom = ctx === 'column' && /\bSELECT\b/.test(upperStmt) && !/\bFROM\b/.test(upperStmt) + // "FROM users " (table already named) → clause keywords beat more tables + const afterTableName = ctx === 'table' && /\b(?:FROM|JOIN|UPDATE|INTO)\s+[\w"$.]+\s+[\w"]*$/i.test(stmtBeforeCursor) + // ── Priority tiers by context ────────────────────────────────────── // table → tables=0 schemas=1 kws=2 fns=7 cols=9 snip=6 // column → cols_ref=0 cols_all=1 fns=2 kws=3 tables=7 snip=6 // any → snip=0 kws=1 schemas=2 tables=3 cols=4 fns=5 - const tierTable = ctx === 'table' ? '0_' : ctx === 'column' ? '7_' : '3_' - const tierSchema = ctx === 'table' ? '1_' : ctx === 'column' ? '8_' : '2_' + const tierTable = ctx === 'table' ? (afterTableName ? '7_' : '0_') : ctx === 'column' ? '7_' : '3_' + const tierSchema = ctx === 'table' ? (afterTableName ? '8_' : '1_') : ctx === 'column' ? '8_' : '2_' const tierColRef = ctx === 'column' ? '0_' : ctx === 'table' ? '9_' : '4_' // cols from referenced tables const tierColAll = ctx === 'column' ? '1_' : ctx === 'table' ? '9_' : '4_' // cols from other tables const tierFn = ctx === 'column' ? '2_' : ctx === 'table' ? '7_' : '5_' - const tierKw = ctx === 'table' ? '2_' : ctx === 'column' ? '3_' : '1_' + const tierKw = ctx === 'table' ? (afterTableName ? '0_' : '2_') : ctx === 'column' ? '3_' : '1_' const tierSnip = ctx === 'any' ? '0_' : '6_' + if (wantsFrom) { + suggestions.push({ + label: 'FROM', + kind: monaco.languages.CompletionItemKind.Keyword, + insertText: 'FROM ', + detail: 'keyword', + range, + sortText: '00_from', + // Re-open the widget so FROM immediately offers tables + command: { id: 'editor.action.triggerSuggest', title: 'Suggest' }, + }) + } + // ── 2. Schemas + tables ──────────────────────────────────────────── for (const s of H.schemas) push(s, tierSchema) for (const t of H.tables) push(t, tierTable) // ── 3. Columns — referenced tables first, then all others ────────── + // Non-referenced tables are deduped by column name: without a FROM the + // same column (id, count, …) would otherwise appear once per table. const refTableSet = new Set(referencedTables) + const seenColNames = new Set() for (const refTable of referencedTables) { - for (const c of H.colsByTbl.get(refTable) ?? []) push(c, tierColRef) + for (const c of H.colsByTbl.get(refTable) ?? []) { + push(c, tierColRef) + seenColNames.add(c.lc) + } } for (const [tblShort, cols] of H.colsByTbl) { if (refTableSet.has(tblShort)) continue - for (const c of cols) push(c, tierColAll) + for (const c of cols) { + if (seenColNames.has(c.lc)) continue + seenColNames.add(c.lc) + push(c, tierColAll) + } } // ── 4. Functions + enum values (not in table context) ────────────── diff --git a/src/lib/sql-statements.js b/src/lib/sql-statements.js index 8878a246..e7018ea7 100644 --- a/src/lib/sql-statements.js +++ b/src/lib/sql-statements.js @@ -79,6 +79,111 @@ export function splitSqlStatements(text) { return out } +/** + * @typedef {{ message: string, severity: 'error' | 'warning', start: number, end: number }} SqlDiagnostic + * Offsets are into the source text; `end` exclusive. + */ + +/** + * Lightweight SQL lint — catches lexical problems worth flagging while typing: + * unterminated strings/identifiers, unclosed block comments and dollar quotes, + * unbalanced parentheses, and a missing `;` on the final statement. + * + * @param {string} text + * @returns {SqlDiagnostic[]} + */ +export function lintSql(text) { + /** @type {SqlDiagnostic[]} */ + const diags = [] + const n = text.length + let i = 0 + /** @type {number[]} open-paren offsets */ + const parens = [] + + while (i < n) { + const ch = text[i] + const next = text[i + 1] + if (ch === '-' && next === '-') { + const nl = text.indexOf('\n', i + 2) + i = nl === -1 ? n : nl + 1 + } else if (ch === '/' && next === '*') { + const close = text.indexOf('*/', i + 2) + if (close === -1) { + diags.push({ message: 'Unclosed block comment — missing */', severity: 'warning', start: i, end: n }) + i = n + } else { + i = close + 2 + } + } else if (ch === "'" || ch === '"' || ch === '`') { + const qStart = i + i++ + let closed = false + while (i < n) { + if (ch === "'" && text[i] === '\\') { i += 2; continue } + if (text[i] === ch) { + if (ch === "'" && text[i + 1] === "'") { i += 2; continue } + i++ + closed = true + break + } + i++ + } + if (!closed) { + diags.push({ + message: ch === "'" ? "Unterminated string — missing closing '" : `Unterminated quoted identifier — missing closing ${ch}`, + severity: 'error', + start: qStart, + end: n, + }) + } + } else if (ch === '$') { + const m = /^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/.exec(text.slice(i)) + if (m) { + const tag = m[0] + const close = text.indexOf(tag, i + tag.length) + if (close === -1) { + diags.push({ message: `Unterminated dollar-quoted string — missing closing ${tag}`, severity: 'error', start: i, end: n }) + i = n + } else { + i = close + tag.length + } + } else { + i++ + } + } else if (ch === '(') { + parens.push(i) + i++ + } else if (ch === ')') { + if (parens.length === 0) { + diags.push({ message: 'Unmatched closing parenthesis', severity: 'error', start: i, end: i + 1 }) + } else { + parens.pop() + } + i++ + } else { + i++ + } + } + + for (const p of parens) { + diags.push({ message: 'Unclosed parenthesis', severity: 'warning', start: p, end: p + 1 }) + } + + // Final statement not terminated with `;` + const stmts = splitSqlStatements(text) + const last = stmts[stmts.length - 1] + if (last && !last.text.endsWith(';')) { + diags.push({ + message: "Statement is not terminated with ';'", + severity: 'warning', + start: Math.max(last.end - 1, last.start), + end: last.end, + }) + } + + return diags +} + /** * Find the statement the cursor is in. When the cursor sits between two * statements (blank line after a `;`), prefer the previous statement; before diff --git a/src/lib/studio-tabs.js b/src/lib/studio-tabs.js index f6c8707d..c6b6509d 100644 --- a/src/lib/studio-tabs.js +++ b/src/lib/studio-tabs.js @@ -47,6 +47,7 @@ * @property {StudioTabKind} kind * @property {string} title * @property {TableTabState | SqlTabState | null} state + * @property {boolean} [pinned] — pinned tabs stay grouped at the front and survive "Close Others"/"Close All" */ let tabSeq = 0 From bbdc37f7b7329062e523ffaa63e05d77369e231f Mon Sep 17 00:00:00 2001 From: broisnischal Date: Thu, 2 Jul 2026 23:08:43 +0545 Subject: [PATCH 3/5] feat::working --- src-tauri/src/db/connection.rs | 37 ++++++++++++++++----------- src/lib/components/StudioShell.svelte | 5 ++-- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/src-tauri/src/db/connection.rs b/src-tauri/src/db/connection.rs index 3034fded..2ea7aff3 100644 --- a/src-tauri/src/db/connection.rs +++ b/src-tauri/src/db/connection.rs @@ -269,22 +269,29 @@ fn set_conn(state: &State<'_, DbState>, conn: Option) -> Resul async fn close_existing(state: &State<'_, DbState>) { let old = state.conn.lock().ok().and_then(|mut g| g.take()); - let timeout = std::time::Duration::from_secs(3); - match old { - // PgPool::close() waits for every connection to be returned to the pool. - // A stalled or long-running query would block forever, crashing the UI. - // Cap it: after 3 s we move on and let the OS clean up the sockets. - Some(ActiveConnection::Postgres(p)) => { - let _ = tokio::time::timeout(timeout, p.close()).await; + let Some(conn) = old else { return }; + // Drain the old pool in the background. Pool::close() waits for every + // connection to be handed back — and against a dead peer (sleep/wake, + // network change: exactly the state a reconnect follows) that stalls for + // seconds. Nothing downstream needs the drain to finish — the state slot + // is already empty — so never make connect/switch wait on it. The timeout + // caps the drain; after that the OS cleans up the sockets. + tokio::spawn(async move { + let timeout = std::time::Duration::from_secs(3); + match conn { + ActiveConnection::Postgres(p) => { + let _ = tokio::time::timeout(timeout, p.close()).await; + } + ActiveConnection::Sqlite(p) => { + let _ = tokio::time::timeout(timeout, p.close()).await; + } + ActiveConnection::Mysql(p) => { + let _ = tokio::time::timeout(timeout, p.close()).await; + } + // Handle-based engines (DuckDB, MSSQL) and HTTP configs clean up in Drop. + _ => {} } - Some(ActiveConnection::Sqlite(p)) => { - let _ = tokio::time::timeout(timeout, p.close()).await; - } - Some(ActiveConnection::Mysql(p)) => { - let _ = tokio::time::timeout(timeout, p.close()).await; - } - _ => {} - } + }); } // ── Fast reachability preflight ─────────────────────────────────────────────── diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index 906ddffa..63d0c4de 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -2644,8 +2644,9 @@ let rowSearch = $state('') if (schemas.length > 0) break } await loadTables({ force: true }) - // If tables came back empty despite a valid schema, give the backend one more chance - if (tables.length === 0 && schemas.length > 0) { + // Retry only when the fetch actually failed — an empty database is a valid + // result and must not pay a 1 s penalty on every connect. + if (tables.length === 0 && schemas.length > 0 && error) { await new Promise(r => setTimeout(r, 1000)) await loadTables({ force: true }) } From 006d60e833182805e65e069136ef83736213dc60 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Fri, 3 Jul 2026 08:44:58 +0545 Subject: [PATCH 4/5] feat::working on optimization and all fixes --- index.html | 2 +- src-tauri/Cargo.toml | 30 ++ src-tauri/src/commands.rs | 15 +- src-tauri/src/db/connection.rs | 80 +++- src-tauri/src/db/mod.rs | 4 +- src-tauri/src/db/schema.rs | 150 +++--- src-tauri/src/lib.rs | 1 + src/app.css | 8 + src/lib/api.js | 16 + src/lib/canvas-table.js | 12 +- src/lib/components/AiMarkdown.svelte | 27 +- src/lib/components/AiSidebar.svelte | 2 +- src/lib/components/DataTable.svelte | 115 +++-- src/lib/components/LicensePage.svelte | 208 +++++++++ src/lib/components/SettingsDialog.svelte | 569 ++++++++++------------- src/lib/components/Sidebar.svelte | 128 ++++- src/lib/components/SqlConsole.svelte | 68 ++- src/lib/components/StatusBar.svelte | 2 +- src/lib/components/StudioShell.svelte | 100 +++- src/lib/components/TabBar.svelte | 7 + src/lib/components/TitleBar.svelte | 4 +- src/lib/studio-tabs.js | 17 +- src/lib/table-list.js | 8 +- src/lib/themes/app-themes.css | 36 ++ src/lib/themes/monaco-presets.js | 29 ++ src/lib/themes/registry.js | 18 +- 26 files changed, 1142 insertions(+), 514 deletions(-) create mode 100644 src/lib/components/LicensePage.svelte diff --git a/index.html b/index.html index 1de6be30..3f873cbf 100644 --- a/index.html +++ b/index.html @@ -7,7 +7,7 @@ const parsed = raw ? JSON.parse(raw) : null const valid = [ 'light', 'parchment', 'ice', 'github-light', - 'dark', 'midnight', 'vitesse', 'slate', 'forest', 'mono', + 'dark', 'graphite', 'midnight', 'vitesse', 'slate', 'forest', 'mono', 'rose-pine', 'catppuccin', 'solarized', ] const lightThemes = ['light', 'parchment', 'ice', 'github-light'] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b651c003..a66966eb 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -58,3 +58,33 @@ objc2-web-kit = { version = "0.3", features = ["WKWebView"] } strip = true lto = true codegen-units = 1 + +# ── Dev-profile crypto optimization ─────────────────────────────────────────── +# Connect-time auth runs heavy crypto: Postgres SCRAM-SHA-256 (PBKDF2, 4096 +# HMAC-SHA-256 rounds via sha2/hmac), MySQL caching_sha2_password (RSA via +# rsa/num-bigint-dig), and rustls/ring TLS handshakes. Unoptimized, these are +# 50-200x slower — debug-build connects took seconds while release was instant. +# Optimizing only these leaf crates keeps incremental dev builds fast; they are +# compiled once and never change with app code. +[profile.dev.package.sha2] +opt-level = 3 +[profile.dev.package.sha1] +opt-level = 3 +[profile.dev.package.hmac] +opt-level = 3 +[profile.dev.package.digest] +opt-level = 3 +[profile.dev.package.md-5] +opt-level = 3 +[profile.dev.package.rsa] +opt-level = 3 +[profile.dev.package.num-bigint-dig] +opt-level = 3 +[profile.dev.package.ring] +opt-level = 3 +[profile.dev.package.rustls] +opt-level = 3 +[profile.dev.package.curve25519-dalek] +opt-level = 3 +[profile.dev.package.block-buffer] +opt-level = 3 diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index d7933cf0..28fc2359 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -122,11 +122,11 @@ pub fn toggle_devtools(window: tauri::WebviewWindow) { use crate::db::{ connect, connect_clickhouse, connect_d1, connect_duckdb, connect_libsql, connect_mssql, connect_mysql, connect_sqlite, disconnect, delete_table_row, delete_table_rows, execute_ddl, execute_sql, execute_sql_multi, get_table_rows, insert_table_row, - list_schemas, list_tables, list_indexes, list_enums, list_functions, list_triggers, list_sequences, ping_connection, + list_schemas, list_tables, list_indexes, list_enums, list_functions, list_triggers, list_sequences, ping_connection, table_row_counts, truncate_table, drop_table, get_table_column_structure, get_incoming_foreign_keys, get_table_ddl as db_get_table_ddl, test_clickhouse_connection, test_connection, test_d1_connection, test_duckdb_connection, test_libsql_connection, test_mssql_connection, test_mysql_connection, test_sqlite_connection, update_table_cell, ConnectionConfig, D1Config, DbState, EnumInfo, FunctionInfo, ExplainResult, IndexInfo, LibSqlConfig, - SqlResult, SqliteConfig, TableInfo, TableRows, TriggerInfo, SequenceInfo, + SqlResult, SqliteConfig, TableInfo, TableRowCount, TableRows, TriggerInfo, SequenceInfo, ColumnStructureRow, IncomingForeignKey, InsertRowResult, TunnelState, explain_pg, explain_mysql, explain_sqlite, }; @@ -310,6 +310,17 @@ pub async fn pg_list_tables( list_tables(state, schema).await } +/// Background pass: exact COUNT(*) for tables `pg_list_tables` returned with an +/// unknown (-1) row count. See `db::schema::table_row_counts`. +#[tauri::command] +pub async fn pg_table_row_counts( + state: State<'_, DbState>, + schema: String, + tables: Vec, +) -> Result, String> { + table_row_counts(state, schema, tables).await +} + #[tauri::command] pub async fn pg_list_indexes( state: State<'_, DbState>, diff --git a/src-tauri/src/db/connection.rs b/src-tauri/src/db/connection.rs index 2ea7aff3..b98356f2 100644 --- a/src-tauri/src/db/connection.rs +++ b/src-tauri/src/db/connection.rs @@ -313,14 +313,7 @@ async fn tcp_preflight(host: &str, port: u16) -> Result<(), String> { // ── PostgreSQL connect / test ───────────────────────────────────────────────── -pub(crate) async fn open_pg(config: &PgConfig) -> Result { - tcp_preflight(&config.host, config.port).await?; - let opts: PgConnectOptions = config - .connection_url() - .parse() - .map_err(|e| format!("Connection failed: {e}"))?; - let opts = opts.log_slow_statements(LevelFilter::Debug, Duration::from_secs(5)); - +fn pg_pool_builder() -> PgPoolOptions { PgPoolOptions::new() // Desktop app: at most 2-3 tabs open simultaneously, each running 1-2 // queries. 4 connections is the real-world ceiling; monitored logs showed @@ -343,19 +336,64 @@ pub(crate) async fn open_pg(config: &PgConfig) -> Result { // sleep/wake. max_lifetime caps server-side staleness. .idle_timeout(Duration::from_secs(600)) .max_lifetime(Duration::from_secs(1800)) - // Kill truly runaway queries. 10 min covers bulk inserts / migrations while - // still bounding accidental full-table scans that would pin a connection. - .after_connect(|conn, _meta| { - Box::pin(async move { - sqlx::query("SET statement_timeout = '10min'") - .execute(&mut *conn) - .await?; - Ok(()) - }) - }) - .connect_with(opts) - .await - .map_err(|e| format!("Connection failed: {e}")) +} + +pub(crate) async fn open_pg(config: &PgConfig) -> Result { + let opts: PgConnectOptions = config + .connection_url() + .parse() + .map_err(|e| format!("Connection failed: {e}"))?; + let opts = opts.log_slow_statements(LevelFilter::Debug, Duration::from_secs(5)); + + // Kill truly runaway queries. 10 min covers bulk inserts / migrations while + // still bounding accidental full-table scans that would pin a connection. + // + // Preferred path: ride statement_timeout in the startup packet (`options=`), + // which saves one round trip per pooled connection vs an after_connect SET — + // on a remote host that's a full RTT for every connection the pool opens. + let fast_opts = opts + .clone() + .options([("statement_timeout", "10min")]); + + let connect = async { + match pg_pool_builder().connect_with(fast_opts).await { + Ok(pool) => Ok(pool), + // Some poolers (PgBouncer without `ignore_startup_parameters=options`) + // reject the `options` startup parameter outright. Fall back to the + // slower after_connect SET so those hosts still connect. + Err(e) if e.to_string().contains("unsupported startup parameter") => { + pg_pool_builder() + .after_connect(|conn, _meta| { + Box::pin(async move { + sqlx::query("SET statement_timeout = '10min'") + .execute(&mut *conn) + .await?; + Ok(()) + }) + }) + .connect_with(opts) + .await + .map_err(|e| format!("Connection failed: {e}")) + } + Err(e) => Err(format!("Connection failed: {e}")), + } + }; + + // Race the real connect against a raw TCP preflight instead of running the + // preflight first: it exists only to fail fast with a clear message on + // unreachable hosts/wrong ports/empty values. When the host is reachable + // the preflight resolves Ok quickly and we simply keep waiting for the + // handshake — no longer a serial extra round trip before every connect. + let preflight = tcp_preflight(&config.host, config.port); + tokio::pin!(preflight); + tokio::pin!(connect); + tokio::select! { + pre = &mut preflight => { + pre?; + connect.await + } + pool = &mut connect => pool, + } } pub async fn test_connection(config: PgConfig) -> Result<(), String> { diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs index 2dd76aa1..ae3b9066 100644 --- a/src-tauri/src/db/mod.rs +++ b/src-tauri/src/db/mod.rs @@ -29,7 +29,7 @@ pub use query::{ pub use schema::{ list_schemas, list_tables, list_schemas_on_conn, list_tables_on_conn, list_indexes, list_enums, list_functions, list_triggers, list_sequences, - truncate_table, drop_table, get_table_column_structure, get_incoming_foreign_keys, + table_row_counts, truncate_table, drop_table, get_table_column_structure, get_incoming_foreign_keys, get_table_ddl, get_table_ddl_on_conn, - TableInfo, IndexInfo, EnumInfo, FunctionInfo, TriggerInfo, SequenceInfo, ColumnStructureRow, IncomingForeignKey, + TableInfo, TableRowCount, IndexInfo, EnumInfo, FunctionInfo, TriggerInfo, SequenceInfo, ColumnStructureRow, IncomingForeignKey, }; diff --git a/src-tauri/src/db/schema.rs b/src-tauri/src/db/schema.rs index 78127620..2760dd5d 100644 --- a/src-tauri/src/db/schema.rs +++ b/src-tauri/src/db/schema.rs @@ -123,54 +123,76 @@ const LIST_INDEXES_SQL: &str = r#" "#; async fn exact_row_count(pool: &PgPool, schema: &str, table: &str) -> Result { - let sql = format!(r#"SELECT COUNT(*)::bigint FROM "{schema}"."{table}""#); + // Names arrive from the frontend in the lazy-count pass — escape embedded + // quotes so an unusual (or hostile) identifier can't break out of the quoting. + let q = |id: &str| format!("\"{}\"", id.replace('"', "\"\"")); + let sql = format!("SELECT COUNT(*)::bigint FROM {}.{}", q(schema), q(table)); sqlx::query_scalar(&sql) .fetch_one(pool) .await .map_err(|e| format!("Failed to count rows for {table}: {e}")) } -async fn resolve_unknown_row_counts(pool: &PgPool, schema: &str, tables: &mut [TableInfo]) { - // Zero out view/foreign-table entries immediately (no COUNT needed). - for t in tables.iter_mut() { - if t.row_count < 0 && (t.kind == "view" || t.kind == "foreign_table") { - t.row_count = 0; - } - } - - // Collect indices of materialized views / tables that still need an exact count. - let needs_count: Vec = tables - .iter() - .enumerate() - .filter(|(_, t)| t.row_count < 0) - .map(|(i, _)| i) - .collect(); +/// Bound on concurrent COUNT(*) queries in the lazy-count pass. Firing all of +/// them at once (join_all) on a large schema swamps the small desktop pool — +/// 70+ acquires against max_connections=4 queue for seconds each ("time to +/// acquire exceeded slow threshold") and starve interactive queries. Matches +/// the pool's max_connections (see open_pg). +const COUNT_CONCURRENCY: usize = 4; - if needs_count.is_empty() { - return; - } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TableRowCount { + pub name: String, + pub row_count: i64, +} - // Run the COUNT(*) queries with bounded concurrency. Firing all of them at - // once (join_all) on a large schema swamps the small desktop pool — 70+ - // acquires against max_connections=4 queue for seconds each ("time to acquire - // exceeded slow threshold") and starve the schema/catalog loads happening in - // parallel during (re)connect. Capping the in-flight count at the pool size - // keeps counts fast without ever saturating the pool. +/// Exact COUNT(*) for the given tables of the active connection. +/// +/// `list_tables` returns immediately with estimates (-1 = unknown) so the +/// sidebar renders without waiting on counts; the UI then calls this in a +/// background pass and patches the counts in as they resolve. Engines whose +/// `list_tables` already returns exact counts inline (SQLite/D1/libSQL/…) +/// never report -1, so they return an empty list here. +pub async fn table_row_counts( + state: State<'_, DbState>, + schema: String, + tables: Vec, +) -> Result, String> { use futures::stream::StreamExt; - // Matches the pool's max_connections (see open_pg). One connection is left - // implicitly free for interactive work by keeping the cap at the ceiling - // rather than above it. - const COUNT_CONCURRENCY: usize = 4; - let counts: Vec<(usize, i64)> = futures::stream::iter(needs_count.iter().copied().map(|i| { - let name = tables[i].name.clone(); - async move { (i, exact_row_count(pool, schema, &name).await.unwrap_or(0)) } - })) - .buffer_unordered(COUNT_CONCURRENCY) - .collect() - .await; - - for (idx, count) in counts { - tables[idx].row_count = count; + match require_conn(&state)? { + ActiveConnection::Postgres(pool) => { + let counts: Vec = + futures::stream::iter(tables.into_iter().map(|name| { + let pool = pool.clone(); + let schema = schema.clone(); + async move { + let row_count = exact_row_count(&pool, &schema, &name).await.unwrap_or(0); + TableRowCount { name, row_count } + } + })) + .buffer_unordered(COUNT_CONCURRENCY) + .collect() + .await; + Ok(counts) + } + ActiveConnection::Mysql(pool) => { + let counts: Vec = + futures::stream::iter(tables.into_iter().map(|name| { + let pool = pool.clone(); + let schema = schema.clone(); + async move { + let row_count = + mysql_exact_row_count(&pool, &schema, &name).await.unwrap_or(0); + TableRowCount { name, row_count } + } + })) + .buffer_unordered(COUNT_CONCURRENCY) + .collect() + .await; + Ok(counts) + } + _ => Ok(Vec::new()), } } @@ -208,7 +230,15 @@ async fn list_tables_pg(pool: &PgPool, schema: &str) -> Result, S }) .collect(); - resolve_unknown_row_counts(pool, schema, &mut tables).await; + // Views and foreign tables never get a COUNT(*) — report 0 immediately. + // Real tables keep -1 (unknown): returning right away lets the sidebar + // render instantly, and the UI resolves exact counts in a background + // `table_row_counts` pass instead of blocking the list on N COUNT(*)s. + for t in tables.iter_mut() { + if t.row_count < 0 && (t.kind == "view" || t.kind == "foreign_table") { + t.row_count = 0; + } + } Ok(tables) } @@ -528,37 +558,6 @@ async fn mysql_exact_row_count(pool: &MySqlPool, schema: &str, table: &str) -> R .map_err(|e| format!("Failed to count rows for {table}: {e}")) } -/// Replace ambiguous (-1) row counts with an exact COUNT(*). Mirrors the -/// PostgreSQL `resolve_unknown_row_counts` path. -async fn resolve_unknown_row_counts_mysql(pool: &MySqlPool, schema: &str, tables: &mut [TableInfo]) { - for t in tables.iter_mut() { - if t.row_count < 0 && t.kind == "view" { - t.row_count = 0; - } - } - - let needs_count: Vec = tables - .iter() - .enumerate() - .filter(|(_, t)| t.row_count < 0) - .map(|(i, _)| i) - .collect(); - - if needs_count.is_empty() { - return; - } - - let futs: Vec<_> = needs_count - .iter() - .map(|&i| mysql_exact_row_count(pool, schema, &tables[i].name)) - .collect(); - let counts = futures::future::join_all(futs).await; - - for (&idx, result) in needs_count.iter().zip(counts.iter()) { - tables[idx].row_count = result.as_ref().copied().unwrap_or(0); - } -} - async fn list_tables_mysql(pool: &MySqlPool, schema: &str) -> Result, String> { let rows = sqlx::query( "SELECT TABLE_NAME, TABLE_TYPE, COALESCE(TABLE_ROWS, 0) \ @@ -588,7 +587,14 @@ async fn list_tables_mysql(pool: &MySqlPool, schema: &str) -> Results with no ring classes of their own — give them a visible + theme-colored ring so tabbing through the app is followable. */ + [data-studio-chrome] :is(button, [role='button']):focus-visible { + outline: 2px solid color-mix(in oklch, var(--ring) 55%, transparent); + outline-offset: -1px; + } + /* tabindex="-1" elements are programmatically focusable but never keyboard-navigable; they should never show a focus ring regardless of input method */ [tabindex="-1"]:focus, diff --git a/src/lib/api.js b/src/lib/api.js index e4d324bf..a3cd53a8 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -296,6 +296,22 @@ export async function listTables(schema) { } } +/** + * Exact row counts for tables that `listTables` returned with an unknown + * (null/-1) count. Called in a background pass so the sidebar renders + * immediately and counts fill in as they resolve. + * @param {string} schema + * @param {string[]} tables + * @returns {Promise<{ name: string, rowCount: number }[]>} + */ +export async function getTableRowCounts(schema, tables) { + try { + return await invoke('pg_table_row_counts', { schema, tables }) + } catch (err) { + throw new Error(formatInvokeError(err)) + } +} + /** @param {string} schema */ export async function listIndexes(schema) { try { diff --git a/src/lib/canvas-table.js b/src/lib/canvas-table.js index db684fbf..8089848d 100644 --- a/src/lib/canvas-table.js +++ b/src/lib/canvas-table.js @@ -62,6 +62,10 @@ export function withAlpha(/** @type {string} */ color, /** @type {number} */ a) const cached = _alphaCache.get(key); if (cached !== undefined) return cached; const out = _computeAlpha(color, a); + // Theme colors produce a few dozen stable keys, but extension-computed + // per-cell tints (heatmap etc.) can mint a distinct alpha per value — cap + // the cache so a long session over big result sets can't grow it forever. + if (_alphaCache.size >= 4096) _alphaCache.clear(); _alphaCache.set(key, out); return out; } @@ -261,7 +265,8 @@ export function drawTriangle(ctx, cx, cy, r, dir, color) { /** * Compute per-column horizontal geometry in absolute *content* coordinates * (0 = far left, before horizontal scroll). Pinned columns keep their content - * slot but get a `pinnedFixedX` resting position so they freeze to the left. + * slot but get a `pinnedFixedX` resting position so they freeze to the + * viewport's left edge once scrolled past. * * @param {object} o * @param {{ name: string, dataType: string }[]} o.columns Visible columns in order. @@ -279,7 +284,10 @@ export function computeColumnGeometry({ columns, widthOf, isPinned, gutterWidth contentX += w; } const totalWidth = contentX; - let pinX = gutterWidth; + // Pinned columns freeze from the viewport's left edge (0), not after the + // gutters: the gutters scroll away with the content, so reserving their width + // here would leave a permanent empty band left of the frozen columns. + let pinX = 0; /** @type {Map} */ const pinnedFixedX = new Map(); for (const col of cols) { diff --git a/src/lib/components/AiMarkdown.svelte b/src/lib/components/AiMarkdown.svelte index d6b5582e..a80528c6 100644 --- a/src/lib/components/AiMarkdown.svelte +++ b/src/lib/components/AiMarkdown.svelte @@ -1,4 +1,5 @@ + +
+
+ +
+
+ +
+
+

Stroke Pro

+

+ + {statusLine.text} +

+
+
+ + {#if status?.status === 'Valid' && !activatedHere} + +
+
+ + License active +
+
+
+
Email
+
{status.email}
+
+
+
Plan
+
{status.plan}
+
+ {#if status.issued_at} +
+
Activated
+
{formatDate(status.issued_at)}
+
+ {/if} +
+ +
+ {:else} + +
+
+

Enter your license key

+

Sent to your email after purchase.

+
+ onactivated()} /> +
+ {/if} + + +
+

What Pro unlocks

+
    + {#each features as f (f.label)} +
  • + + {f.label} +
  • + {/each} +
+
+ + {#if status?.status !== 'Valid'} + +
+

No license yet?

+ +
+ {/if} +
+
diff --git a/src/lib/components/SettingsDialog.svelte b/src/lib/components/SettingsDialog.svelte index 1a574b3f..478da962 100644 --- a/src/lib/components/SettingsDialog.svelte +++ b/src/lib/components/SettingsDialog.svelte @@ -2,11 +2,17 @@ import Minus from "@lucide/svelte/icons/minus"; import Plus from "@lucide/svelte/icons/plus"; import ChevronRight from "@lucide/svelte/icons/chevron-right"; + import Blocks from "@lucide/svelte/icons/blocks"; + import Server from "@lucide/svelte/icons/server"; + import Sparkles from "@lucide/svelte/icons/sparkles"; + import KeyRound from "@lucide/svelte/icons/key-round"; + import Info from "@lucide/svelte/icons/info"; 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"; import ThemeSwatch from "$lib/components/ThemeSwatch.svelte"; import { getThemeDefinition, themesByGroup } from "$lib/themes/registry.js"; + import { licenseStatus } from "$lib/stores/license.js"; import { appThemeId, loadSettings, @@ -33,6 +39,7 @@ onopenmodelconfiguration = () => {}, onopenabout = () => {}, onopenextensions = () => {}, + onopenlicense = () => {}, } = $props(); let settings = $state(loadSettings()); @@ -49,7 +56,7 @@ }); const themeSelectTrigger = - "h-8 w-[11.5rem] justify-between gap-2 border-border/80 bg-background px-2 text-ui-xs font-normal shadow-none"; + "h-7 w-[11.5rem] justify-between gap-2 border-border/80 bg-background px-2 text-ui-xs font-normal shadow-none"; function refreshSettings() { settings = loadSettings(); @@ -88,6 +95,10 @@ settings = updateSettings({ mcpAutoStart: !settings.mcpAutoStart }); } + function toggleAutoReconnect() { + settings = updateSettings({ autoReconnectOnStartup: !settings.autoReconnectOnStartup }); + } + /** @type {boolean | null} */ let launchAtLogin = $state(null); @@ -119,341 +130,277 @@ } const zoomLabel = $derived(`${Math.round(settings.zoom * 100)}%`); + + const planBadge = $derived.by(() => { + const s = $licenseStatus; + if (s?.status === "Valid") return { label: "Pro", class: "border-green-500/25 bg-green-500/10 text-green-600 dark:text-green-400" }; + if (s?.status === "Trial") return { label: `Trial · ${s.days_remaining}d`, class: "border-amber-500/25 bg-amber-500/10 text-amber-600 dark:text-amber-400" }; + return { label: "Free", class: "border-border/70 bg-muted/40 text-muted-foreground" }; + }); - - - Settings - - Appearance and integrations + + + Settings + + Appearance, behavior, and integrations -
-
-
- Theme - { - if (v) - setTheme( - /** @type {import('$lib/themes/registry.js').ThemeId} */ (v), - ); - }} - > - + +
+

Appearance

+
+
+ Theme + { + if (v) setTheme(/** @type {import('$lib/themes/registry.js').ThemeId} */ (v)); + }} > - - - {activeTheme.name} - - - - {#each themeGroups as group, i (group.id)} - {#if i > 0} - - {/if} - - - {group.label} - - {#each group.themes as theme (theme.id)} - + + + {activeTheme.name} + + + + {#each themeGroups as group, i (group.id)} + {#if i > 0} + + {/if} + + - {#snippet children()} - - - - {theme.name} - - {theme.description} + {group.label} + + {#each group.themes as theme (theme.id)} + + {#snippet children()} + + + + {theme.name} + + {theme.description} + - - {/snippet} - - {/each} - - {/each} - - -
+ {/snippet} + + {/each} + + {/each} + + +
-
- Font - { if (v) setFont(/** @type {import('$lib/stores/settings.js').FontId} */ (v)); }} - > - + Font + { if (v) setFont(/** @type {import('$lib/stores/settings.js').FontId} */ (v)); }} > - - - {FONT_PRESETS[settings.font]?.label ?? "Geist"} - - - - {#each fontEntries as [id, preset] (id)} - - {#snippet children()} - - - - {preset.label} - {preset.description} + + + + {FONT_PRESETS[settings.font]?.label ?? "Geist"} + + + + {#each fontEntries as [id, preset] (id)} + + {#snippet children()} + + + + {preset.label} + {preset.description} + - - {/snippet} - - {/each} - - -
+ {/snippet} + + {/each} + + +
-
- Icons - { if (v) setIconStyle(/** @type {import('$lib/stores/settings.js').IconStyleId} */ (v)); }} - > - + Icons + { if (v) setIconStyle(/** @type {import('$lib/stores/settings.js').IconStyleId} */ (v)); }} > - - - - - {#each iconStyleEntries as [id, preset] (id)} - - {#snippet children()} - - - - - {preset.label} - {preset.description} + + + + + + {#each iconStyleEntries as [id, preset] (id)} + + {#snippet children()} + + + + + {preset.label} + {preset.description} + - - {/snippet} - - {/each} - - -
- -
- Zoom -
- - - + {/snippet} + + {/each} + +
-
-
-
-

MCP auto-start

-

- On database connect -

+
+ Zoom +
+ + + +
-
+ -
-
-

Launch at login

-
- + +
+

Behavior

+
+ {@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('MCP auto-start', 'Start the MCP server on database connect', settings.mcpAutoStart, toggleMcpAutoStart)}
+
-
-
-

Auto reconnect on startup

-

- Reconnect to last database on launch -

-
- + +
+

More

+
+ {@render navRow(Blocks, 'Extensions', () => { open = false; onopenextensions(); })} + {@render navRow(Server, 'MCP configuration', () => { open = false; onopenmcp(); })} + {@render navRow(Sparkles, 'AI models', openModelConfiguration)} + {@render navRow(KeyRound, 'License', () => { open = false; onopenlicense(); }, planBadge)} + {@render navRow(Info, 'About Stroke', () => { open = false; onopenabout(); })}
+
+
- - - - -
- -

- ⌘M cycle theme · ⌘⇧M previous theme · ⌘+ / ⌘− zoom · ⌘0 reset -

+ +
+ {@render shortcut('⌘M', 'cycle theme')} + {@render shortcut('⌘⇧M', 'previous theme')} + {@render shortcut('⌘+ / ⌘−', 'zoom')} + {@render shortcut('⌘0', 'reset zoom')}
+ +{#snippet toggleRow(/** @type {string} */ label, /** @type {string} */ description, /** @type {boolean} */ checked, /** @type {() => void} */ ontoggle)} +
+
+

{label}

+

{description}

+
+ +
+{/snippet} + +{#snippet navRow(/** @type {any} */ Icon, /** @type {string} */ label, /** @type {() => void} */ onselect, /** @type {{ label: string, class: string } | null} */ badge = null)} + +{/snippet} + +{#snippet shortcut(/** @type {string} */ keys, /** @type {string} */ action)} + + {keys} + {action} + +{/snippet} diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte index ceb506ab..a310275f 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -20,7 +20,6 @@ import Code2 from "@lucide/svelte/icons/code-2"; import FileDown from "@lucide/svelte/icons/file-down"; import Download from "@lucide/svelte/icons/download"; - import Sparkles from "@lucide/svelte/icons/sparkles"; import ClipboardCopy from "@lucide/svelte/icons/clipboard-copy"; import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js"; import RefreshCw from "@lucide/svelte/icons/refresh-cw"; @@ -86,7 +85,6 @@ onviewddl = /** @type {(table: string) => void} */ (() => {}), onexportsql = /** @type {(table: string) => void} */ (() => {}), onexportdata = /** @type {(table: string) => void} */ (() => {}), - ongeneratetestdata = /** @type {(table: string) => void} */ (() => {}), /** Names of tables that currently have an open tab (current schema). @type {string[]} */ openTables = [], onclosetable = /** @type {(table: string) => void} */ (() => {}), @@ -129,9 +127,41 @@ activeSchema; untrack(() => { expandedTables = new Set(); + expandedHeights = new Map(); }); }); + /** + * Measured pixel height of each expanded row's inline column list, keyed by + * table name. Lets the virtual list keep working while rows are expanded: + * collapsed rows use the fixed ROW_H stride, expanded rows add their real + * rendered height. Measurements persist after a row scrolls out of the + * render window so the spacer math stays stable. + * @type {Map} + */ + let expandedHeights = $state(new Map()); + + /** @param {string} name @param {number} h */ + function setExpandedHeight(name, h) { + if (h <= 0 || expandedHeights.get(name) === h) return; + const next = new Map(expandedHeights); + next.set(name, h); + expandedHeights = next; + } + + /** + * Svelte action: keep the expanded column list's height in expandedHeights. + * offsetHeight is exact because the list uses padding (not margins) for its + * outer spacing. + * @param {HTMLElement} node @param {string} name + */ + function measureExpanded(node, name) { + setExpandedHeight(name, node.offsetHeight); + const ro = new ResizeObserver(() => setExpandedHeight(name, node.offsetHeight)); + ro.observe(node); + return { destroy: () => ro.disconnect() }; + } + const openTableSet = $derived(new Set(openTables)) // PostgreSQL is the only driver where "schema" is a CREATE SCHEMA namespace. @@ -482,21 +512,80 @@ tableListOffsetTop = off }) - // Expanded rows have variable height, which breaks the fixed-stride spacer - // math — fall back to full rendering while anything is expanded. - const shouldVirtualize = $derived(filteredRegularTables.length > VIRT_THRESHOLD && expandedTables.size === 0) + const shouldVirtualize = $derived(filteredRegularTables.length > VIRT_THRESHOLD) + + /** Fallback extra height for an expanded row that hasn't been measured yet + * (only reachable in exotic cases — expanding mounts the row, which measures + * immediately). Roughly one "Loading columns…" line. */ + const EXPANDED_FALLBACK_H = 26 + + /** + * Expanded rows within the filtered list, ascending by index, with the extra + * height their inline column list adds beyond the fixed ROW_H stride. + * Usually 0-3 entries, so the per-scroll window math below stays O(expanded) + * rather than O(tables). + */ + const expandedExtras = $derived.by(() => { + if (expandedTables.size === 0) return /** @type {{ i: number, extra: number }[]} */ ([]) + const out = [] + for (let i = 0; i < filteredRegularTables.length; i++) { + const name = filteredRegularTables[i].name + if (expandedTables.has(name)) { + out.push({ i, extra: expandedHeights.get(name) ?? EXPANDED_FALLBACK_H }) + } + } + return out + }) const virtStart = $derived.by(() => { if (!shouldVirtualize) return 0 - return Math.max(0, Math.floor((sidebarScrollTop - tableListOffsetTop) / ROW_H) - VIRT_BUFFER) + const y = sidebarScrollTop - tableListOffsetTop + // Subtract the extra height of expanded blocks that sit fully above y. + // If y lands INSIDE an expanded block (its tall column list), cap the + // start at that row so it stays mounted while any part of it is visible. + let acc = 0 + let cap = Infinity + for (const { i, extra } of expandedExtras) { + const top = i * ROW_H + acc + if (top + ROW_H + extra <= y) { + acc += extra + } else { + if (top <= y) cap = i + break + } + } + const idx = Math.min(cap, Math.floor((y - acc) / ROW_H)) + return Math.max(0, idx - VIRT_BUFFER) }) const virtEnd = $derived.by(() => { if (!shouldVirtualize) return filteredRegularTables.length - const end = Math.ceil((sidebarScrollTop + sidebarHeight - tableListOffsetTop) / ROW_H) + VIRT_BUFFER + const y2 = sidebarScrollTop + sidebarHeight - tableListOffsetTop + // Expanded blocks starting above the viewport bottom push later rows down, + // so fewer fixed-stride rows fit — fold their extra height out of y2. + // minEnd keeps a block that straddles the bottom edge mounted. + let acc = 0 + let minEnd = 0 + for (const { i, extra } of expandedExtras) { + if (i * ROW_H + acc <= y2) { + acc += extra + minEnd = i + 1 + } else break + } + const end = Math.max(minEnd, Math.ceil((y2 - acc) / ROW_H)) + VIRT_BUFFER return Math.min(filteredRegularTables.length, end) }) - const virtTopPad = $derived(shouldVirtualize ? virtStart * ROW_H : 0) - const virtBotPad = $derived(shouldVirtualize ? Math.max(0, (filteredRegularTables.length - virtEnd) * ROW_H) : 0) + const virtTopPad = $derived.by(() => { + if (!shouldVirtualize) return 0 + let extra = 0 + for (const e of expandedExtras) { if (e.i < virtStart) extra += e.extra; else break } + return virtStart * ROW_H + extra + }) + const virtBotPad = $derived.by(() => { + if (!shouldVirtualize) return 0 + let extra = 0 + for (const e of expandedExtras) { if (e.i >= virtEnd) extra += e.extra } + return Math.max(0, (filteredRegularTables.length - virtEnd) * ROW_H + extra) + }) /** Shared field chrome for schema select + table filter (aligned in sidebar grid) */ const sidebarFieldClass = @@ -988,11 +1077,6 @@ Export data - - ongeneratetestdata(tableName)}> - - Generate test data - {/if} @@ -1115,8 +1199,10 @@ {/if} {#if showRowCount} + {formatTableRowCount(table.rowCount)} {/if} @@ -1201,11 +1287,6 @@ Export data - ongeneratetestdata(table.name)}> - - Generate test data - - openDangerDialog('truncate', table.name)}> Truncate table @@ -1221,7 +1302,12 @@ {#if isExpanded} {@const colState = expandedColumnsCache.get(`${activeSchema}.${table.name}`)} -
    + +
      {#if colState === 'loading' || colState === undefined}
    • diff --git a/src/lib/components/SqlConsole.svelte b/src/lib/components/SqlConsole.svelte index 09ba0f39..05b1bbac 100644 --- a/src/lib/components/SqlConsole.svelte +++ b/src/lib/components/SqlConsole.svelte @@ -148,6 +148,7 @@ untrack(() => { selected = new Set() activeResultIdx = 0 + resultSort = null if (outputView === 'explain') outputView = 'table' }) }) @@ -164,15 +165,58 @@ }) }) - const currentDisplay = $derived.by(() => { - if (resultSets.length > 1) { - const idx = Math.min(activeResultIdx, resultSets.length - 1) - const s = resultSets[idx] - return { columns: s.columns, rows: s.rows, queryMs: s.queryMs, message: s.message } + /** + * Header-click sort for result rows. Ad-hoc results have no table to re-query, + * so sorting is done client-side over the returned rows. + * @type {{ column: string, direction: 'asc' | 'desc' } | null} + */ + let resultSort = $state(null) + + /** Compare two cell values: numbers numerically, everything else as text. */ + function compareCellValues(a, b) { + if (typeof a === 'number' && typeof b === 'number') return a - b + if (typeof a === 'boolean' && typeof b === 'boolean') return a === b ? 0 : a ? 1 : -1 + const sa = typeof a === 'object' ? JSON.stringify(a) : String(a) + const sb = typeof b === 'object' ? JSON.stringify(b) : String(b) + if (sa.trim() !== '' && sb.trim() !== '') { + const na = Number(sa) + const nb = Number(sb) + if (!Number.isNaN(na) && !Number.isNaN(nb)) return na - nb } - return { columns, rows, queryMs, message } + return sa.localeCompare(sb, undefined, { numeric: true, sensitivity: 'base' }) + } + + const currentDisplay = $derived.by(() => { + const base = resultSets.length > 1 + ? (() => { + const idx = Math.min(activeResultIdx, resultSets.length - 1) + const s = resultSets[idx] + return { columns: s.columns, rows: s.rows, queryMs: s.queryMs, message: s.message } + })() + : { columns, rows, queryMs, message } + if (!resultSort) return base + const colIdx = base.columns.findIndex((c) => (c.name ?? c) === resultSort.column) + if (colIdx < 0) return base + const dir = resultSort.direction === 'desc' ? -1 : 1 + const sorted = [...base.rows].sort((ra, rb) => { + const a = ra[colIdx] + const b = rb[colIdx] + const aNull = a === null || a === undefined + const bNull = b === null || b === undefined + // NULLs sort last in either direction. + if (aNull || bNull) return aNull && bNull ? 0 : aNull ? 1 : -1 + return dir * compareCellValues(a, b) + }) + return { ...base, rows: sorted } }) + /** @param {{ column: string, direction: 'asc' | 'desc' } | null} sort */ + function handleResultSort(sort) { + resultSort = sort + // Row indices change with the order — selection would point at the wrong rows. + selected = new Set() + } + async function handleExplain() { const querySql = sql.trim() if (!querySql) return @@ -632,7 +676,7 @@ {@const rsActive = Math.min(activeResultIdx, resultSets.length - 1) === i}
@@ -3379,7 +3436,10 @@ let rowSearch = $state('') /> -{#if autoConnecting} +{#if autoConnecting && !connection} +
void openTableStructure(t)} onexportsql={(t) => void handleExportSql(t)} onexportdata={(t) => void handleExportData(t)} - ongeneratetestdata={(t) => void handleGenerateTestData(t)} openTables={tabs.filter((t) => t.kind === 'table' && t.state && /** @type {any} */ (t.state).schema === activeSchema).map((t) => /** @type {any} */ (t.state).table)} onclosetable={(name) => { const tab = findTableTab(tabs, activeSchema, name) @@ -3684,6 +3743,17 @@ let rowSearch = $state('')
{/if} + + {#if activeTab?.kind === 'license'} +
+ + {#await import('./LicensePage.svelte')}{:then { default: LicensePage }} + + {/await} + +
+ {/if} + {#if jsonEverOpened}
getCurrentWindow().toggleMaximize()).catch(() => {}) } @@ -77,6 +83,7 @@ if (tab.kind === 'security') return ShieldCheck if (tab.kind === 'extensions') return Blocks if (tab.kind === 'search') return Search + if (tab.kind === 'license') return KeyRound return FileText } diff --git a/src/lib/components/TitleBar.svelte b/src/lib/components/TitleBar.svelte index e44c1572..b1361ea7 100644 --- a/src/lib/components/TitleBar.svelte +++ b/src/lib/components/TitleBar.svelte @@ -90,11 +90,13 @@
+
diff --git a/src/lib/studio-tabs.js b/src/lib/studio-tabs.js index c6b6509d..714c356a 100644 --- a/src/lib/studio-tabs.js +++ b/src/lib/studio-tabs.js @@ -1,4 +1,4 @@ -/** @typedef {'table' | 'sql' | 'welcome' | 'ai' | 'schema' | 'orm' | 'security' | 'logs' | 'json' | 'charts' | 'dashboard' | 'erd' | 'reltree' | 'diagrams' | 'search' | 'notebook' | 'schema-timeline' | 'data-diff'} StudioTabKind */ +/** @typedef {'table' | 'sql' | 'welcome' | 'ai' | 'schema' | 'orm' | 'security' | 'logs' | 'extensions' | 'backup' | 'json' | 'charts' | 'dashboard' | 'erd' | 'reltree' | 'diagrams' | 'search' | 'notebook' | 'schema-timeline' | 'data-diff' | 'license'} StudioTabKind */ /** @typedef {import('$lib/table-query.js').TableSort} TableSort */ /** @typedef {import('$lib/table-query.js').TableFilter} TableFilter */ @@ -339,6 +339,20 @@ export function findErdTab(tabs) { return tabs.find((t) => t.kind === 'erd') ?? null } +export function createLicenseTab() { + return /** @type {StudioTab} */ ({ + id: nextTabId(), + kind: 'license', + title: 'Stroke Pro', + state: null, + }) +} + +/** @param {StudioTab[]} tabs */ +export function findLicenseTab(tabs) { + return tabs.find((t) => t.kind === 'license') ?? null +} + /** @param {TableTabState} state */ export function tableTabTitle(state) { if (!state.table) return 'Table' @@ -364,6 +378,7 @@ export function tabDisplayTitle(tab) { if (tab.kind === 'notebook') return tab.title || 'Untitled Notebook' if (tab.kind === 'schema-timeline') return 'Schema Timeline' if (tab.kind === 'data-diff') return 'Data Diff' + if (tab.kind === 'license') return 'Stroke Pro' return tab.title } diff --git a/src/lib/table-list.js b/src/lib/table-list.js index bd65aeba..c278011f 100644 --- a/src/lib/table-list.js +++ b/src/lib/table-list.js @@ -3,6 +3,8 @@ * @param {number | string | null | undefined} count */ export function formatCompactCount(count) { + // null = count still being resolved in the background (see normalizeTableRowCount) + if (count === null) return '…' const n = Number(count) if (!Number.isFinite(n)) return '—' @@ -35,10 +37,14 @@ export function formatTableRowCount(count) { } /** + * Backend reports -1 when a table's row count wasn't resolved yet (it arrives + * later via getTableRowCounts). Map that to null so the UI can tell + * "still counting" apart from a genuinely empty table. * @param {number | string | null | undefined} count + * @returns {number | null} */ export function normalizeTableRowCount(count) { const n = Number(count) - if (!Number.isFinite(n) || n < 0) return 0 + if (!Number.isFinite(n) || n < 0) return null return n } diff --git a/src/lib/themes/app-themes.css b/src/lib/themes/app-themes.css index 4f9a035e..00ec0378 100644 --- a/src/lib/themes/app-themes.css +++ b/src/lib/themes/app-themes.css @@ -443,3 +443,39 @@ html[data-theme="solarized"] { --editor-surface: #002b36; --editor-line-highlight: #073642; } + +/* ── Graphite — cool JetBrains-style graphite, restrained blue accent ── + Flat surfaces: content #1e1f22, chrome a step darker, raised menus lighter. + Separation comes from hairline borders; blue is reserved for primary/ + selection/focus so data (syntax colors, statuses) is the only other color. */ +html[data-theme="graphite"] { + --background: #1e1f22; + --foreground: #dfe1e5; + --card: #26282c; + --card-foreground: #dfe1e5; + --popover: #26282c; + --popover-foreground: #dfe1e5; + --primary: #3574f0; + --primary-foreground: #ffffff; + --secondary: #2b2d31; + --secondary-foreground: #d3d5da; + --muted: #2b2d31; + --muted-foreground: #9da1a8; + --accent: #313438; + --accent-foreground: #eceef2; + --destructive: #e5534b; + --border: #37393e; + --input: #3c3e44; + --ring: #3574f0; + --sidebar: #1b1c1f; + --sidebar-foreground: #d3d5da; + --sidebar-accent: #2b2d31; + --sidebar-accent-foreground:#eceef2; + --sidebar-border: #313337; + --panel: #1e1f22; + --table-grid: #2e3034; + --link: #548af7; + --link-hover: #7ba4f9; + --editor-surface: #1e1f22; + --editor-line-highlight: #26282d; +} diff --git a/src/lib/themes/monaco-presets.js b/src/lib/themes/monaco-presets.js index 91c08588..97288e46 100644 --- a/src/lib/themes/monaco-presets.js +++ b/src/lib/themes/monaco-presets.js @@ -153,6 +153,35 @@ export const MONACO_THEME_SPECS = { { token: 'identifier', foreground: 'ebebeb' }, ], }, + graphite: { + base: 'vs-dark', + chrome: { + background: '#1e1f22', + foreground: '#dfe1e5', + lineNumber: '#5c5f66', + lineNumberActive: '#9da1a8', + lineHighlight: '#26282d', + selection: '#3574f04d', + selectionInactive: '#3574f026', + cursor: '#dfe1e5', + widgetBg: '#26282c', + widgetBorder: '#37393e', + indent: '#2c2e33', + indentActive: '#43464c', + bracketBg: '#3a3d4288', + bracketBorder: '#5c5f66', + }, + rules: [ + { token: 'comment', foreground: '6f737a', fontStyle: 'italic' }, + { token: 'string', foreground: '7ee787' }, + { token: 'string.sql', foreground: '7ee787' }, + { token: 'number', foreground: '79c0ff' }, + { token: 'keyword', foreground: 'c4b5fd' }, + { token: 'keyword.sql', foreground: 'c4b5fd' }, + { token: 'operator', foreground: 'd1d5db' }, + { token: 'identifier', foreground: 'dfe1e5' }, + ], + }, vitesse: { base: 'vs-dark', chrome: { diff --git a/src/lib/themes/registry.js b/src/lib/themes/registry.js index cb3504a0..7784c66a 100644 --- a/src/lib/themes/registry.js +++ b/src/lib/themes/registry.js @@ -1,6 +1,6 @@ /** @typedef {'light' | 'dark'} ThemeGroup */ -/** @typedef {'light' | 'parchment' | 'ice' | 'github-light' | 'dark' | 'midnight' | 'vitesse' | 'slate' | 'forest' | 'mono' | 'rose-pine' | 'catppuccin' | 'solarized'} ThemeId */ +/** @typedef {'light' | 'parchment' | 'ice' | 'github-light' | 'dark' | 'graphite' | 'midnight' | 'vitesse' | 'slate' | 'forest' | 'mono' | 'rose-pine' | 'catppuccin' | 'solarized'} ThemeId */ /** @typedef {{ id: ThemeId, name: string, description: string, isDark: boolean, group: ThemeGroup, preview: { bg: string, fg: string, accent: string } }} ThemeDefinition */ @@ -59,6 +59,14 @@ export const APP_THEMES = [ group: 'dark', preview: { bg: '#1c1c1c', fg: '#f0f0f0', accent: '#f0f0f0' }, }, + { + id: 'graphite', + name: 'Graphite', + description: 'Cool graphite, blue accent', + isDark: true, + group: 'dark', + preview: { bg: '#1e1f22', fg: '#dfe1e5', accent: '#3574f0' }, + }, { id: 'midnight', name: 'Midnight', @@ -198,6 +206,14 @@ export function mermaidThemeFor(id) { accent: '#f0f0f0', border: '#333333', }, + graphite: { + bg: '#1e1f22', + fg: '#dfe1e5', + muted: '#9da1a8', + line: '#37393e', + accent: '#3574f0', + border: '#37393e', + }, midnight: { bg: '#0f1520', fg: '#dde8ff', From 72ab5deac50c8b3508f52268aef23f9f1aa0d412 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Fri, 3 Jul 2026 09:13:24 +0545 Subject: [PATCH 5/5] feat::working on optimization and all fixes --- .github/workflows/auto-release.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index c4e6997f..f0e7b7a3 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -30,9 +30,12 @@ jobs: - uses: actions/checkout@v5 with: fetch-depth: 0 - # If master has branch protection that blocks the default bot token, - # replace with: token: ${{ secrets.RELEASE_TOKEN }} - token: ${{ secrets.GITHUB_TOKEN }} + # Push the version-bump commit + tag with the RELEASES_TOKEN PAT, not + # the default GITHUB_TOKEN: tags pushed by GITHUB_TOKEN never trigger + # other workflows (GitHub Actions rule), which is why release.yml had + # to be dispatched manually after every auto-release. With the PAT, + # the tag push starts the Release build automatically. + token: ${{ secrets.RELEASES_TOKEN }} - uses: actions/setup-node@v5 with: