Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
0a95ef2
feat(motion): add shared easing tokens and reduce-motion handling
broisnees Jul 10, 2026
eb2e0e2
feat(ui): add press feedback to buttons
broisnees Jul 10, 2026
99f0af7
feat(ui): crisp single-ring input focus state
broisnees Jul 10, 2026
9c05c30
feat(ui): origin-aware dropdown menu motion
broisnees Jul 10, 2026
282d6bb
feat(ui): origin-aware select motion
broisnees Jul 10, 2026
10b6525
feat(ui): origin-aware popover motion
broisnees Jul 10, 2026
f4cb441
feat(ui): quicker tooltip motion
broisnees Jul 10, 2026
3ff138d
feat(ui): refine dialog enter/exit and overlay fade
broisnees Jul 10, 2026
5bf92a5
perf(ui): keep command palette open/close snappy
broisnees Jul 10, 2026
3056d55
feat(sidebar): baseline-align rows, raise contrast, refine section he…
broisnees Jul 10, 2026
8f6c41c
fix(table): fall back to default page size when the limit is All or i…
broisnees Jul 10, 2026
cb7e8e7
fix(grid): block Set NULL on NOT NULL columns; edit-overlay and press…
broisnees Jul 10, 2026
3d453ba
feat(connect): two-pane layout, segmented input mode, redesigned erro…
broisnees Jul 10, 2026
0ee9a79
feat(connect): compact provider sign-in action
broisnees Jul 10, 2026
4a2fcc1
feat(settings): tighten spacing and control feedback
broisnees Jul 10, 2026
353262f
chore(status-bar): update status bar
broisnees Jul 10, 2026
27575ac
chore: update keyboard shortcuts dialog
broisnees Jul 10, 2026
091bc70
refactor: connection URI parsing
broisnees Jul 10, 2026
7844141
chore: update database capabilities
broisnees Jul 10, 2026
b2b9557
chore: update data diff page
broisnees Jul 10, 2026
04e1347
refactor(erd): consolidate ERD into a single canvas component
broisnees Jul 10, 2026
3916392
refactor(db): update query handling
broisnees Jul 10, 2026
ab29a4f
refactor(db): update MySQL layer
broisnees Jul 10, 2026
64b2f1b
refactor(db): update MSSQL layer
broisnees Jul 10, 2026
3676af4
refactor(db): update EXPLAIN handling
broisnees Jul 10, 2026
5d91864
chore: update package-lock
broisnees Jul 10, 2026
1801e0c
docs(changelog): add unreleased entry
broisnees Jul 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

135 changes: 127 additions & 8 deletions src-tauri/src/db/explain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,23 @@ fn strip_sql(sql: &str) -> &str {
}

pub async fn explain_pg(pool: &sqlx::PgPool, sql: &str) -> Result<ExplainResult, String> {
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())
Expand All @@ -65,6 +72,118 @@ pub async fn explain_pg(pool: &sqlx::PgPool, sql: &str) -> Result<ExplainResult,
})
}

/// CockroachDB has no JSON plan format, so parse its indented `• node` text tree
/// into the same `{ "Node Type", "Plans" }` shape the PG/SQLite renderer draws.
/// Uses plain `EXPLAIN` (not `ANALYZE`) so the query is never executed.
pub async fn explain_cockroach(pool: &sqlx::PgPool, sql: &str) -> Result<ExplainResult, String> {
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<String> = rows
.iter()
.filter_map(|r| r.try_get::<String, _>(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<serde_json::Value> {
use serde_json::{json, Map, Value};

struct Frame {
depth: usize,
node: Map<String, Value>,
children: Vec<Value>,
}
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<Frame>, roots: &mut Vec<Value>, done: Value) {
match stack.last_mut() {
Some(p) => p.children.push(done),
None => roots.push(done),
}
}

let mut stack: Vec<Frame> = Vec::new();
let mut roots: Vec<Value> = 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::<i64>() {
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<ExplainResult, String> {
let sql = strip_sql(sql);
let q = format!("EXPLAIN FORMAT=JSON {sql}");
Expand Down
15 changes: 13 additions & 2 deletions src-tauri/src/db/mssql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -491,9 +491,20 @@ pub async fn insert_table_row(
let val_list = cols.iter().map(|c| sql_literal(&values[c])).collect::<Vec<_>>().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(
Expand Down
12 changes: 7 additions & 5 deletions src-tauri/src/db/mysql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
Expand All @@ -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();

Expand Down
23 changes: 23 additions & 0 deletions src-tauri/src/db/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<i64>));
}
if t == "bool" || t == "boolean" {
return Ok(q.bind(None::<bool>));
}
if t == "uuid" {
return Ok(q.bind(None::<Uuid>));
}
if t.contains("numeric")
|| t.contains("decimal")
|| t.contains("real")
|| t.contains("double")
|| t.contains("float")
{
return Ok(q.bind(None::<f64>));
}
// 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::<String>));
}

Expand Down
29 changes: 29 additions & 0 deletions src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading