diff --git a/.github/assets/preview.png b/.github/assets/preview.png new file mode 100644 index 00000000..6a1670b1 Binary files /dev/null and b/.github/assets/preview.png differ 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: 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/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 3034fded..b98356f2 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 ─────────────────────────────────────────────── @@ -306,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 @@ -336,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/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-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 bfb04075..a310275f 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -8,6 +8,9 @@ import Eye from "@lucide/svelte/icons/eye"; import Layers from "@lucide/svelte/icons/layers"; import ChevronDown from "@lucide/svelte/icons/chevron-down"; + import ChevronRight from "@lucide/svelte/icons/chevron-right"; + import Loader2 from "@lucide/svelte/icons/loader-2"; + import Columns3 from "@lucide/svelte/icons/columns-3"; import ListFilter from "@lucide/svelte/icons/list-filter"; import Lock from "@lucide/svelte/icons/lock"; import Square from "@lucide/svelte/icons/square"; @@ -17,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"; @@ -42,6 +44,7 @@ import ResizeHandle from "./ResizeHandle.svelte"; import { cn } from "$lib/utils.js"; import { formatTableRowCount } from "$lib/table-list.js"; + import { getTableColumnStructure } from "$lib/api.js"; import { clampNavSidebarWidth, loadLayout, @@ -82,12 +85,83 @@ 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} */ (() => {}), + /** Open the table with its structure view active. */ + onviewstructure = /** @type {(table: string) => void} */ (() => {}), } = $props(); + // ── Inline column expansion (chevron on table rows) ───────────────────────── + /** @type {Set} table names expanded in the current schema */ + let expandedTables = $state(new Set()); + /** @type {Map | 'loading' | 'error'>} keyed by "schema.table" */ + let expandedColumnsCache = $state(new Map()); + + /** @param {string} tableName */ + async function toggleExpand(tableName) { + const next = new Set(expandedTables); + if (next.has(tableName)) { + next.delete(tableName); + expandedTables = next; + return; + } + next.add(tableName); + expandedTables = next; + const key = `${activeSchema}.${tableName}`; + if (expandedColumnsCache.has(key) && expandedColumnsCache.get(key) !== 'error') return; + expandedColumnsCache = new Map(expandedColumnsCache).set(key, 'loading'); + try { + const cols = await getTableColumnStructure(activeSchema, tableName); + expandedColumnsCache = new Map(expandedColumnsCache).set( + key, + cols.map((c) => ({ name: c.name, dataType: c.dataType ?? '' })), + ); + } catch { + expandedColumnsCache = new Map(expandedColumnsCache).set(key, 'error'); + } + } + + // Collapse everything when the schema changes — names refer to other tables now + $effect(() => { + 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. @@ -440,17 +514,78 @@ 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 = @@ -926,6 +1061,10 @@ Unpin table + onviewstructure(tableName)}> + + View data structure + onviewddl(tableName)}> View DDL @@ -938,11 +1077,6 @@ Export data - - ongeneratetestdata(tableName)}> - - Generate test data - {/if} @@ -1011,13 +1145,14 @@ {#if virtTopPad > 0}{/if} {#each filteredRegularTables.slice(virtStart, virtEnd) as table (table.name)} {@const isSelected = selectedItems.has(table.name)} + {@const isExpanded = expandedTables.has(table.name)}
  • {/each} {#if virtBotPad > 0}{/if} diff --git a/src/lib/components/SqlConsole.svelte b/src/lib/components/SqlConsole.svelte index 68720af2..05b1bbac 100644 --- a/src/lib/components/SqlConsole.svelte +++ b/src/lib/components/SqlConsole.svelte @@ -1,20 +1,18 @@ +{#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 activeResult.loading} - + {#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} - +
    - - - - - - - - - - -
    - -
    - - -
    - {#each sqlTabs as tab (tab.id)} - {@const isActive = tab.id === activeTabId} -
    - + {/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.')} + + + + + - {tab.name} - - {#if sqlTabs.length > 1} - - {/if} -
    - {/each} - + {#if ormCopied} + + {:else} + + {/if} + + + + copyAsOrm('drizzle')}> + + Copy as Drizzle + + copyAsOrm('prisma')}> + + Copy as Prisma + + + +
    handleRun(undefined)} + onrunstatement={(stmt) => handleRun(stmt)} onmods={openSaveDialog} {onmodi} {onmodb} @@ -714,12 +612,6 @@ {onmodshifto} onmodj={toggleOutput} onmodshiftb={() => { queryHistoryVisible = !queryHistoryVisible; onmodshiftb?.() }} - onchange={(content) => { - if (content !== _lastSyncedSql) { - _lastSyncedSql = content - sql = content - } - }} onactionsready={(actions) => { formatSql = actions.format; }} @@ -743,8 +635,8 @@ /> {/if} - {#if activeResult.error} - {#if isNetworkError(activeResult.error)} + {#if error} + {#if isNetworkError(error)}
    @@ -755,7 +647,7 @@
    error -
    {activeResult.error}
    +
    {error}
    {#if onfixwithai} + + + {#snippet child({ props })} + + {/snippet} + + + {@render hint(outputVisible ? 'Hide results' : 'Show results', 'Collapse the results panel to give the editor the full height.', [mod, 'J'])} + +
    {#if outputVisible}
    - {#key `${activeTabId}:${activeResult.activeResultIdx ?? 0}`} - {#if currentDisplay.outputView === 'explain'} - {#if activeResult.explainLoading} + {#key `${outputView}:${Math.min(activeResultIdx, Math.max(resultSets.length - 1, 0))}`} + {#if outputView === 'explain'} + {#if explainLoading} - {:else if activeResult.explainError} + {:else if explainError}
    -

    {activeResult.explainError}

    +

    {explainError}

    - {:else if activeResult.explainResult} - + {:else if explainResult} + {:else}
    @@ -891,20 +792,28 @@
    {/if} {:else if currentDisplay.columns.length > 0} - {#if currentDisplay.outputView === 'json'} - setOutputView('table')} /> - {:else if currentDisplay.outputView === 'chart'} + {#if outputView === 'json'} + (outputView = 'table')} /> + {:else if outputView === 'chart'} setChartType(t)} + initialChartType={chartType} + oncharttypechange={(t) => (chartType = t)} /> {:else} - + {/if} - {:else if activeResult.loading} + {:else if loading} {:else}
    @@ -918,6 +827,7 @@
    + diff --git a/src/lib/components/SqlEditor.svelte b/src/lib/components/SqlEditor.svelte index 36433cc6..d4f52570 100644 --- a/src/lib/components/SqlEditor.svelte +++ b/src/lib/components/SqlEditor.svelte @@ -3,13 +3,14 @@ import * as monaco from 'monaco-editor' import { configureMonacoWorkers, editorFontFamily } from '$lib/monaco-env.js' import { registerMonacoSqlFormatter } from '$lib/format-sql.js' - import { registerMonacoSqlCompletion } from '$lib/monaco-sql-complete.js' + import { registerMonacoSqlCompletion, setSqlHintsForModel } from '$lib/monaco-sql-complete.js' import { defineStrokeMonacoThemes, monacoThemeId, readEditorFontOptions, } from '$lib/monaco-themes.js' import { normalizeThemeId } from '$lib/themes/registry.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 */ @@ -21,7 +22,12 @@ schemaHints = /** @type {SqlSchemaHints} */ ({}), onmodk = undefined, onmodenter = undefined, - onmodr = undefined, + /** + * Run a single statement (Ctrl/Cmd+R) — receives the selected text, or the + * statement under the cursor when there is no selection. + * @type {((sql: string) => void) | undefined} + */ + onrunstatement = undefined, onmods = undefined, // Global app shortcuts — registered inside Monaco so they work when editor is focused onmodi = undefined, @@ -44,16 +50,54 @@ 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() { return normalizeThemeId(document.documentElement.dataset.theme) } + /** + * Statement under the cursor (or containing the selection anchor). + * @param {monaco.editor.IStandaloneCodeEditor} ed + */ + function statementAtCursor(ed) { + const model = ed.getModel() + const pos = ed.getPosition() + if (!model || !pos) return null + return statementAtOffset(splitSqlStatements(model.getValue()), model.getOffsetAt(pos)) + } + /** @param {monaco.editor.IStandaloneCodeEditor} ed */ function registerAppShortcuts(ed) { const { CtrlCmd, Shift } = monaco.KeyMod - const { KeyK, KeyR, KeyS, KeyI, KeyB, KeyW, KeyN, KeyM, KeyT, KeyD, KeyO, KeyE, KeyJ, Enter } = monaco.KeyCode + const { KeyK, KeyL, KeyR, KeyS, KeyI, KeyB, KeyW, KeyN, KeyM, KeyT, KeyD, KeyO, KeyE, KeyJ, Enter } = monaco.KeyCode /** @param {() => void | undefined} fn */ const run = (fn) => fn?.() @@ -77,9 +121,39 @@ // Editor-local shortcuts ed.addCommand(CtrlCmd | KeyK, () => run(onmodk)) - ed.addCommand(CtrlCmd | KeyR, () => run(onmodr)) ed.addCommand(CtrlCmd | KeyS, () => run(onmods)) + // Ctrl/Cmd+L — select the statement under the cursor + ed.addCommand(CtrlCmd | KeyL, () => { + const stmt = statementAtCursor(ed) + const model = ed.getModel() + if (!stmt || !model) return + const s = model.getPositionAt(stmt.start) + const e = model.getPositionAt(stmt.end) + ed.setSelection(new monaco.Selection(s.lineNumber, s.column, e.lineNumber, e.column)) + ed.revealRangeInCenterIfOutsideViewport( + new monaco.Range(s.lineNumber, s.column, e.lineNumber, e.column), + ) + }) + + // Ctrl/Cmd+R — run the selection if any, else the statement under the cursor + ed.addCommand(CtrlCmd | KeyR, () => { + if (!onrunstatement) return + const model = ed.getModel() + const sel = ed.getSelection() + const selText = model && sel && !sel.isEmpty() ? model.getValueInRange(sel).trim() : '' + const stmt = selText || statementAtCursor(ed)?.text || '' + if (stmt) onrunstatement(stmt) + }) + ed.addAction({ + id: 'stroke.run-statement', + label: 'Run Statement at Cursor', + run: () => { + const stmt = statementAtCursor(ed)?.text + if (stmt && onrunstatement) onrunstatement(stmt) + }, + }) + // Global app shortcuts — work even when Monaco has focus ed.addCommand(CtrlCmd | KeyI, () => run(onmodi)) ed.addCommand(CtrlCmd | KeyB, () => run(onmodb)) @@ -134,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, @@ -146,12 +221,14 @@ cursorSmoothCaretAnimation: 'on', smoothScrolling: true, fixedOverflowWidgets: true, - quickSuggestions: { other: true, comments: false, strings: true }, - quickSuggestionsDelay: 50, + quickSuggestions: { other: true, comments: false, strings: false }, + quickSuggestionsDelay: 0, suggestOnTriggerCharacters: true, tabCompletion: 'on', wordBasedSuggestions: 'off', - acceptSuggestionOnEnter: 'on', + // 'smart' — Enter inserts a newline unless the suggestion actually + // changes the typed text; 'on' stole Enter constantly while writing SQL. + acceptSuggestionOnEnter: 'smart', snippetSuggestions: 'none', renderWhitespace: 'none', bracketPairColorization: { enabled: true }, @@ -162,16 +239,82 @@ showFunctions: true, showSnippets: true, filterGraceful: true, + // Match anywhere in the identifier: "email" finds "user_email" + matchOnWordStartOnly: false, insertMode: 'insert', showStatusBar: false, preview: false, }, - suggestSelection: 'recentlyUsedByPrefix', + suggestSelection: 'first', parameterHints: { enabled: true, cycle: true }, }) + // Bind this model to this component's (live) schema hints — the completion + // provider is global, so hints must be looked up per model, not captured + // from whichever editor happened to register first. + setSqlHintsForModel(editor.getModel(), () => schemaHints) + registerAppShortcuts(editor) + // Subtle gutter bar marking the statement the cursor is in — only shown + // when the buffer holds more than one statement, so single queries stay clean. + const stmtDecorations = editor.createDecorationsCollection() + function refreshActiveStatement() { + const model = editor?.getModel() + const pos = editor?.getPosition() + if (!model || !pos) return + const stmts = splitSqlStatements(model.getValue()) + const stmt = stmts.length > 1 ? statementAtOffset(stmts, model.getOffsetAt(pos)) : null + if (!stmt) { + stmtDecorations.clear() + return + } + const s = model.getPositionAt(stmt.start) + const e = model.getPositionAt(stmt.end) + stmtDecorations.set([ + { + range: new monaco.Range(s.lineNumber, 1, e.lineNumber, 1), + options: { isWholeLine: true, linesDecorationsClassName: 'stmt-active-gutter' }, + }, + ]) + } + 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()) @@ -218,6 +361,11 @@ value = next 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 @@ -231,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() } }) @@ -277,6 +429,47 @@ outline: none !important; } + /* Active-statement marker in the line-decorations gutter strip */ + .sql-editor-host :global(.stmt-active-gutter) { + width: 2px !important; + margin-left: 1px; + border-radius: 1px; + 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/StatusBar.svelte b/src/lib/components/StatusBar.svelte index d7674b11..47afaaba 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -515,7 +515,7 @@ {#if pendingEditCount > 0}
    @@ -3348,7 +3436,10 @@ let rowSearch = $state('') /> -{#if autoConnecting} +{#if autoConnecting && !connection} +
    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)} 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) @@ -3574,6 +3665,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) }} @@ -3651,6 +3743,17 @@ let rowSearch = $state('')
    {/if} + + {#if activeTab?.kind === 'license'} +
    + + {#await import('./LicensePage.svelte')}{:then { default: LicensePage }} + + {/await} + +
    + {/if} + {#if jsonEverOpened}
    { commandOpen = true }} - onmodenter={() => runSql()} onmods={() => saveActiveTabState()} onmodi={() => { if (connection) toggleAiSidebar() }} onmodb={() => { sidebarOpen = !sidebarOpen }} diff --git a/src/lib/components/TabBar.svelte b/src/lib/components/TabBar.svelte index cb7702d8..279a9b35 100644 --- a/src/lib/components/TabBar.svelte +++ b/src/lib/components/TabBar.svelte @@ -11,8 +11,11 @@ import Layers from '@lucide/svelte/icons/layers' import Search from '@lucide/svelte/icons/search' import Blocks from '@lucide/svelte/icons/blocks' + import KeyRound from '@lucide/svelte/icons/key-round' 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' @@ -29,6 +32,11 @@ /** @param {MouseEvent} e */ function handleDragAreaDblClick(e) { if (!isTauri) return + // Direct double-clicks on the header are already handled by Tauri's + // injected drag-region script (data-tauri-drag-region) — toggling here + // too made the window maximize and instantly restore. This handler only + // covers double-clicks on non-interactive children the built-in ignores. + if (e.target === e.currentTarget) return if (/** @type {Element} */ (e.target).closest('button')) return import('@tauri-apps/api/window').then(({ getCurrentWindow }) => getCurrentWindow().toggleMaximize()).catch(() => {}) } @@ -42,6 +50,7 @@ onclose = () => {}, oncloseothers = /** @param {string} _id */ (_id) => {}, oncloseall = () => {}, + onpintoggle = /** @param {string} _id */ (_id) => {}, onnew = () => {}, /** @type {import('$lib/stores/recent-tabs.js').RecentTab[]} */ recentTabs = [], @@ -74,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 } @@ -125,22 +135,39 @@ {tabDisplayTitle(tab)} - - + {#if tab.pinned} + + + {:else} + + + {/if} {#if !active && !nextActive} @@ -151,6 +178,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/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/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 6394691e..0447a675 100644 --- a/src/lib/monaco-sql-complete.js +++ b/src/lib/monaco-sql-complete.js @@ -1,3 +1,5 @@ +import { splitSqlStatements, statementAtOffset } from './sql-statements.js' + /** @typedef {{ * schemas?: string[] * activeSchema?: string @@ -268,16 +270,166 @@ function quoteIdent(name) { return /^[a-z_][a-z0-9_$]*$/i.test(name) ? name : `"${name.replace(/"/g, '""')}"` } +// ── Per-model schema hints ──────────────────────────────────────────────────── +// The completion provider is registered once globally, but multiple editors +// (SQL console, notebook cells, …) exist with different hints. Each editor +// 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} */ +let fallbackGetHints = null + /** - * Sort key that places exact matches before prefix matches within the same tier. - * @param {string} tier - e.g. '0_', '1_' - * @param {string} partial - current partial text (lowercase) - * @param {string} name - candidate name (any case) + * @param {import('monaco-editor').editor.ITextModel | null} model + * @param {() => SqlSchemaHints} getHints */ -function sortKey(tier, partial, name) { - const lower = name.toLowerCase() - const score = partial && lower === partial ? '0' : '1' - return `${tier}${score}_${lower}` +export function setSqlHintsForModel(model, getHints) { + if (model) hintsByModel.set(model, getHints) +} + +// ── Cached suggestion templates ─────────────────────────────────────────────── +// Suggestion objects are expensive to rebuild on every request (regex per item, +// doc markdown, …). Templates carry everything except `range`/`sortText`, +// which get stamped per request. Static templates build once; hint-derived +// templates rebuild only when the hints object identity changes ($derived in +// the shell keeps it stable between schema loads). + +/** @typedef {{ tpl: object, lc: string }} SuggestTemplate */ + +let staticCache = null + +/** @param {typeof import('monaco-editor')} monaco */ +function getStaticTemplates(monaco) { + if (staticCache) return staticCache + const Kind = monaco.languages.CompletionItemKind + const InsertAsSnippet = monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet + staticCache = { + /** @type {SuggestTemplate[]} */ + keywords: PG_KEYWORDS.map((kw) => ({ + tpl: { label: kw, kind: Kind.Keyword, insertText: kw, detail: 'keyword' }, + lc: kw.toLowerCase(), + })), + /** @type {SuggestTemplate[]} */ + functions: PG_FUNCTIONS.map((fn) => ({ + tpl: { + label: fn.label, + kind: Kind.Function, + insertText: fn.sig, + insertTextRules: InsertAsSnippet, + detail: fn.sig.replace(/\$\{\d+:?([^}]*)\}/g, '$1').replace(/\$\d+/g, ''), + documentation: { value: fn.doc }, + }, + lc: fn.label.toLowerCase(), + })), + /** @type {SuggestTemplate[]} */ + snippets: SQL_SNIPPETS.map((snip) => ({ + tpl: { + label: snip.label, + kind: Kind.Snippet, + insertText: snip.body, + insertTextRules: InsertAsSnippet, + detail: snip.detail, + documentation: { value: `\`\`\`sql\n${snip.body.replace(/\$\{\d+:?([^}]*)\}/g, '$1')}\n\`\`\`` }, + }, + lc: snip.label.toLowerCase(), + })), + } + return staticCache +} + +/** @type {SqlSchemaHints | null} */ +let hintsCacheSrc = null +let hintsCache = null + +/** + * @param {typeof import('monaco-editor')} monaco + * @param {SqlSchemaHints} hints + */ +function getHintTemplates(monaco, hints) { + if (hintsCacheSrc === hints && hintsCache) return hintsCache + const Kind = monaco.languages.CompletionItemKind + const InsertAsSnippet = monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet + const activeSchema = hints.activeSchema ?? 'public' + + /** @type {SuggestTemplate[]} */ + const schemas = (hints.schemas ?? []).map((schema) => ({ + tpl: { + label: schema, + kind: Kind.Module, + insertText: `${quoteIdent(schema)}.`, + detail: 'schema', + // Re-open the widget so schema. immediately offers its tables + command: { id: 'editor.action.triggerSuggest', title: 'Suggest' }, + }, + lc: schema.toLowerCase(), + })) + + /** @type {SuggestTemplate[]} */ + const tables = (hints.tables ?? []).map((table) => ({ + tpl: { label: table, kind: Kind.Class, insertText: quoteIdent(table), detail: `table · ${activeSchema}` }, + lc: table.toLowerCase(), + })) + + // Columns grouped (and deduped) by short table name — schema-qualified keys + // like "public.users" collapse into "users". + /** @type {Map} */ + const colsByTbl = new Map() + { + /** @type {Map>} */ + const seen = new Map() + for (const [tbl, cols] of Object.entries(hints.columnsByTable ?? {})) { + const tblShort = (tbl.includes('.') ? tbl.split('.').pop() ?? tbl : tbl).toLowerCase() + let bucket = colsByTbl.get(tblShort) + let seenSet = seen.get(tblShort) + if (!bucket) { + bucket = [] + seenSet = new Set() + colsByTbl.set(tblShort, bucket) + seen.set(tblShort, seenSet) + } + for (const col of cols ?? []) { + if (!col || seenSet?.has(col)) continue + seenSet?.add(col) + bucket.push({ + tpl: { label: col, kind: Kind.Field, insertText: quoteIdent(col), detail: `column · ${tblShort}` }, + lc: col.toLowerCase(), + }) + } + } + } + + /** @type {SuggestTemplate[]} */ + const enums = [] + for (const [enumName, values] of Object.entries(hints.enumValues ?? {})) { + for (const val of values ?? []) { + enums.push({ + tpl: { label: val, kind: Kind.Value, insertText: `'${val}'`, detail: `enum · ${enumName}` }, + lc: val.toLowerCase(), + }) + } + } + + /** @type {SuggestTemplate[]} */ + const userFns = (hints.userFunctions ?? []).map((ufn) => ({ + tpl: { + label: ufn.name, + kind: ufn.kind === 'aggregate' ? Kind.Operator : Kind.Function, + insertText: `${ufn.name}($0)`, + insertTextRules: InsertAsSnippet, + detail: `→ ${ufn.returnType} [${ufn.kind}]`, + documentation: { value: `\`\`\`sql\n${ufn.signature}\`\`\`` }, + }, + lc: ufn.name.toLowerCase(), + })) + + hintsCache = { activeSchema, schemas, tables, colsByTbl, enums, userFns } + hintsCacheSrc = hints + return hintsCache } // ── Registration ────────────────────────────────────────────────────────────── @@ -287,6 +439,7 @@ function sortKey(tier, partial, name) { * @param {() => SqlSchemaHints} getHints */ export function registerMonacoSqlCompletion(monaco, getHints) { + if (getHints) fallbackGetHints = getHints if (registerMonacoSqlCompletion.done) return registerMonacoSqlCompletion.done = true @@ -307,15 +460,14 @@ export function registerMonacoSqlCompletion(monaco, getHints) { }) monaco.languages.registerCompletionItemProvider('sql', { - triggerCharacters: ['.', ' ', '\n'], + // Only '.' — quickSuggestions covers word typing. Space/newline triggers + // used to rerun the whole provider on every space, which felt slow and + // popped the widget when it wasn't wanted. + triggerCharacters: ['.'], provideCompletionItems(model, position) { - const hints = getHints() - const schemas = hints.schemas ?? [] - const activeSchema = hints.activeSchema ?? 'public' - const tables = hints.tables ?? [] - const columnsByTable = hints.columnsByTable ?? {} - const enumValues = hints.enumValues ?? {} - const userFunctions = hints.userFunctions ?? [] + const hints = (hintsByModel.get(model) ?? fallbackGetHints)?.() ?? EMPTY_HINTS + const S = getStaticTemplates(monaco) + const H = getHintTemplates(monaco, hints) const word = model.getWordUntilPosition(position) const range = new monaco.Range( @@ -328,18 +480,23 @@ export function registerMonacoSqlCompletion(monaco, getHints) { endLineNumber: position.lineNumber, endColumn: position.column, }) - const docBeforeCursor = model.getValueInRange({ - startLineNumber: 1, startColumn: 1, - endLineNumber: position.lineNumber, endColumn: position.column, - }) - - const partial = word.word.toLowerCase() - const { kind: ctx, referencedTables, aliasMap } = analyzeQuery(docBeforeCursor, tables) + // Analyze only the statement under the cursor — a FROM in an earlier + // statement must not leak table/column context into this one. + const fullText = model.getValue() + const offset = model.getOffsetAt(position) + const stmt = statementAtOffset(splitSqlStatements(fullText), offset) + const stmtBeforeCursor = stmt && offset > stmt.start ? fullText.slice(stmt.start, offset) : '' + const { kind: ctx, referencedTables, aliasMap } = analyzeQuery(stmtBeforeCursor, hints.tables ?? []) /** @type {import('monaco-editor').languages.CompletionItem[]} */ const suggestions = [] - const Kind = monaco.languages.CompletionItemKind - const InsertAsSnippet = monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet + // Stamp a template with this request's range + tier. No prefix filtering + // here: Monaco requests once per word session and fuzzy-filters the full + // list itself — pre-filtering broke matching after backspace/mid-word. + /** @param {SuggestTemplate} entry @param {string} tier */ + const push = (entry, tier) => { + suggestions.push({ ...entry.tpl, range, sortText: tier + entry.lc }) + } // ── 1. Dot completion: schema.table or alias/table.column ────────── const dotMatch = linePrefix.match(/(?:["']([^"']+)["']|([\w$]+))\.["']?([\w$]*)$/) @@ -348,224 +505,102 @@ export function registerMonacoSqlCompletion(monaco, getHints) { const leftLower = left.toLowerCase() // Schema dot → tables - if (schemas.some((s) => s.toLowerCase() === leftLower)) { - for (const table of tables) { - if (partial && !table.toLowerCase().startsWith(partial)) continue - suggestions.push({ - label: table, - kind: Kind.Class, - insertText: quoteIdent(table), - range, - detail: `table in ${left}`, - sortText: sortKey('0_', partial, table), - }) - } + if (H.schemas.some((s) => s.lc === leftLower)) { + for (const t of H.tables) push(t, '0_') return { suggestions } } // Alias or table name dot → columns - // Resolve alias first, then fall back to direct table name match const resolvedTable = aliasMap[leftLower] ?? leftLower - const colKeys = [ - resolvedTable, - `${activeSchema}.${resolvedTable}`, - left, - `${activeSchema}.${left}`, - ] - const cols = new Set() - for (const key of colKeys) { - for (const c of columnsByTable[key] ?? []) cols.add(c) - } - if (cols.size > 0) { - for (const col of cols) { - if (partial && !col.toLowerCase().startsWith(partial)) continue - suggestions.push({ - label: col, - kind: Kind.Field, - insertText: quoteIdent(col), - range, - detail: `column · ${resolvedTable}`, - sortText: sortKey('0_', partial, col), - }) - } + const bucket = H.colsByTbl.get(resolvedTable) ?? H.colsByTbl.get(leftLower) + if (bucket && bucket.length > 0) { + for (const c of bucket) push(c, '0_') return { suggestions } } } + // ── 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_' - // ── 2. Schemas ───────────────────────────────────────────────────── - for (const schema of schemas) { - if (partial && !schema.toLowerCase().startsWith(partial)) continue + if (wantsFrom) { suggestions.push({ - label: schema, - kind: Kind.Module, - insertText: `${quoteIdent(schema)}.`, + label: 'FROM', + kind: monaco.languages.CompletionItemKind.Keyword, + insertText: 'FROM ', + detail: 'keyword', range, - detail: 'schema', - sortText: sortKey(tierSchema, partial, schema), + sortText: '00_from', + // Re-open the widget so FROM immediately offers tables + command: { id: 'editor.action.triggerSuggest', title: 'Suggest' }, }) } - // ── 3. Tables ────────────────────────────────────────────────────── - for (const table of tables) { - if (partial && !table.toLowerCase().startsWith(partial)) continue - suggestions.push({ - label: table, - kind: Kind.Class, - insertText: quoteIdent(table), - range, - detail: `table · ${activeSchema}`, - sortText: sortKey(tierTable, partial, table), - }) - } + // ── 2. Schemas + tables ──────────────────────────────────────────── + for (const s of H.schemas) push(s, tierSchema) + for (const t of H.tables) push(t, tierTable) - // ── 4. Columns — referenced tables first, then all ───────────────── + // ── 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 seenCols = new Set() - - // First pass: columns from tables already in the query + const seenColNames = new Set() for (const refTable of referencedTables) { - const colKeys = [refTable, `${activeSchema}.${refTable}`] - for (const key of colKeys) { - for (const col of columnsByTable[key] ?? []) { - if (!col) continue - const ck = `${refTable}.${col}` - if (seenCols.has(ck)) continue - seenCols.add(ck) - if (partial && !col.toLowerCase().startsWith(partial)) continue - suggestions.push({ - label: col, - kind: Kind.Field, - insertText: quoteIdent(col), - range, - detail: `column · ${refTable}`, - sortText: sortKey(tierColRef, partial, col), - }) - } + for (const c of H.colsByTbl.get(refTable) ?? []) { + push(c, tierColRef) + seenColNames.add(c.lc) } } - - // Second pass: columns from all other tables - for (const [tbl, cols] of Object.entries(columnsByTable)) { - const tblShort = tbl.includes('.') ? tbl.split('.').pop() ?? tbl : tbl + for (const [tblShort, cols] of H.colsByTbl) { if (refTableSet.has(tblShort)) continue - for (const col of cols) { - if (!col) continue - const ck = `${tblShort}.${col}` - if (seenCols.has(ck)) continue - seenCols.add(ck) - if (partial && !col.toLowerCase().startsWith(partial)) continue - suggestions.push({ - label: col, - kind: Kind.Field, - insertText: quoteIdent(col), - range, - detail: `column · ${tblShort}`, - sortText: sortKey(tierColAll, partial, col), - }) - } - } - - // ── 5. Functions ─────────────────────────────────────────────────── - // In table context, skip functions entirely (they don't belong after FROM) - if (ctx !== 'table') { - for (const fn of PG_FUNCTIONS) { - if (partial && !fn.label.toLowerCase().startsWith(partial)) continue - suggestions.push({ - label: fn.label, - kind: Kind.Function, - insertText: fn.sig, - insertTextRules: InsertAsSnippet, - range, - detail: fn.sig.replace(/\$\{\d+:?([^}]*)\}/g, '$1').replace(/\$\d+/g, ''), - documentation: { value: fn.doc }, - sortText: sortKey(tierFn, partial, fn.label), - }) - } - // ── 5b. User-defined functions / procedures / aggregates ────── - for (const ufn of userFunctions) { - if (partial && !ufn.name.toLowerCase().startsWith(partial)) continue - suggestions.push({ - label: ufn.name, - kind: ufn.kind === 'aggregate' ? Kind.Operator : Kind.Function, - insertText: `${ufn.name}($0)`, - insertTextRules: InsertAsSnippet, - range, - detail: `→ ${ufn.returnType} [${ufn.kind}]`, - documentation: { value: `\`\`\`sql\n${ufn.signature}\`\`\`` }, - sortText: sortKey(tierFn, partial, ufn.name), - }) + for (const c of cols) { + if (seenColNames.has(c.lc)) continue + seenColNames.add(c.lc) + push(c, tierColAll) } } - // ── 5c. Enum values ──────────────────────────────────────────────── - // Show enum values in column/value context or when partial starts inside quotes + // ── 4. Functions + enum values (not in table context) ────────────── if (ctx !== 'table') { - for (const [enumName, values] of Object.entries(enumValues)) { - for (const val of values) { - if (partial && !val.toLowerCase().startsWith(partial)) continue - suggestions.push({ - label: val, - kind: Kind.Value, - insertText: `'${val}'`, - range, - detail: `enum · ${enumName}`, - sortText: sortKey('6_', partial, val), - }) - } - } + for (const f of S.functions) push(f, tierFn) + for (const f of H.userFns) push(f, tierFn) + for (const e of H.enums) push(e, '6_') } - // ── 6. Keywords (context-filtered) ──────────────────────────────── - // When the user has typed a partial prefix always match ALL keywords so - // typing "sel" → SELECT works regardless of context. With no partial, - // filter to the contextually relevant subset to reduce noise. - const kwSet = partial + // ── 5. Keywords (context-filtered) ───────────────────────────────── + // When the user has typed a prefix, offer ALL keywords so typing "sel" + // → SELECT works regardless of context. With no prefix, filter to the + // contextually relevant subset to reduce noise. + const kwSet = word.word ? null : ctx === 'table' ? TABLE_CTX_KWS : ctx === 'column' ? COLUMN_CTX_KWS : null - for (const kw of PG_KEYWORDS) { - if (kwSet && !kwSet.has(kw)) continue - if (partial && !kw.toLowerCase().startsWith(partial)) continue - suggestions.push({ - label: kw, - kind: Kind.Keyword, - insertText: kw, - range, - detail: 'keyword', - sortText: sortKey(tierKw, partial, kw), - }) + for (const kw of S.keywords) { + if (kwSet && !kwSet.has(/** @type {string} */ (kw.tpl.label))) continue + push(kw, tierKw) } - // ── 7. Snippets ──────────────────────────────────────────────────── - // Show snippets only in 'any' context (statement start) or when partial matches - if (ctx === 'any' || partial) { - for (const snip of SQL_SNIPPETS) { - if (partial && !snip.label.toLowerCase().startsWith(partial)) continue - suggestions.push({ - label: snip.label, - kind: Kind.Snippet, - insertText: snip.body, - insertTextRules: InsertAsSnippet, - range, - detail: snip.detail, - documentation: { value: `\`\`\`sql\n${snip.body.replace(/\$\{\d+:?([^}]*)\}/g, '$1')}\n\`\`\`` }, - sortText: sortKey(tierSnip, partial, snip.label), - }) - } + // ── 6. Snippets ──────────────────────────────────────────────────── + // Statement start or while typing a word — not right after a trigger char + if (ctx === 'any' || word.word) { + for (const s of S.snippets) push(s, tierSnip) } return { suggestions } diff --git a/src/lib/sql-statements.js b/src/lib/sql-statements.js new file mode 100644 index 00000000..e7018ea7 --- /dev/null +++ b/src/lib/sql-statements.js @@ -0,0 +1,205 @@ +/** + * Lightweight SQL statement splitter used by the SQL editor for + * statement-at-cursor actions (select / run) and the active-statement gutter. + * + * Understands enough SQL lexing to not split inside: + * - single/double-quoted strings and backtick identifiers ('' and \' escapes) + * - line comments (`-- …`) and block comments + * - Postgres dollar-quoted bodies ($$ … $$, $tag$ … $tag$) + * + * @typedef {{ text: string, start: number, end: number }} SqlStatement + * `start`/`end` are character offsets into the source text; `end` is + * exclusive and includes the terminating semicolon when present. + */ + +/** + * @param {string} text + * @returns {SqlStatement[]} + */ +export function splitSqlStatements(text) { + /** @type {SqlStatement[]} */ + const out = [] + const n = text.length + let i = 0 + let start = 0 + + /** @param {number} end exclusive boundary (just past the `;` or EOF) */ + function flush(end) { + let s = start + let e = end + while (s < e && /\s/.test(text[s])) s++ + while (e > s && /\s/.test(text[e - 1])) e-- + if (e > s) { + const t = text.slice(s, e) + // Skip fragments that are only comments/semicolons + const meaningful = t.replace(/--[^\n]*|\/\*[\s\S]*?\*\//g, '').replace(/;/g, '').trim() + if (meaningful) out.push({ text: t, start: s, end: e }) + } + start = end + } + + 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) + i = close === -1 ? n : close + 2 + } else if (ch === "'" || ch === '"' || ch === '`') { + i++ + while (i < n) { + if (ch === "'" && text[i] === '\\') { i += 2; continue } + if (text[i] === ch) { + // '' inside a single-quoted string is an escaped quote, not the end + if (ch === "'" && text[i + 1] === "'") { i += 2; continue } + i++ + break + } + i++ + } + } 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) + i = close === -1 ? n : close + tag.length + } else { + i++ + } + } else if (ch === ';') { + i++ + flush(i) + } else { + i++ + } + } + flush(n) + 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 + * the first statement, return the first. + * + * @param {SqlStatement[]} statements + * @param {number} offset + * @returns {SqlStatement | null} + */ +export function statementAtOffset(statements, offset) { + if (statements.length === 0) return null + let prev = null + for (const s of statements) { + if (offset >= s.start && offset <= s.end) return s + if (s.end < offset) prev = s + else if (s.start > offset) return prev ?? s + } + return prev +} diff --git a/src/lib/studio-tabs.js b/src/lib/studio-tabs.js index f6c8707d..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 */ @@ -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 @@ -338,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' @@ -363,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',