diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bb5767e..a70decb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ All notable changes to Stroke are listed here, newest first. --- +## [Unreleased] + +### Changes + +#### Connect experience (redesigned) +- Rebuilt the connection screen as a **two-pane layout** — choose your connection type, driver, and saved connections in a left rail while editing details in a focused right pane, so the fields are reachable without scrolling. +- **Pick one input method** — a segmented switch toggles between pasting a **connection string** and filling **manual fields**, instead of showing both at once. Pasting a string auto-fills the fields and validates on connect. +- **Cleaner provider picker** — Neon, Supabase, PlanetScale, and Prisma Postgres now appear as a single calm list with a clear selected state and a compact sign-in action, replacing the bulky cards. +- Redesigned the **connection error** as a readable inline alert and gave the footer a clear status chip (Ready / Unsaved / Connection OK / Failed) alongside unambiguous Test and Connect actions. +- Inputs now use a **crisp outline and a single accent focus ring**, consistent across the app. +- Widened the connections rail and stopped the connection screen from flickering when switching between saved connections or drivers. + +#### Interface polish +- Added **press feedback** to buttons and gave menus, selects, popovers, tooltips, and dialogs consistent, snappier open/close motion; menus now scale from the control that opened them. +- The app now respects the system **"reduce motion"** setting — movement is minimized while feedback is preserved. +- **Sidebar** table rows: names and row counts sit on a shared baseline, resting contrast is higher for easier scanning, and section headers are refined. +- Tightened **Settings** spacing and control feedback. + +### Bug Fixes +- Fixed **"Limit must be at least 1"** when opening a table after choosing "All" rows — the page size now falls back to a valid default (50) instead of erroring. +- The data grid no longer offers **Set NULL** on NOT NULL columns; it shows a clear message instead of attempting a write that would fail. +- Text on the connection screen is no longer accidentally selectable, and the cursor now shows a pointer on every clickable control. + + ## [1.7.0] - 2026-07-06 ### New Features diff --git a/package-lock.json b/package-lock.json index 608c6a0c..63b53fc9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "stroke", - "version": "1.5.0", + "version": "1.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "stroke", - "version": "1.5.0", + "version": "1.7.0", "dependencies": { "@dagrejs/dagre": "^3.0.0", "@fontsource-variable/geist": "^5.2.9", diff --git a/src-tauri/src/db/explain.rs b/src-tauri/src/db/explain.rs index 03adfd4b..9d6ad86e 100644 --- a/src-tauri/src/db/explain.rs +++ b/src-tauri/src/db/explain.rs @@ -37,16 +37,23 @@ fn strip_sql(sql: &str) -> &str { } pub async fn explain_pg(pool: &sqlx::PgPool, sql: &str) -> Result { - let sql = strip_sql(sql); - let q = format!("EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) {sql}"); - - let row: (serde_json::Value,) = sqlx::query_as(&q) - .fetch_one(pool) - .await - .map_err(|e| format!("EXPLAIN failed: {e}"))?; + let stripped = strip_sql(sql); + let q = format!("EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) {stripped}"); // PG returns [{Plan: {...}, "Planning Time": x, "Execution Time": y}] - let arr = row.0; + let arr = match sqlx::query_as::<_, (serde_json::Value,)>(&q).fetch_one(pool).await { + Ok(row) => row.0, + // CockroachDB speaks the PG wire protocol but rejects PG's EXPLAIN option + // syntax (`at or near "analyze"`). Fall back to its indented text plan; if + // that also fails, surface the original error — it's the meaningful one for + // a genuine query fault on real PostgreSQL. + Err(e) => { + return match explain_cockroach(pool, sql).await { + Ok(res) => Ok(res), + Err(_) => Err(format!("EXPLAIN failed: {e}")), + }; + } + }; let entry = arr .as_array() .and_then(|a| a.first()) @@ -65,6 +72,118 @@ pub async fn explain_pg(pool: &sqlx::PgPool, sql: &str) -> Result Result { + let sql = strip_sql(sql); + let q = format!("EXPLAIN {sql}"); + + let rows = sqlx::query(&q) + .fetch_all(pool) + .await + .map_err(|e| format!("EXPLAIN failed: {e}"))?; + + let lines: Vec = rows + .iter() + .filter_map(|r| r.try_get::(0).ok()) + .collect(); + + let plan = parse_cockroach_plan(&lines).ok_or_else(|| "Empty EXPLAIN result".to_string())?; + + Ok(ExplainResult { + plan, + planning_time: 0.0, + execution_time: 0.0, + driver: "cockroach".into(), + }) +} + +/// Turn Cockroach's `EXPLAIN` text (one plan line per row) into a nested plan +/// tree. Node lines carry a `•` bullet whose indentation encodes depth; the +/// `key: value` lines that follow describe the node just above them. +fn parse_cockroach_plan(lines: &[String]) -> Option { + use serde_json::{json, Map, Value}; + + struct Frame { + depth: usize, + node: Map, + children: Vec, + } + fn finalize(mut f: Frame) -> Value { + if !f.children.is_empty() { + f.node.insert("Plans".into(), Value::Array(f.children)); + } + Value::Object(f.node) + } + // Attach a finished frame to its parent (or the root list if there is none). + fn attach(stack: &mut Vec, roots: &mut Vec, done: Value) { + match stack.last_mut() { + Some(p) => p.children.push(done), + None => roots.push(done), + } + } + + let mut stack: Vec = Vec::new(); + let mut roots: Vec = Vec::new(); + + for line in lines { + if let Some((prefix, rest)) = line.split_once('•') { + let depth = prefix.chars().count(); + let name = rest.trim(); + if name.is_empty() { + continue; + } + while stack.last().is_some_and(|f| f.depth >= depth) { + let done = finalize(stack.pop().unwrap()); + attach(&mut stack, &mut roots, done); + } + let mut node = Map::new(); + node.insert("Node Type".into(), json!(name)); + node.insert("Total Cost".into(), json!(0.0)); + node.insert("Startup Cost".into(), json!(0.0)); + node.insert("Plan Rows".into(), json!(0)); + stack.push(Frame { depth, node, children: Vec::new() }); + } else if let Some((k, v)) = line.split_once(':') { + // A detail line for the current node (header lines like + // "distribution: full" arrive before any bullet and are ignored). + if let Some(f) = stack.last_mut() { + let key = k.trim_matches(|c: char| "│└├─ \t".contains(c)).to_ascii_lowercase(); + let val = v.trim(); + match key.as_str() { + "table" => { + f.node.insert("Relation Name".into(), json!(val.split('@').next().unwrap_or(val))); + } + "estimated row count" => { + let digits: String = + val.chars().take_while(|c| c.is_ascii_digit() || *c == ',').filter(|c| *c != ',').collect(); + if let Ok(n) = digits.parse::() { + f.node.insert("Plan Rows".into(), json!(n)); + } + } + _ => {} + } + } + } + } + while let Some(f) = stack.pop() { + let done = finalize(f); + attach(&mut stack, &mut roots, done); + } + + match roots.len() { + 0 => None, + 1 => roots.into_iter().next(), + _ => Some(json!({ + "Node Type": "Query Plan", + "Total Cost": 0.0, + "Startup Cost": 0.0, + "Plan Rows": 0, + "Plans": roots, + })), + } +} + pub async fn explain_mysql(pool: &sqlx::MySqlPool, sql: &str) -> Result { let sql = strip_sql(sql); let q = format!("EXPLAIN FORMAT=JSON {sql}"); diff --git a/src-tauri/src/db/mssql.rs b/src-tauri/src/db/mssql.rs index f0c3b332..cf55d5f2 100644 --- a/src-tauri/src/db/mssql.rs +++ b/src-tauri/src/db/mssql.rs @@ -491,9 +491,20 @@ pub async fn insert_table_row( let val_list = cols.iter().map(|c| sql_literal(&values[c])).collect::>().join(", "); let tq = format!("{}.{}", quote_ident(schema), quote_ident(table)); // OUTPUT INSERTED.* returns the full new row (including server-generated columns). + // This INSERT yields a result set, so it must go through simple_query — not + // execute_sql, whose DML branch uses execute() and discards returned rows + // (which left the grid showing an all-NULL row after insert). let sql = format!("INSERT INTO {tq} ({col_list}) OUTPUT INSERTED.* VALUES ({val_list})"); - let res = execute_sql(handle, &sql).await?; - Ok(res.rows.into_iter().next().unwrap_or_default()) + let mut client = handle.lock().await; + let results = client + .simple_query(&sql) + .await + .map_err(|e| format!("MS SQL error: {e}"))? + .into_results() + .await + .map_err(|e| format!("MS SQL error: {e}"))?; + let rows = results.into_iter().next().unwrap_or_default(); + Ok(rows_to_result(&rows, 0).rows.into_iter().next().unwrap_or_default()) } pub async fn delete_table_rows( diff --git a/src-tauri/src/db/mysql.rs b/src-tauri/src/db/mysql.rs index 218d19a9..0dc20236 100644 --- a/src-tauri/src/db/mysql.rs +++ b/src-tauri/src/db/mysql.rs @@ -237,10 +237,12 @@ pub async fn get_table_rows( }; let table_ref = format!("{}.{}", bt(schema), bt(table)); - let count_sql = format!("SELECT COUNT(*) FROM {table_ref}{}", where_clause.sql); - // MySQL returns COUNT(*) as BIGINT UNSIGNED (u64). Decoding as i64 causes a - // type-mismatch error; fetch as u64 then cast. - let mut count_q = sqlx::query_scalar::<_, u64>(&count_sql); + // MySQL types COUNT(*) as BIGINT UNSIGNED, but MariaDB types it as signed + // BIGINT — decoding the wrong signedness is a hard type-mismatch in sqlx. + // CAST(... AS SIGNED) normalizes both to i64, which comfortably holds any + // real row count. + let count_sql = format!("SELECT CAST(COUNT(*) AS SIGNED) FROM {table_ref}{}", where_clause.sql); + let mut count_q = sqlx::query_scalar::<_, i64>(&count_sql); for b in &where_clause.binds { count_q = count_q.bind(b.as_str()); } @@ -264,7 +266,7 @@ pub async fn get_table_rows( data_q.fetch_all(pool), meta_q.fetch_all(pool), ); - let total: i64 = total_res.map_err(|e| format!("Failed to count rows: {e}"))? as i64; + let total: i64 = total_res.map_err(|e| format!("Failed to count rows: {e}"))?; let rows = rows_res.map_err(|e| format!("Failed to fetch rows: {e}"))?; let meta_rows = meta_res.unwrap_or_default(); diff --git a/src-tauri/src/db/query.rs b/src-tauri/src/db/query.rs index 57652535..20d738db 100644 --- a/src-tauri/src/db/query.rs +++ b/src-tauri/src/db/query.rs @@ -1578,6 +1578,29 @@ fn bind_typed_value<'a>( let t = normalize_pg_type(data_type); if value.is_null() { + // Bind a NULL whose parameter type matches the column family. The base + // SET clause is `"col" = $1` with no cast, so a text-typed NULL against a + // non-text column (e.g. int4) fails assignment-cast checking with + // "column is of type integer but expression is of type text". + if t.contains("int") || t == "serial" || t.ends_with("serial") || t == "oid" { + return Ok(q.bind(None::)); + } + if t == "bool" || t == "boolean" { + return Ok(q.bind(None::)); + } + if t == "uuid" { + return Ok(q.bind(None::)); + } + if t.contains("numeric") + || t.contains("decimal") + || t.contains("real") + || t.contains("double") + || t.contains("float") + { + return Ok(q.bind(None::)); + } + // text/varchar/char, plus json/jsonb and datetime types whose SET clause + // already carries an explicit `$1::type` cast that absorbs a text NULL. return Ok(q.bind(None::)); } diff --git a/src/app.css b/src/app.css index 73e4afea..9553809c 100644 --- a/src/app.css +++ b/src/app.css @@ -21,6 +21,13 @@ "editor font" and everything else. JetBrains Mono is a true code typeface: heavier, even rhythm, designed ligatures. */ --editor-font-family: "JetBrains Mono Variable", "Geist Mono Variable", ui-monospace, monospace; + /* Motion tokens — stronger-than-default curves (Emil Kowalski). These override + Tailwind's built-in --ease-out / --ease-in-out so the `ease-out` / `ease-in-out` + utilities (and tw-animate-css enter/exit keyframes, which read --tw-ease) all + inherit the punchier curves. Only transform/opacity should ride these. */ + --ease-out: cubic-bezier(0.23, 1, 0.32, 1); + --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); + --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1); } /* JSON colorizer tokens (highlightJson) — dark-friendly defaults, light override @@ -100,6 +107,28 @@ html[data-os="linux"] { --window-radius: 8px; } @apply border-border outline-ring/50; } + /* Reduced motion — reduced, not zero (Emil / WCAG). Keep opacity + colour + fades that aid comprehension; strip movement. Neutralising tw-animate-css's + enter/exit transform vars makes overlays fade in place instead of + sliding/zooming, and clamping durations keeps any remaining transition brief. */ + @media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + --tw-enter-translate-x: 0 !important; + --tw-enter-translate-y: 0 !important; + --tw-exit-translate-x: 0 !important; + --tw-exit-translate-y: 0 !important; + --tw-enter-scale: 1 !important; + --tw-exit-scale: 1 !important; + --tw-enter-rotate: 0 !important; + --tw-exit-rotate: 0 !important; + animation-duration: 0.12s !important; + transition-duration: 0.12s !important; + scroll-behavior: auto !important; + } + } + /* Disable OS word suggestions, autocorrect, and spellcheck on all inputs. */ input:not([type="file"]), textarea { diff --git a/src/lib/components/ConnectionModal.svelte b/src/lib/components/ConnectionModal.svelte index b952c77f..3f07e673 100644 --- a/src/lib/components/ConnectionModal.svelte +++ b/src/lib/components/ConnectionModal.svelte @@ -23,9 +23,11 @@ import { Input } from '$lib/components/ui/input/index.js' import { Checkbox } from '$lib/components/ui/checkbox/index.js' import { ScrollArea } from '$lib/components/ui/scroll-area/index.js' + import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js' import { Dialog as DialogPrimitive } from 'bits-ui' import { cn } from '$lib/utils.js' import { parseConnectionUri } from '$lib/connection-uri.js' + import { PROVIDERS } from '$lib/providers.js' let { open = $bindable(false), @@ -93,6 +95,33 @@ .filter(Boolean) .map((d) => ({ value: d.id, label: d.label, keywords: [d.label, d.desc], disabled: !!d.soon })) + // Provider (sign-in) ids are surfaced as cards on their own tab, so keep them + // out of the manual Type dropdown. + const PROVIDER_IDS = ['neon', 'supabase', 'planetscale', 'prisma'] + const manualDriverItems = driverItems.filter((d) => !PROVIDER_IDS.includes(d.value)) + + // Subtle per-engine icon tint (color-500/600), theme-aware via Tailwind tokens. + const ENGINE_TINT = { + postgres: 'text-sky-500/80', + cockroachdb: 'text-teal-500/80', + mysql: 'text-amber-500/80', + mariadb: 'text-orange-500/80', + sqlite: 'text-blue-500/80', + 'sqlite-memory': 'text-blue-500/80', + mssql: 'text-red-500/80', + clickhouse: 'text-yellow-500/80', + duckdb: 'text-yellow-500/80', + 'duckdb-memory': 'text-yellow-500/80', + d1: 'text-orange-500/80', + libsql: 'text-emerald-500/80', + neon: 'text-emerald-500/80', + supabase: 'text-emerald-500/80', + planetscale: 'text-foreground/70', + prisma: 'text-indigo-500/80', + bigquery: 'text-blue-500/80', + } + function engineTint(id) { return ENGINE_TINT[id] ?? 'text-muted-foreground/60' } + let saved = $state(loadSavedConnections().sort((a, b) => (b.lastConnectedAt ?? 0) - (a.lastConnectedAt ?? 0))) let lastId = $state(getLastConnectionId()) let editingId = $state(/** @type {string|null} */ (null)) @@ -114,6 +143,10 @@ let dbType = $state('postgres') let driverMenuOpen = $state(false) + // Top-level entry mode: connect manually vs sign in with a hosting provider. + let entryMode = $state(/** @type {'manual'|'provider'} */ ('manual')) + // Advanced (SSL / SSH / read-only) disclosure — collapsed by default. + let advancedOpen = $state(false) let name = $state('') let host = $state('127.0.0.1') let port = $state('5432') @@ -132,6 +165,9 @@ let libsqlToken = $state('') let connectionUri = $state('') let uriHint = $state('') + // Manual-form input mode for URI-capable engines: paste a connection string + // vs. fill individual fields. UI-only — not tracked as a dirty change. + let fieldMode = $state(/** @type {'string'|'fields'} */ ('fields')) // ── Connection options ─────────────────────────────────────────────────────── let readOnly = $state(false) @@ -166,6 +202,28 @@ } const activeDriver = $derived(ALL_DRIVERS.find(d => d.id === dbType) ?? ALL_DRIVERS[0]) + const isProvider = $derived(PROVIDER_IDS.includes(dbType)) + + // Engines that expose the "Connection string | Manual fields" toggle. + const URI_TOGGLE_ENGINES = ['postgres', 'cockroachdb', 'mysql', 'mariadb'] + const hasFieldToggle = $derived(URI_TOGGLE_ENGINES.includes(dbType)) + + // Briefly ring-highlight the fields a parsed connection string just filled in. + let flashedFields = $state(/** @type {Set} */ (new Set())) + let flashTimer + const flashCls = 'ring-2 ring-emerald-500/40' + function flashFields(keys) { + flashedFields = new Set(keys) + clearTimeout(flashTimer) + flashTimer = setTimeout(() => { flashedFields = new Set() }, 1000) + } + + /** Switch the manual/provider tab. Leaving provider mode drops back to Postgres. */ + function setEntryMode(mode) { + if (entryMode === mode) return + entryMode = mode + if (mode === 'manual' && isProvider) switchDriver('postgres') + } function sshPayload() { if (!sshEnabled || !sshHost.trim() || !sshUsername.trim()) return undefined @@ -223,8 +281,13 @@ sshEnabled = false; sshHost = ''; sshPort = '22'; sshUsername = ''; sshKeyPath = '' readOnly = false } + entryMode = PROVIDER_IDS.includes(dbType) ? 'provider' : 'manual' + fieldMode = 'fields' + advancedOpen = false + flashedFields = new Set() error = ''; testOk = false; connectionUri = ''; uriHint = '' d1Reset() + baseline = snapshot() } /** @@ -274,17 +337,31 @@ function applyConnectionUri() { uriHint = '' - const parsed = parseConnectionUri(dbType === 'sqlite' || dbType === 'sqlite-memory' ? 'sqlite' : 'postgres', connectionUri) - if (!parsed) return - if ('error' in parsed) { uriHint = parsed.error; return } + const uriType = + dbType === 'sqlite' || dbType === 'sqlite-memory' ? 'sqlite' + : dbType === 'mysql' || dbType === 'mariadb' ? 'mysql' + : dbType === 'mssql' ? 'mssql' + : dbType === 'clickhouse' ? 'clickhouse' + : 'postgres' + const parsed = parseConnectionUri(uriType, connectionUri) + if (!parsed) return false + if ('error' in parsed) { uriHint = parsed.error; return false } if ((dbType === 'sqlite' || dbType === 'sqlite-memory') && 'filePath' in parsed) { - filePath = parsed.filePath; uriHint = 'Fields updated from URI'; return + filePath = parsed.filePath; flashFields(['filePath']); uriHint = 'Fields updated from URI'; return true } if ('host' in parsed) { host = parsed.host; port = parsed.port; database = parsed.database - user = parsed.user; password = parsed.password; ssl = parsed.ssl + user = parsed.user; password = parsed.password + const changed = ['host', 'port', 'database', 'user', 'password'] + // Each engine carries its own TLS shape; apply whichever the parser returned. + if ('encrypt' in parsed) { encrypt = parsed.encrypt; trustCert = parsed.trustCert; changed.push('encrypt', 'trustCert') } + else if ('secure' in parsed) { secure = parsed.secure; changed.push('secure') } + else { ssl = parsed.ssl; changed.push('ssl') } + flashFields(changed) uriHint = 'Fields updated from URI' + return true } + return false } function connDetail(conn) { @@ -344,6 +421,12 @@ const myOp = ++opId testing = true; error = ''; testOk = false try { + // In connection-string mode the payload is built from the individual + // fields, so parse the URI into them first (finally clears `testing`). + if (fieldMode === 'string' && URI_TOGGLE_ENGINES.includes(dbType) && !applyConnectionUri()) { + error = uriHint || 'Enter a valid connection string' + return + } const p = formPayload() if (p.type === 'sqlite') await testSqliteConnection(p) else if (p.type === 'd1') await testD1Connection(p) @@ -367,6 +450,12 @@ const myOp = ++opId connecting = editingId ?? '__new__'; error = '' try { + // In connection-string mode the payload is built from the individual + // fields, so parse the URI into them first (finally clears `connecting`). + if (fieldMode === 'string' && URI_TOGGLE_ENGINES.includes(dbType) && !applyConnectionUri()) { + error = uriHint || 'Enter a valid connection string' + return + } const payload = formPayload() if (payload.type === 'sqlite') await connectSqlite(payload) else if (payload.type === 'd1') await connectD1(payload) @@ -398,6 +487,40 @@ const canTest = $derived(dbType !== 'bigquery') const isBusy = $derived(testing || !!connecting) + // ── Dirty tracking + close guard ───────────────────────────────────────────── + // A snapshot of every editable field; `baseline` is re-stamped each time a form + // loads (resetForm), so isDirty flips true the moment the user changes anything. + let baseline = $state('') + let confirmDiscardOpen = $state(false) + function snapshot() { + return JSON.stringify([ + dbType, name, host, port, database, user, password, ssl, secure, encrypt, trustCert, + filePath, accountId, databaseId, apiToken, libsqlUrl, libsqlToken, readOnly, + sshEnabled, sshHost, sshPort, sshUsername, sshKeyPath, + ]) + } + const isDirty = $derived(snapshot() !== baseline) + + /** Full-width status-bar target for the current form. */ + const statusTarget = $derived.by(() => { + if (['sqlite', 'sqlite-memory', 'duckdb', 'duckdb-memory'].includes(dbType)) return filePath || ':memory:' + if (dbType === 'libsql') return libsqlUrl || '—' + if (dbType === 'd1') return databaseId ? `${databaseId.slice(0, 8)}…` : '—' + return `${host || '—'}:${port || '—'}/${database || ''}` + }) + + /** Attempt to close the dialog — guard against discarding unsaved edits. */ + function requestClose() { + if (isBusy) return + if (isDirty) { confirmDiscardOpen = true; return } + open = false + } + function discardAndClose() { + confirmDiscardOpen = false + baseline = snapshot() // stop isDirty re-firing during the close animation + open = false + } + async function d1Discover() { if (!apiToken.trim()) { d1DiscoverError = 'Enter your API token first.'; return } d1DiscoverPhase = 'loading'; d1DiscoverError = '' @@ -421,8 +544,12 @@ d1Databases = []; d1SelectedAccountId = ''; d1DbLoadPhase = 'idle' } - const lbl = 'mb-1.5 block text-[10.5px] font-semibold uppercase tracking-[0.06em] text-muted-foreground/55' - const inp = 'h-9 w-full rounded-lg border border-border/60 bg-muted/25 px-3 text-[13px] text-foreground placeholder:text-muted-foreground/35 placeholder:font-normal transition-[color,border-color,box-shadow] hover:border-border focus-visible:border-ring focus-visible:bg-muted/15 focus-visible:ring-2 focus-visible:ring-ring/20 focus-visible:outline-none' + const lbl = 'mb-1.5 block text-[10px] font-medium uppercase tracking-wider text-muted-foreground/50' + // Segmented pill switch (entry-mode + field-mode) — shared base for consistency. + const segBtn = 'inline-flex flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 py-1.5 text-[12px] font-medium transition-[color,background-color,box-shadow,transform] duration-150 ease-out active:scale-[0.97]' + const segOn = 'bg-muted/70 text-foreground shadow-sm' + const segOff = 'text-muted-foreground/60 hover:text-foreground' + const inp = 'h-9 w-full rounded-lg border border-input bg-muted/25 px-3 text-[12.5px] text-foreground placeholder:text-muted-foreground/35 placeholder:font-normal outline-none transition-[color,border-color,box-shadow] hover:border-ring/40 focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring' const inpNum = inp + ' [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none' async function pickSqliteFile() { @@ -496,16 +623,77 @@ {/snippet} + +{#snippet advancedSection()} +
+ + + {#if advancedOpen} +
+ {#if dbType === 'postgres' || dbType === 'cockroachdb' || dbType === 'mysql' || dbType === 'mariadb'} + + {@render sshSection(dbType)} + {:else if dbType === 'clickhouse'} + + {:else if dbType === 'mssql'} +
+ + +
+ {/if} + + + +
+ {/if} +
+{/snippet} + { if (isDirty && !isBusy) { e.preventDefault(); confirmDiscardOpen = true } }} + onInteractOutside={(e) => { if (isDirty && !isBusy) { e.preventDefault(); confirmDiscardOpen = true } }} > Connections -
+
+ +
+
+ + +
+ + {#if entryMode === 'manual'} +
+ Type + switchDriver(it.value)} + > + {#snippet trigger(props)} + + {/snippet} + {#snippet item(it)} + + {it.label} + {#if it.disabled} + soon + {:else if it.value === dbType} + + {/if} + {/snippet} + +
+ {/if} +
+ -
+
- +
-

+

{conn.name || 'Unnamed'}

- {#if isSel} -

{connDetail(conn)}

- {/if} +

+ {driverById(cid).label} · {connDetail(conn)} +

@@ -604,168 +849,142 @@
- + -
+
-
- -
- -
- - -
+ {#if entryMode === 'manual'} - -
- Type - switchDriver(it.value)} - > - {#snippet trigger(props)} - - {/snippet} - {#snippet item(it)} - - {it.label} - {#if it.disabled} - soon - {:else if it.value === dbType} - - {/if} - {/snippet} - -
+ +
+ +
+ {#key dbType}
- - {#if dbType === 'neon' || dbType === 'supabase' || dbType === 'planetscale' || dbType === 'prisma'} - saved.find((s) => s.host === host && s.user === user && s.password)?.password} - onselect={(conn) => connectProviderConnection(conn)} - /> + + {#if hasFieldToggle} +
+ + +
+ {/if} - {:else if dbType === 'postgres' || dbType === 'cockroachdb'} + {#if dbType === 'postgres' || dbType === 'cockroachdb'} -
- -
+ {#if fieldMode === 'string'} +
+ requestAnimationFrame(applyConnectionUri)} onkeydown={(e) => e.key === 'Enter' && (e.preventDefault(), applyConnectionUri())} /> - -
- {#if uriHint} -

- {#if uriHint.includes('Could') || uriHint.includes('Expected')} - - {:else} - - {/if} - {uriHint} -

- {/if} -
- -
-
- - + {#if uriHint} +

+ {#if uriHint.includes('Could') || uriHint.includes('Expected')} + + {:else} + + {/if} + {uriHint} +

+ {/if}
-
- - + {:else} +
+
+ + +
+
+ + +
-
-
- - -
- -
-
- - -
- - + +
-
- - - {@render sshSection('pg')} +
+
+ + +
+
+ + +
+
+ {/if} {:else if dbType === 'mysql' || dbType === 'mariadb'} -
+ {#if fieldMode === 'string'}
- - + + requestAnimationFrame(applyConnectionUri)} + onkeydown={(e) => e.key === 'Enter' && (e.preventDefault(), applyConnectionUri())} + /> + {#if uriHint} +

+ {#if uriHint.includes('Could') || uriHint.includes('Expected')} + + {:else} + + {/if} + {uriHint} +

+ {/if}
-
- - + {:else} +
+
+ + +
+
+ + +
-
-
- - -
- -
- - + +
-
- - -
-
- - - {@render sshSection('mysql')} +
+
+ + +
+
+ + +
+
+ {/if} {:else if dbType === 'sqlite'} @@ -777,7 +996,7 @@ placeholder="/path/to/database.db" class={cn(inp, 'font-mono text-[11px]')} /> @@ -851,38 +1070,60 @@ {:else if dbType === 'clickhouse'} -
+
+ +
+ requestAnimationFrame(applyConnectionUri)} + onkeydown={(e) => e.key === 'Enter' && (e.preventDefault(), applyConnectionUri())} + /> + +
+ {#if uriHint} +

+ {#if uriHint.includes('Could') || uriHint.includes('Expected')} + + {:else} + + {/if} + {uriHint} +

+ {/if} +
+ +
- +
- +
- +
- +
- +
- - {:else if dbType === 'duckdb'} @@ -914,140 +1155,270 @@ {:else if dbType === 'mssql'} -
+
+ +
+ requestAnimationFrame(applyConnectionUri)} + onkeydown={(e) => e.key === 'Enter' && (e.preventDefault(), applyConnectionUri())} + /> + +
+ {#if uriHint} +

+ {#if uriHint.includes('Could') || uriHint.includes('Expected')} + + {:else} + + {/if} + {uriHint} +

+ {/if} +
+ +
- +
- +
- +
- +
- +
-
- - -
- {/if} - - + {@render advancedSection()} + +
+ {/key} + + {:else} + + +
+ {#each PROVIDERS as p, i (p.id)} + {@const active = dbType === p.id} + + {/each} +
+ + {#if isProvider} +
+ saved.find((s) => s.host === host && s.user === user && s.password)?.password} + onselect={(conn) => connectProviderConnection(conn)} + /> + {@render advancedSection()} +
+ {/if} + + {/if} -
- -
-
+ +
+
- -
+ {#if error} -

- {error} -

- {:else if testOk} -

- Connected successfully -

+
+ + + Couldn't connect + {error} + +
{/if} -
- -
- -
- {#if lastId && saved.find(c => c.id === lastId)} - {@const lastConn = saved.find(c => c.id === lastId)} - + {/if} + {#if isBusy} + + {/if} + {#if canTest} + + {/if} + - {/if} -
- -
- {#if isBusy} - - {/if} - {#if canTest} - - {/if} - +
-
- - + + + + {#if confirmDiscardOpen} +
+
+
+
+ +
+
+

Discard unsaved changes?

+

+ You have unsaved edits in this connection. Closing now will lose them. +

+
+
+
+ + +
+
+
+ {/if} + + diff --git a/src/lib/components/DataDiffPage.svelte b/src/lib/components/DataDiffPage.svelte index 03327141..17690ab0 100644 --- a/src/lib/components/DataDiffPage.svelte +++ b/src/lib/components/DataDiffPage.svelte @@ -39,6 +39,20 @@ // svelte-ignore state_referenced_locally const _init = activeSchema + // System/catalog schemas that sort ahead of the user's real schema (e.g. + // Cockroach's crdb_internal, PG's pg_catalog) — picking schemas[0] would land + // on one of these and every table lookup fails ("relation X does not exist"). + const SYSTEM_SCHEMAS = new Set([ + 'crdb_internal', 'information_schema', 'pg_catalog', 'pg_toast', 'pg_extension', + 'sys', 'mysql', 'performance_schema', 'sys_catalog', + ]) + /** Best default schema: prefer `public`, then any non-system schema, else the first. */ + function pickDefaultSchema(/** @type {string[]} */ list) { + if (!list.length) return '' + if (list.includes('public')) return 'public' + return list.find((s) => !SYSTEM_SCHEMAS.has(s.toLowerCase())) ?? list[0] + } + /** @param {string} connId @param {string} database @param {string} schema @returns {SourceState} */ function makeSource(connId, database, schema) { return { connId, database, databases: [], loadingDbs: false, schema, schemas: [], loadingSchemas: false, table: '', tables: [], loadingTables: false, mode: 'table', sql: '' } @@ -256,14 +270,14 @@ async function loadSchemas(/** @type {SourceState} */ src, /** @type {SavedConnection} */ cfg, /** @type {(s:SourceState)=>void} */ set) { if (!cfg) return if (isCurrent(src.connId) && cfg.database === connById(src.connId)?.database) { - const schema = schemas[0] ?? '' + const schema = pickDefaultSchema(schemas) const next = { ...src, schemas, schema, tables: [], table: '', loadingSchemas: false } set(next); await loadTables(next, cfg, set); return } set({ ...src, loadingSchemas: true, schemas: [], schema: '', tables: [], table: '' }) try { const s = await listSchemasOnConnection(cfg) - const schema = s[0] ?? '' + const schema = pickDefaultSchema(s) const next = { ...src, loadingSchemas: false, schemas: s, schema, tables: [], table: '' } set(next) if (schema) await loadTables(next, cfg, set) diff --git a/src/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte index c99341bc..29356758 100644 --- a/src/lib/components/DataTable.svelte +++ b/src/lib/components/DataTable.svelte @@ -1,5 +1,7 @@ + +
+ + + + + +
+ + + +
+ + + {#if nodes.length} + + {/if} +
diff --git a/src/lib/components/ErdFocusController.svelte b/src/lib/components/ErdFocusController.svelte deleted file mode 100644 index f71a8fca..00000000 --- a/src/lib/components/ErdFocusController.svelte +++ /dev/null @@ -1,15 +0,0 @@ - diff --git a/src/lib/components/ErdRelationEdge.svelte b/src/lib/components/ErdRelationEdge.svelte deleted file mode 100644 index af2bd34e..00000000 --- a/src/lib/components/ErdRelationEdge.svelte +++ /dev/null @@ -1,71 +0,0 @@ - - - - - - - - - - sourceX ? 14 : -14)} - y={sourceY - 6} - font-size="9" - font-family="ui-monospace,monospace" - fill={selected ? 'hsl(var(--primary)/0.7)' : 'hsl(var(--muted-foreground)/0.4)'} - text-anchor="middle" - dominant-baseline="auto" - pointer-events="none" ->* - - - targetX ? 14 : -14)} - y={targetY - 6} - font-size="9" - font-family="ui-monospace,monospace" - fill={selected ? 'hsl(var(--primary)/0.7)' : 'hsl(var(--muted-foreground)/0.4)'} - text-anchor="middle" - dominant-baseline="auto" - pointer-events="none" ->1 diff --git a/src/lib/components/ErdTableNode.svelte b/src/lib/components/ErdTableNode.svelte deleted file mode 100644 index c8cc9056..00000000 --- a/src/lib/components/ErdTableNode.svelte +++ /dev/null @@ -1,136 +0,0 @@ - - - -
data.onSelect?.(data.name)} - ondblclick={() => data.onOpen?.(data.name)} - onkeydown={(e) => { if (e.key === 'Enter') data.onOpen?.(data.name) }} -> - - - - -
- {data.name} - -
- - -
- {#each (data.columns ?? []) as col (col.name)} - {@const isPk = data.pkCols?.has(col.name)} - {@const isFk = !!col.foreignKey} -
- - {#if isFk} - - {/if} - - - {col.name} - - - {col.dataType} - - - {#if isPk} - pk - {:else if isFk} - fk - {/if} -
- {/each} -
-
- - diff --git a/src/lib/components/KeyboardShortcutsDialog.svelte b/src/lib/components/KeyboardShortcutsDialog.svelte index 24c95561..38b1d375 100644 --- a/src/lib/components/KeyboardShortcutsDialog.svelte +++ b/src/lib/components/KeyboardShortcutsDialog.svelte @@ -116,6 +116,9 @@ label: "Appearance", icon: Palette, shortcuts: [ + { keys: [mod, "B"], desc: "Toggle sidebar" }, + { keys: [mod, "⇧", "T"], desc: "Toggle tab bar" }, + { keys: [mod, "⇧", "B"], desc: "Toggle status bar" }, { keys: [mod, "M"], desc: "Cycle theme" }, { keys: [mod, "⇧", "M"], desc: "Previous theme" }, { keys: [mod, "+"], desc: "Zoom in" }, @@ -127,6 +130,7 @@ label: "General", icon: Settings, shortcuts: [ + { keys: [mod, "?"], desc: "Keyboard shortcuts" }, { keys: ["?"], desc: "Keyboard shortcuts" }, { keys: [mod, ","], desc: "Settings" }, { keys: ["Esc"], desc: "Dismiss / close" }, diff --git a/src/lib/components/ProviderConnect.svelte b/src/lib/components/ProviderConnect.svelte index c4ae47c7..59a04c79 100644 --- a/src/lib/components/ProviderConnect.svelte +++ b/src/lib/components/ProviderConnect.svelte @@ -8,6 +8,7 @@ import LogOut from '@lucide/svelte/icons/log-out' import AlertTriangle from '@lucide/svelte/icons/alert-triangle' import RefreshCw from '@lucide/svelte/icons/refresh-cw' + import ArrowRight from '@lucide/svelte/icons/arrow-right' import DbIcon from './DbIcon.svelte' import Search from '@lucide/svelte/icons/search' import KeyRound from '@lucide/svelte/icons/key-round' @@ -171,50 +172,40 @@
{#if phase === 'idle'} - -
-
-
- -
-
-

Connect with {meta?.name}

-

{meta?.blurb}

-
+ + {#if meta?.mode === 'token'} +
+ { if (e.key === 'Enter') saveToken() }} + /> + +

Paste your {meta?.name} connection string to continue.

- - {#if meta?.mode === 'token'} -
- { if (e.key === 'Enter') saveToken() }} - /> - -
- {:else} -
- -

Opens your browser to authorize · secure PKCE flow

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

Opens your browser to authorize · secure PKCE flow

+
+ {/if} {:else if phase === 'authorizing'}
diff --git a/src/lib/components/SettingsDialog.svelte b/src/lib/components/SettingsDialog.svelte index c2ce1d67..c9d26745 100644 --- a/src/lib/components/SettingsDialog.svelte +++ b/src/lib/components/SettingsDialog.svelte @@ -189,7 +189,7 @@ type="button" onclick={() => { category = c.id; query = ''; }} class={cn( - 'flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-[13px] transition-colors', + 'flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-[13px] transition-[background-color,color,transform] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] active:scale-[0.98]', !searching && category === c.id ? 'bg-muted/60 font-medium text-foreground' : 'text-muted-foreground hover:bg-muted/30 hover:text-foreground', @@ -205,24 +205,28 @@
-

- {searching ? 'Search results' : activeCategory.label} -

- - {#if searching} - {@render generalContent()} - {@render appearanceContent()} - {@render integrationsContent()} - {@render aboutContent()} - {:else if category === 'general'} - {@render generalContent()} - {:else if category === 'appearance'} - {@render appearanceContent()} - {:else if category === 'integrations'} - {@render integrationsContent()} - {:else} - {@render aboutContent()} - {/if} + {#key searching ? '__search__' : category} +
+

+ {searching ? 'Search results' : activeCategory.label} +

+ + {#if searching} + {@render generalContent()} + {@render appearanceContent()} + {@render integrationsContent()} + {@render aboutContent()} + {:else if category === 'general'} + {@render generalContent()} + {:else if category === 'appearance'} + {@render appearanceContent()} + {:else if category === 'integrations'} + {@render integrationsContent()} + {:else} + {@render aboutContent()} + {/if} +
+ {/key}
@@ -247,11 +251,11 @@ type="button" role="switch" aria-checked={checked} aria-label={label} onclick={ontoggle} class={cn( - 'relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors', + 'relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-[background-color,transform] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] active:scale-[0.98]', checked ? 'bg-primary' : 'bg-muted', )} > - +
{/snippet} @@ -445,7 +449,7 @@ - + @@ -483,7 +487,7 @@

Website

Docs, licensing, and support.

- + stroke.click
@@ -507,3 +511,27 @@ {action} {/snippet} + + diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte index 768da45f..6a1c16e9 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -419,7 +419,7 @@ /** Shared field chrome for schema select + table filter (aligned in sidebar grid) */ const sidebarFieldClass = - "h-7 w-full min-w-0 rounded-md border border-border bg-background/40 text-ui-sm text-foreground shadow-none transition-colors hover:bg-background/55 focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/30"; + "h-7 w-full min-w-0 rounded-md border border-border bg-background/40 text-ui-sm text-foreground shadow-none transition-colors hover:bg-background/55 focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring"; { @@ -734,7 +734,7 @@ )} /> - Recent + Recent {Math.min(recentTabs.length, 5)} @@ -1260,7 +1260,7 @@ )} /> Materialized Views {#if matViews.length > 0} @@ -1289,7 +1289,7 @@ ? "bg-primary/10 text-foreground" : activeTable === mv.name ? "bg-sidebar-accent text-sidebar-accent-foreground" - : "text-muted-foreground hover:bg-sidebar-accent/50 hover:text-foreground", + : "text-foreground/70 hover:bg-sidebar-accent/50 hover:text-foreground", )} onclick={() => ontableselect(mv.name)} > @@ -1308,9 +1308,9 @@