From 6174a5c1220da9ccff79dea35d58f11d4bd8f07a Mon Sep 17 00:00:00 2001 From: broisnischal Date: Fri, 3 Jul 2026 10:17:05 +0545 Subject: [PATCH 1/5] chore: v1.1 changeset + auto-release tag push via PAT Co-Authored-By: Claude Fable 5 --- .changeset/quality-pass-1-1.md | 40 ++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .changeset/quality-pass-1-1.md diff --git a/.changeset/quality-pass-1-1.md b/.changeset/quality-pass-1-1.md new file mode 100644 index 00000000..0862ef2a --- /dev/null +++ b/.changeset/quality-pass-1-1.md @@ -0,0 +1,40 @@ +### New Features + +#### Tabs +- Pin tabs: pinned tabs stay grouped at the front, show a pin badge, and survive "Close Others" / "Close All" + +#### Themes +- New **Graphite** theme, wired through the editor and diagram themes + +#### Licensing +- New in-app license page: "Activate Pro" now opens a dedicated tab with key entry, plan status, what Pro unlocks, and a purchase link — confetti included on activation +- License entry in Settings with a live plan badge (Pro / Trial / Free) + +#### SQL Editor +- Column sorting on query results — numbers sort numerically, NULLs last, and the sorted order carries into the JSON view, charts, and CSV/JSON exports + +### Bug Fixes + +#### Data Table +- Pinned columns now freeze flush to the left edge — no more empty gutter-wide gap after scrolling +- Query results grid is read-only: removed actions that can't apply to ad-hoc results (Filter/Exclude by value, Edit, Set NULL, Duplicate/Delete row, INSERT copy) +- Column stats no longer error with "Invalid identifier" on SQL editor results +- Sidebar rows no longer shift as row counts load in + +#### Window +- Double-clicking the titlebar now maximizes reliably (a duplicate handler was maximizing and instantly restoring) + +### Changes + +#### Performance +- Reconnect feels instant: the overlay drops as soon as the connection is live, and schemas/tables stream into the sidebar instead of blocking the screen +- Table row counts load lazily — the sidebar renders immediately with placeholders and exact counts fill in from a background pass; schema switches get the same speedup +- Faster Postgres connect: one fewer round trip per pooled connection, and the reachability check runs concurrently with the real handshake +- Sidebar stays virtualized while table rows are expanded (no more hitch when expanding columns on large schemas) +- AI streaming responses parse markdown at most once per frame instead of once per token + +#### Settings +- Settings dialog redesigned: grouped sections (Appearance / Behavior / More), clearer hierarchy, keyboard-shortcut chips + +#### Release pipeline +- Releases now publish to the public `stroke` repository — auto-update, Homebrew, and Scoop keep working with the private source repo From 134e65ebb138cf4550a89842a82976c6e940da30 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Fri, 3 Jul 2026 10:20:57 +0545 Subject: [PATCH 2/5] chore(changelog): fold unreleased notes into 1.0.0, sync changelog to public repo on release Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 27 ++++++++++++++++++++++++--- CHANGELOG.md | 6 ------ 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 55d720e0..1ac1370f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -405,9 +405,10 @@ jobs: TAG: ${{ env.RELEASE_TAG }} run: gh release upload "$TAG" --repo "$RELEASES_REPO" latest.json --clobber -# ── 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. +# ── 4. Refresh the Scoop manifest + changelog in the public releases repo ──── +# Scoop clones the bucket repo anonymously, and the website reads the +# changelog from the public repo — both must live in RELEASES_REPO, not +# in this private source repo. update-scoop: needs: build runs-on: ubuntu-latest @@ -468,6 +469,26 @@ jobs: git push origin HEAD fi + # Mirror the changelog into the public repo so the website (and anyone + # browsing github.com/broisnischal/stroke) sees release history without + # access to the private source repo. Fetched at the release tag so it + # includes the entry auto-release just folded in. + - name: Sync CHANGELOG.md + env: + GH_TOKEN: ${{ secrets.RELEASES_TOKEN }} + TAG: ${{ env.RELEASE_TAG }} + run: | + gh api "repos/${{ github.repository }}/contents/CHANGELOG.md?ref=${TAG}" \ + --jq .content | base64 -d > CHANGELOG.md + + git add CHANGELOG.md + if git diff --cached --quiet; then + echo "Changelog already current." + else + git commit -m "docs(changelog): stroke ${TAG}" + git push origin HEAD + fi + # Optional instant Homebrew tap refresh. Without a PAT this is skipped and # the tap updates itself on its own schedule instead (see homebrew-tap repo). # (secrets can't be used in an `if:`, so the check lives in the script.) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1821b051..fc96e856 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,6 @@ All notable changes to Stroke are listed here, newest first. ## [1.0.0] - 2026-07-01 -### Changes -- feat:: perf, ux, themes and optimizations (#13) - - -## [Unreleased] - ### Performance - Faster (re)connect: `onConnected` now loads query history + MCP autostart concurrently with the schema/table load instead of behind it, and no longer double-fetches the query stores on startup. - Fixed the reconnect "slow acquire" storm: per-table `COUNT(*)` on large Postgres schemas now runs with bounded concurrency (capped at the pool size) instead of firing every count at once and starving the 4-connection pool. From b90f41ea3d8f305e7ab7b8ed1c5080e3dd027559 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Fri, 3 Jul 2026 11:07:53 +0545 Subject: [PATCH 3/5] =?UTF-8?q?hotfix(sidebar):=20remove=20inline=20column?= =?UTF-8?q?=20expansion=20(chevron)=20=E2=80=94=20restore=20plain=20virtua?= =?UTF-8?q?lized=20scrolling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- src/lib/components/Sidebar.svelte | 186 +----------------------------- 1 file changed, 5 insertions(+), 181 deletions(-) diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte index a310275f..912b68b0 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -8,8 +8,6 @@ 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"; @@ -44,7 +42,6 @@ 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, @@ -92,76 +89,6 @@ 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. @@ -514,78 +441,17 @@ 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 - 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) + return Math.max(0, Math.floor((sidebarScrollTop - tableListOffsetTop) / ROW_H) - VIRT_BUFFER) }) const virtEnd = $derived.by(() => { if (!shouldVirtualize) return filteredRegularTables.length - 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 + const end = Math.ceil((sidebarScrollTop + sidebarHeight - tableListOffsetTop) / ROW_H) + VIRT_BUFFER return Math.min(filteredRegularTables.length, end) }) - 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) - }) + const virtTopPad = $derived(shouldVirtualize ? virtStart * ROW_H : 0) + const virtBotPad = $derived(shouldVirtualize ? Math.max(0, (filteredRegularTables.length - virtEnd) * ROW_H) : 0) /** Shared field chrome for schema select + table filter (aligned in sidebar grid) */ const sidebarFieldClass = @@ -1145,14 +1011,13 @@ {#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} From 45f99d5ceb2e17486deb28ac67e6f5a67eeab47a Mon Sep 17 00:00:00 2001 From: broisnischal Date: Fri, 3 Jul 2026 13:30:40 +0545 Subject: [PATCH 4/5] fix: blob-cell freeze (#1), tab crash isolation, search highlight, JSON tree (#2), switcher focus (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical fixes: - Cap per-cell payloads at 256KB in Postgres/MySQL/SQLite cell serializers (and the MCP server's pg_cell): multi-MB file buffers stored in jsonb/text/ blob froze the app end-to-end (IPC, webview parse, grid stringify, Monaco pretty-print). Oversized cells now ship as a sentinel with a 16KB preview; the grid shows a truncation marker, the JSON lightbox shows the preview under a size banner, copy/export render the truncation explicitly, and editing/filtering on truncated cells is blocked so a preview can never be written back over real data. - Contain canvas crashes to their tab: the draw loop and canvas event handlers run outside Svelte's effect tree, so exceptions bypassed the per-tab and froze the grid with no error UI. First error is now captured and rethrown from an effect, landing in the existing "Reload this view" card while the rest of the app stays alive. Features: - Search-match highlighting in the data grid: toolbar search now paints where each match falls inside visible cell text (case-insensitive, matches ILIKE). - JsonTree.svelte: collapsible JSON tree in expanded rows with per-node copy and open-in-Monaco-lightbox; Tree/Raw toggle persisted; children paginate at 200 so huge arrays can't blow up the DOM. Fixes & polish: - DB switcher: filter input always rendered and autofocused on open via onOpenAutoFocus (bits-ui no longer steals focus); ⌘D / ⇧⌘C keybindings and arrows/Tab/Enter list navigation from the input. - Trial licensing fails open: only a definitive TrialExpired locks Pro; transient license-check errors keep the last known status. - SQL console: cached sort keys — object cells no longer re-stringify on every comparison. - AI chat: conversation column matches composer width; roomier message rhythm. - Sidebar: "View data structure" → "View structure". Co-Authored-By: Claude Fable 5 --- src-tauri/src/db/mysql.rs | 20 +- src-tauri/src/db/query.rs | 24 +++ src-tauri/src/db/sql_util.rs | 49 +++++ src-tauri/src/db/sqlite.rs | 28 ++- src-tauri/src/mcp/tools.rs | 38 +++- src/lib/cell-expand.js | 11 ++ src/lib/cell-value.js | 48 +++++ src/lib/components/AiChat.svelte | 8 +- src/lib/components/DataTable.svelte | 156 +++++++++++++-- src/lib/components/JsonCellLightbox.svelte | 23 ++- src/lib/components/JsonTree.svelte | 179 ++++++++++++++++++ .../components/KeyboardShortcutsDialog.svelte | 2 + src/lib/components/RowDetailPanel.svelte | 6 +- src/lib/components/RowExpandViewer.svelte | 129 +++++++++---- src/lib/components/Sidebar.svelte | 4 +- src/lib/components/SqlConsole.svelte | 20 +- src/lib/components/StatusBar.svelte | 165 ++++++++++++++-- src/lib/components/StudioShell.svelte | 1 + src/lib/row-inspector.js | 19 +- src/lib/stores/license.js | 12 +- 20 files changed, 849 insertions(+), 93 deletions(-) create mode 100644 src/lib/components/JsonTree.svelte diff --git a/src-tauri/src/db/mysql.rs b/src-tauri/src/db/mysql.rs index dd3a70cf..218d19a9 100644 --- a/src-tauri/src/db/mysql.rs +++ b/src-tauri/src/db/mysql.rs @@ -39,10 +39,26 @@ pub fn cell_to_json(row: &sqlx::mysql::MySqlRow, idx: usize) -> Value { return v.map(|t| json!(t.to_string())).unwrap_or(Value::Null); } if let Ok(v) = row.try_get::, _>(idx) { - return v.unwrap_or(Value::Null); + return match v { + // Cap oversized JSON documents — a multi-MB cell shipped whole + // freezes the webview (see sql_util::CELL_VALUE_CAP). + Some(val) => super::sql_util::cap_json_value( + &row.column(idx).type_info().name().to_lowercase(), + val, + ), + None => Value::Null, + }; } if let Ok(v) = row.try_get::, _>(idx) { - return v.map(|s| json!(s)).unwrap_or(Value::Null); + return match v { + Some(s) if s.len() > super::sql_util::CELL_VALUE_CAP => super::sql_util::oversize_cell( + &row.column(idx).type_info().name().to_lowercase(), + s.len(), + s.as_bytes(), + ), + Some(s) => json!(s), + None => Value::Null, + }; } if let Ok(v) = row.try_get::>, _>(idx) { return v.map(|b| json!(format!("[{} bytes]", b.len()))).unwrap_or(Value::Null); diff --git a/src-tauri/src/db/query.rs b/src-tauri/src/db/query.rs index 553a3d3a..91c13f9b 100644 --- a/src-tauri/src/db/query.rs +++ b/src-tauri/src/db/query.rs @@ -204,6 +204,22 @@ fn cell_to_json(row: &sqlx::postgres::PgRow, idx: usize) -> Value { try_get_string!(Uuid); if type_name == "JSON" || type_name == "JSONB" { + if let Ok(raw) = row.try_get_raw(idx) { + if raw.is_null() { + return Value::Null; + } + // Peek the wire bytes before decoding: an oversized jsonb cell (e.g. a + // file buffer serialized into JSON) would otherwise materialize millions + // of serde_json nodes here and then freeze the webview during IPC parse. + if let Ok(bytes) = raw.as_bytes() { + // Binary-format jsonb = 1-byte version header + JSON text. JSON text + // can never start with 0x01, so stripping it is unambiguous. + let body = if bytes.first() == Some(&1) { &bytes[1..] } else { bytes }; + if body.len() > super::sql_util::CELL_VALUE_CAP { + return super::sql_util::oversize_cell(&type_name.to_lowercase(), body.len(), body); + } + } + } if let Ok(v) = row.try_get::, _>(idx) { return v.unwrap_or(Value::Null); } @@ -217,6 +233,14 @@ fn cell_to_json(row: &sqlx::postgres::PgRow, idx: usize) -> Value { if raw.is_null() { return Value::Null; } + // Oversize guard before allocating the String — text cells beyond the + // cap ship as a sentinel + preview instead of the whole value. Non-UTF-8 + // payloads (bytea) fall through to the byte-count placeholder below. + if let Ok(bytes) = raw.as_bytes() { + if bytes.len() > super::sql_util::CELL_VALUE_CAP && std::str::from_utf8(bytes).is_ok() { + return super::sql_util::oversize_cell(&type_name.to_lowercase(), bytes.len(), bytes); + } + } if let Ok(text) = >::decode(raw) { return json!(text); } diff --git a/src-tauri/src/db/sql_util.rs b/src-tauri/src/db/sql_util.rs index 0e6f1402..84c263ab 100644 --- a/src-tauri/src/db/sql_util.rs +++ b/src-tauri/src/db/sql_util.rs @@ -57,6 +57,55 @@ pub fn esc_backslash_quote(s: &str) -> String { s.replace('\\', "\\\\").replace('\'', "\\'") } +// ── Oversized-cell capping ──────────────────────────────────────────────────── +// +// Cells whose payload exceeds `CELL_VALUE_CAP` are replaced with a small +// sentinel object instead of being shipped to the webview whole. Multi-MB +// cells (file buffers stored in jsonb/text/blob columns) otherwise freeze the +// app: the IPC JSON payload, the webview-side parse, and the grid's per-cell +// stringify all scale with cell size (GitHub issue: "App freezes"). +// The frontend detects the sentinel via `__strokeOversize` (see +// `src/lib/cell-value.js`), renders the preview, and blocks editing so a +// truncated value can never be written back. + +/// Per-cell payload cap, in bytes of serialized text. +pub const CELL_VALUE_CAP: usize = 256 * 1024; + +/// How much of an oversized cell ships as a preview (grid text + JSON lightbox). +pub const CELL_PREVIEW_BYTES: usize = 16 * 1024; + +/// Build the oversize sentinel for a cell too large to ship. `body` is the +/// value's UTF-8 text (JSON text, plain text, or hex for blobs). +pub fn oversize_cell(data_type: &str, total_bytes: usize, body: &[u8]) -> serde_json::Value { + let end = CELL_PREVIEW_BYTES.min(body.len()); + let preview = String::from_utf8_lossy(&body[..end]); + serde_json::json!({ + "__strokeOversize": true, + "dataType": data_type, + "bytes": total_bytes, + "preview": preview, + }) +} + +/// Cap an already-decoded JSON value (engines where the raw wire bytes can't +/// be peeked cheaply). Containers are measured by serializing; oversized ones +/// collapse to the sentinel. Scalars below the cap pass through untouched. +pub fn cap_json_value(data_type: &str, v: serde_json::Value) -> serde_json::Value { + match &v { + serde_json::Value::Object(_) | serde_json::Value::Array(_) => { + let text = v.to_string(); + if text.len() > CELL_VALUE_CAP { + return oversize_cell(data_type, text.len(), text.as_bytes()); + } + v + } + serde_json::Value::String(s) if s.len() > CELL_VALUE_CAP => { + oversize_cell(data_type, s.len(), s.as_bytes()) + } + _ => v, + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/db/sqlite.rs b/src-tauri/src/db/sqlite.rs index b2e1b33f..67958f50 100644 --- a/src-tauri/src/db/sqlite.rs +++ b/src-tauri/src/db/sqlite.rs @@ -17,17 +17,35 @@ pub fn cell_to_json(row: &sqlx::sqlite::SqliteRow, idx: usize) -> Value { return v.map(|n| json!(n)).unwrap_or(Value::Null); } if let Ok(v) = row.try_get::, _>(idx) { - return v.map(|s| json!(s)).unwrap_or(Value::Null); + return match v { + // Cap oversized text/JSON — a multi-MB cell shipped whole freezes + // the webview (see sql_util::CELL_VALUE_CAP). + Some(s) if s.len() > super::sql_util::CELL_VALUE_CAP => { + super::sql_util::oversize_cell(&col_type(row, idx), s.len(), s.as_bytes()) + } + Some(s) => json!(s), + None => Value::Null, + }; } if let Ok(v) = row.try_get::>, _>(idx) { - return v - .map(|b| Value::String(b.iter().map(|x| format!("{x:02x}")).collect())) - .unwrap_or(Value::Null); + return match v { + // Hex display doubles the byte count — cap blobs the same way so a + // file stored in a blob column can't ship megabytes of hex. + Some(b) if b.len() * 2 > super::sql_util::CELL_VALUE_CAP => { + let head: String = b + .iter() + .take(super::sql_util::CELL_PREVIEW_BYTES / 2) + .map(|x| format!("{x:02x}")) + .collect(); + super::sql_util::oversize_cell("blob", b.len(), head.as_bytes()) + } + Some(b) => Value::String(b.iter().map(|x| format!("{x:02x}")).collect()), + None => Value::Null, + }; } Value::Null } -#[allow(dead_code)] fn col_type(row: &sqlx::sqlite::SqliteRow, idx: usize) -> String { row.column(idx).type_info().name().to_lowercase() } diff --git a/src-tauri/src/mcp/tools.rs b/src-tauri/src/mcp/tools.rs index fcea4007..d9088e1a 100644 --- a/src-tauri/src/mcp/tools.rs +++ b/src-tauri/src/mcp/tools.rs @@ -1317,10 +1317,39 @@ fn pg_cell(row: &sqlx::postgres::PgRow, idx: usize) -> Value { try_str!(NaiveDate); try_str!(NaiveTime); try_str!(Uuid); - try_get!(String); + if let Ok(v) = row.try_get::, _>(idx) { + return match v { + // Cap oversized text the same way the grid does — a multi-MB cell + // in an MCP response floods the calling AI tool's context. + Some(s) if s.len() > crate::db::sql_util::CELL_VALUE_CAP => { + crate::db::sql_util::oversize_cell( + &row.column(idx).type_info().name().to_lowercase(), + s.len(), + s.as_bytes(), + ) + } + Some(s) => json!(s), + None => Value::Null, + }; + } let col = row.column(idx); - if col.type_info().name() == "JSON" || col.type_info().name() == "JSONB" { + let type_name = col.type_info().name(); + if type_name == "JSON" || type_name == "JSONB" { + if let Ok(raw) = row.try_get_raw(idx) { + if raw.is_null() { + return Value::Null; + } + // Peek the wire bytes before decoding (see db::query cell handling): + // binary-format jsonb = 1-byte version header + JSON text, and JSON + // text can never start with 0x01. + if let Ok(bytes) = raw.as_bytes() { + let body = if bytes.first() == Some(&1) { &bytes[1..] } else { bytes }; + if body.len() > crate::db::sql_util::CELL_VALUE_CAP { + return crate::db::sql_util::oversize_cell(&type_name.to_lowercase(), body.len(), body); + } + } + } if let Ok(v) = row.try_get::, _>(idx) { return v.unwrap_or(Value::Null); } @@ -1329,6 +1358,11 @@ fn pg_cell(row: &sqlx::postgres::PgRow, idx: usize) -> Value { if raw.is_null() { return Value::Null; } + if let Ok(bytes) = raw.as_bytes() { + if bytes.len() > crate::db::sql_util::CELL_VALUE_CAP && std::str::from_utf8(bytes).is_ok() { + return crate::db::sql_util::oversize_cell(&type_name.to_lowercase(), bytes.len(), bytes); + } + } if let Ok(text) = >::decode(raw) { return json!(text); } diff --git a/src/lib/cell-expand.js b/src/lib/cell-expand.js index ad4d380c..8de8714c 100644 --- a/src/lib/cell-expand.js +++ b/src/lib/cell-expand.js @@ -1,3 +1,10 @@ +import { oversizeCellInfo, oversizeCellText } from './cell-value.js' + +// Strings longer than this are never JSON.parse-probed for expandability — +// parsing multi-MB strings on render freezes the UI. (Backend caps cells well +// below this; the guard covers engines without capping.) +const EXPAND_PARSE_LIMIT = 1_000_000 + /** * @param {string} dataType */ @@ -19,6 +26,7 @@ export function isExpandableCellValue(value, dataType) { if (typeof value === 'object') return true if (typeof value === 'string') { const t = value.trim() + if (t.length > EXPAND_PARSE_LIMIT) return false if ((t.startsWith('{') && t.endsWith('}')) || (t.startsWith('[') && t.endsWith(']'))) { try { JSON.parse(t) @@ -38,6 +46,7 @@ export function isExpandableCellValue(value, dataType) { export function valueForJsonViewer(value) { if (typeof value === 'string') { const t = value.trim() + if (t.length > EXPAND_PARSE_LIMIT) return value if ((t.startsWith('{') && t.endsWith('}')) || (t.startsWith('[') && t.endsWith(']'))) { try { return JSON.parse(t) @@ -56,6 +65,8 @@ export function valueForJsonViewer(value) { export function cellCollapsedPreview(value) { if (value === null || value === undefined) return 'NULL' if (typeof value === 'object') { + const over = oversizeCellInfo(value) + if (over) return oversizeCellText(over) try { return JSON.stringify(value) } catch { diff --git a/src/lib/cell-value.js b/src/lib/cell-value.js index d06a7ab2..5ddc2a10 100644 --- a/src/lib/cell-value.js +++ b/src/lib/cell-value.js @@ -1,5 +1,53 @@ /** @typedef {{ ok: true, value: unknown } | { ok: false, message: string }} ParseResult */ +// ── Oversized cells ────────────────────────────────────────────────────────── +// The backend replaces cells whose payload exceeds its per-cell cap with a +// small sentinel object (see src-tauri/src/db/sql_util.rs) so multi-MB values +// (file buffers in jsonb/text/blob columns) can't freeze the webview. Only a +// preview reaches the frontend; truncated cells must never be edited or used +// as filter values. + +/** @typedef {{ dataType: string, bytes: number, preview: string }} OversizeCellInfo */ + +/** + * Detect the backend's oversized-cell sentinel. + * @param {unknown} value + * @returns {OversizeCellInfo | null} + */ +export function oversizeCellInfo(value) { + if ( + value !== null && + typeof value === 'object' && + /** @type {any} */ (value).__strokeOversize === true + ) { + const v = /** @type {any} */ (value) + return { + dataType: String(v.dataType ?? ''), + bytes: Number(v.bytes) || 0, + preview: String(v.preview ?? ''), + } + } + return null +} + +/** @param {number} bytes */ +export function formatByteSize(bytes) { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB` + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB` +} + +/** + * One-line marker + preview head used wherever a truncated cell becomes text + * (grid, copy, row detail). The full preview stays in the JSON lightbox. + * @param {OversizeCellInfo} over + */ +export function oversizeCellText(over) { + const head = over.preview.length > 200 ? over.preview.slice(0, 200) + '…' : over.preview + return `[${over.dataType || 'value'} · ${formatByteSize(over.bytes)} — truncated] ${head}` +} + /** * @param {unknown} value */ diff --git a/src/lib/components/AiChat.svelte b/src/lib/components/AiChat.svelte index c42d24c6..38a767b6 100644 --- a/src/lib/components/AiChat.svelte +++ b/src/lib/components/AiChat.svelte @@ -2507,11 +2507,15 @@ role="region" aria-label="Chat messages" > +
    {#if items.length === 0} @@ -2615,7 +2619,7 @@
    {/if} {:else} -
    +
    {#each items as item (item.id)}
    diff --git a/src/lib/components/JsonTree.svelte b/src/lib/components/JsonTree.svelte new file mode 100644 index 00000000..0299c874 --- /dev/null +++ b/src/lib/components/JsonTree.svelte @@ -0,0 +1,179 @@ + + +
    0 ? 'padding-left: 1.1rem' : ''}> + +
    + {#if isContainer} + + {:else} + + {/if} + +
    + + {#if label !== null} + : + {/if} + + {#if isContainer} + + {:else} + {leafText} + {/if} + + + + + + {#if isContainer && expanded} +
    + {#each entries.slice(0, childLimit) as [k, v] (k)} + + {/each} + {#if entries.length > childLimit} + + {/if} +
    + {isArray ? ']' : '}'} + {/if} +
    +
    +
    diff --git a/src/lib/components/KeyboardShortcutsDialog.svelte b/src/lib/components/KeyboardShortcutsDialog.svelte index 0d7f111d..d7a3b1cf 100644 --- a/src/lib/components/KeyboardShortcutsDialog.svelte +++ b/src/lib/components/KeyboardShortcutsDialog.svelte @@ -38,6 +38,8 @@ { keys: [mod, "⇧", "Tab"], desc: "Previous tab" }, { keys: [mod, "B"], desc: "Toggle sidebar" }, { keys: [mod, "⇧", "F"], desc: "Focus table filter" }, + { keys: [mod, "D"], desc: "Switch database" }, + { keys: [mod, "⇧", "C"], desc: "Switch connection" }, { keys: ["F11"], desc: "Toggle fullscreen" }, ], }, diff --git a/src/lib/components/RowDetailPanel.svelte b/src/lib/components/RowDetailPanel.svelte index 7f6b3f69..b788dc9b 100644 --- a/src/lib/components/RowDetailPanel.svelte +++ b/src/lib/components/RowDetailPanel.svelte @@ -29,6 +29,7 @@ valueToEditString, parseCellInput, normalizeColumnType, + oversizeCellInfo, } from '$lib/cell-value.js' import { clampInspectorWidth, @@ -132,8 +133,9 @@ const enumValues = getColumnEnumValues(col) const isBoolean = isBooleanType(dataType) const isPk = pkSet.has(col.name) - // PK columns and non-editable types are read-only - const editable = canEdit && !isPk && isEditableType(dataType) + // PK columns, non-editable types, and truncated oversize cells (only a + // preview was loaded — saving would write it back) are read-only. + const editable = canEdit && !isPk && isEditableType(dataType) && !oversizeCellInfo(raw) const isNull = raw === null const isEmpty = typeof raw === 'string' && raw === '' const isJsonType = normalType.startsWith('json') diff --git a/src/lib/components/RowExpandViewer.svelte b/src/lib/components/RowExpandViewer.svelte index bb05f445..67b41f4e 100644 --- a/src/lib/components/RowExpandViewer.svelte +++ b/src/lib/components/RowExpandViewer.svelte @@ -3,6 +3,9 @@ import Copy from '@lucide/svelte/icons/copy' import Check from '@lucide/svelte/icons/check' import WrapText from '@lucide/svelte/icons/wrap-text' + import ListTree from '@lucide/svelte/icons/list-tree' + import Braces from '@lucide/svelte/icons/braces' + import JsonTree from './JsonTree.svelte' import { formatJsonValue } from '$lib/row-inspector.js' import { escapeHtml, @@ -18,6 +21,8 @@ let { record, rowLabel = '', + /** Open a value in the Monaco JSON lightbox. @type {((value: unknown, label: string) => void) | null} */ + onopenjson = null, } = $props() let html = $state('') @@ -26,8 +31,27 @@ try { return localStorage.getItem(WRAP_KEY) === 'true' } catch { return false } } + // Tree = collapsible fold view (issue #2: wrap/unwrap json inside row data); + // Raw = the original highlighted text block. + const VIEW_KEY = 'stroke:row-expand-view' + function loadView() { + try { return localStorage.getItem(VIEW_KEY) === 'raw' ? 'raw' : 'tree' } catch { return 'tree' } + } + let copied = $state(false) let wordWrap = $state(loadWrap()) + let viewMode = $state(loadView()) + + /** @param {'tree' | 'raw'} mode */ + function setViewMode(mode) { + viewMode = mode + try { localStorage.setItem(VIEW_KEY, mode) } catch { /* ignore */ } + } + + /** Copy a single tree-node value (raw string as-is, everything else as JSON). */ + function copyNodeValue(/** @type {unknown} */ v) { + copyText(typeof v === 'string' ? v : formatJsonValue(v)) + } /** @type {ReturnType | null} */ let copiedTimer = null /** @type {HTMLDivElement | null} */ @@ -66,6 +90,9 @@ } $effect(() => { + // Tree mode never touches the stringified text — skip the format + + // highlight work entirely until the raw view is actually shown. + if (viewMode !== 'raw') return const source = displayText // Above the limit, skip per-token spans entirely — plain escaped text keeps // very large values cheap to render. @@ -221,42 +248,78 @@ Copy JSON {/if} - -
    - - - -
    - {#if !html} -

    Loading…

    - {:else} - + + Wrap + {/if} + + +
    + + +
    + + + {#if viewMode === 'tree'} +
    + +
    + {:else} + +
    + {#if !html} +

    Loading…

    + {:else} + + {/if} +
    + {/if}
    diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte index 912b68b0..67be204a 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -929,7 +929,7 @@ onviewstructure(tableName)}> - View data structure + View structure onviewddl(tableName)}> @@ -1125,7 +1125,7 @@ onviewstructure(table.name)}> - View data structure + View structure onviewddl(table.name)}> diff --git a/src/lib/components/SqlConsole.svelte b/src/lib/components/SqlConsole.svelte index 05b1bbac..67cafa81 100644 --- a/src/lib/components/SqlConsole.svelte +++ b/src/lib/components/SqlConsole.svelte @@ -172,12 +172,28 @@ */ let resultSort = $state(null) + // Sort text for object cells is computed once per value (row references are + // stable until a re-run) — stringifying inside the comparator would redo the + // work n·log n times and stall on large JSON cells. + /** @type {WeakMap} */ + const _sortTextCache = new WeakMap() + /** @param {unknown} v */ + function cellSortText(v) { + if (v === null || typeof v !== 'object') return String(v) + const hit = _sortTextCache.get(v) + if (hit !== undefined) return hit + let s + try { s = JSON.stringify(v) } catch { s = String(v) } + _sortTextCache.set(v, s) + return s + } + /** Compare two cell values: numbers numerically, everything else as text. */ function compareCellValues(a, b) { if (typeof a === 'number' && typeof b === 'number') return a - b if (typeof a === 'boolean' && typeof b === 'boolean') return a === b ? 0 : a ? 1 : -1 - const sa = typeof a === 'object' ? JSON.stringify(a) : String(a) - const sb = typeof b === 'object' ? JSON.stringify(b) : String(b) + const sa = cellSortText(a) + const sb = cellSortText(b) if (sa.trim() !== '' && sb.trim() !== '') { const na = Number(sa) const nb = Number(sb) diff --git a/src/lib/components/StatusBar.svelte b/src/lib/components/StatusBar.svelte index 47afaaba..7ec0c134 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -34,6 +34,7 @@ import Moon from '@lucide/svelte/icons/moon' import Lock from '@lucide/svelte/icons/lock' import LockOpen from '@lucide/svelte/icons/lock-open' + import { tick } from 'svelte' import { cn } from '$lib/utils.js' import { aiProfiles, activeProfileId, setActiveProfile } from '$lib/stores/ai-settings.js' import { toggleLightDark, isCurrentThemeDark } from '$lib/stores/settings.js' @@ -107,6 +108,113 @@ let dbList = $state(/** @type {{ key: string, label: string }[]} */ ([])) let dbLoading = $state(false) let dbSearch = $state('') + /** @type {HTMLInputElement | null} */ + let dbInputEl = $state(null) + /** @type {HTMLDivElement | null} */ + let dbListEl = $state(null) + /** Keyboard-highlighted row in the db list — driven from the filter input. */ + let dbHl = $state(0) + + // Reset the highlight whenever the visible list changes or the menu reopens. + $effect(() => { + dbSearch; dbOpen + dbHl = 0 + }) + + // Autofocus the filter input when the switcher opens (issue #3: "search box + // is not active by default"). Primary path is onOpenAutoFocus on the menu + // content, which pre-empts bits-ui's own focus management (it would focus + // the content container after our focus() and undo it). This effect is the + // fallback for open paths that skip that callback; the flag makes it fire + // once per open instead of stealing focus back while navigating. + let _dbFocusedThisOpen = false + $effect(() => { + if (!dbOpen) { _dbFocusedThisOpen = false; return } + if (_dbFocusedThisOpen) return + const el = dbInputEl + if (!el) return + _dbFocusedThisOpen = true + tick().then(() => el?.focus()) + }) + + /** Take over the menu's open-autofocus so the filter input starts focused. + * @param {Event} e */ + function focusDbInputOnOpen(e) { + e.preventDefault() + _dbFocusedThisOpen = true + tick().then(() => dbInputEl?.focus()) + } + + function scrollDbHlIntoView() { + tick().then(() => { + dbListEl?.querySelector('[data-hl]')?.scrollIntoView({ block: 'nearest' }) + }) + } + + /** + * Keyboard driving for the db list while focus stays in the filter input: + * arrows / Tab cycle the highlight, Enter switches, Escape closes. Handled + * (and stopped) here so the menu's own typeahead/focus logic can't fight it. + * @param {KeyboardEvent} e + */ + function onDbInputKeydown(e) { + if (e.key === 'Escape') { dbSearch = ''; dbOpen = false; return } + const n = Math.min(dbFiltered.length, 200) + if (n === 0) return + if (e.key === 'ArrowDown' || (e.key === 'Tab' && !e.shiftKey)) { + e.preventDefault(); e.stopPropagation() + dbHl = (dbHl + 1) % n + scrollDbHlIntoView() + } else if (e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey)) { + e.preventDefault(); e.stopPropagation() + dbHl = (dbHl - 1 + n) % n + scrollDbHlIntoView() + } else if (e.key === 'Enter') { + e.preventDefault(); e.stopPropagation() + const db = dbFiltered[dbHl] + if (db) switchDb(db) + } + } + + /** @param {EventTarget | null} t */ + function isEditableTarget(t) { + const el = /** @type {HTMLElement | null} */ (t) + if (!el || !(el instanceof HTMLElement)) return false + return ( + el.tagName === 'INPUT' || + el.tagName === 'TEXTAREA' || + el.isContentEditable || + !!el.closest('.monaco-editor') + ) + } + + /** + * Global shortcuts: ⌘D toggles the database switcher, ⌘⇧C the connection + * switcher. Skipped while typing (inputs, textareas, Monaco) so ⌘D keeps + * its editor meaning there. + * @param {KeyboardEvent} e + */ + function onWindowKeydown(e) { + const mod = e.metaKey || e.ctrlKey + if (!mod || e.altKey || !connection) return + const k = e.key.toLowerCase() + if (k === 'd' && !e.shiftKey) { + if (!canSwitchDb || isEditableTarget(e.target)) return + e.preventDefault() + connOpen = false + if (dbOpen) { + dbOpen = false + } else { + dbOpen = true + if (dbList.length === 0) void fetchDatabases() + } + } else if (k === 'c' && e.shiftKey) { + if (savedConnections.length === 0 || isEditableTarget(e.target)) return + e.preventDefault() + dbOpen = false + connOpen = !connOpen + } + } const currentDb = $derived( connection?.type === 'libsql' @@ -222,6 +330,8 @@ }) + + {#snippet proBadge()} PRO @@ -244,7 +354,7 @@ {#if connectionLost} @@ -319,7 +429,7 @@ > {#if connection?.type === 'sqlite'} @@ -334,20 +444,27 @@ {/if} - - {#if dbList.length > 5} -
    - { if (e.key === 'Escape') { dbSearch = ''; dbOpen = false } }} - /> -
    - {/if} + + +
    + +
    -
    +
    {#if dbLoading}
    @@ -358,11 +475,21 @@ {dbSearch ? 'No match' : 'No databases found'}
    {:else} - {#each dbFiltered.slice(0, 200) as db (db.key)} + + {#each dbFiltered.slice(0, 200) as db, i (db.key)} {@const isCurrent = db.key === currentDbKey} - switchDb(db)} + onpointerenter={() => (dbHl = i)} > {#if isD1} @@ -371,7 +498,7 @@ {/if} {db.label} {#if isCurrent}{/if} - + {/each} {/if}
    diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index bc2b20e9..5d0d3f64 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -4178,6 +4178,7 @@ let rowSearch = $state('') bind:applyScroll={tableApplyScroll} bind:vcolPanelOpen {rowSort} + searchQuery={rowSearch} onsortchange={(s) => void handleRowSortChange(s)} onhidecolumn={(colName) => { const next = new Set(hiddenColumns) diff --git a/src/lib/row-inspector.js b/src/lib/row-inspector.js index c8e7ce7f..a1c94a2c 100644 --- a/src/lib/row-inspector.js +++ b/src/lib/row-inspector.js @@ -1,3 +1,5 @@ +import { oversizeCellInfo, oversizeCellText } from './cell-value.js' + /** * @param {unknown} value * @returns {unknown} @@ -7,6 +9,17 @@ export function normalizeCellValue(value) { return value } +/** + * JSON.stringify replacer that renders backend oversize sentinels as their + * marker text, so copied rows / detail views show the truncation instead of + * the raw sentinel wrapper. + * @param {string} _key @param {unknown} value + */ +function oversizeReplacer(_key, value) { + const over = oversizeCellInfo(value) + return over ? oversizeCellText(over) : value +} + /** * @param {unknown} value */ @@ -16,8 +29,10 @@ export function formatNormalValue(value) { if (typeof v === 'string') return v if (typeof v === 'number' || typeof v === 'boolean') return String(v) if (typeof v === 'object') { + const over = oversizeCellInfo(v) + if (over) return oversizeCellText(over) try { - return JSON.stringify(v, null, 2) + return JSON.stringify(v, oversizeReplacer, 2) } catch { return String(v) } @@ -30,7 +45,7 @@ export function formatNormalValue(value) { */ export function formatJsonValue(value) { try { - return JSON.stringify(normalizeCellValue(value), null, 2) + return JSON.stringify(normalizeCellValue(value), oversizeReplacer, 2) } catch { return 'null' } diff --git a/src/lib/stores/license.js b/src/lib/stores/license.js index 3376361b..197cc886 100644 --- a/src/lib/stores/license.js +++ b/src/lib/stores/license.js @@ -34,7 +34,9 @@ export async function refreshLicenseStatus() { licenseStatus.set(/** @type {LicenseStatus} */ (status)) return status } catch (e) { - licenseStatus.set({ status: 'Error', message: String(e) }) + // Keep the last known status — flipping to Error here would drop Pro + // access (trial or paid) over a transient IPC failure. + console.error('[license] status check failed:', e) return null } } @@ -92,11 +94,15 @@ const PRO_FEATURES = new Set([ /** * Reactive: true when the user has Pro access (Valid license or active Trial). - * Null state (still loading from Tauri) also returns true to prevent UI flicker. + * + * Fails OPEN: only a definitive `TrialExpired` verdict locks Pro features. + * `null` (status still loading) and `Error` (data dir inaccessible, transient + * check failure) must never lock out an active-trial or paid user — an + * expired trial is the only state where we positively know there's no Pro. */ export const hasPro = derived( licenseStatus, - ($s) => $s === null || $s?.status === 'Valid' || $s?.status === 'Trial', + ($s) => $s?.status !== 'TrialExpired', ) /** From 01f3a38f5e4c1723deb891525d4d2d153ff641de Mon Sep 17 00:00:00 2001 From: broisnischal Date: Fri, 3 Jul 2026 13:32:25 +0545 Subject: [PATCH 5/5] chore(changelog): enrich 1.1.0 notes, add 1.2.0 changeset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fill the [1.1.0] section with the real release notes (pin tabs, Graphite, license page, SQL result sorting, pinned-column fix, reconnect perf, settings redesign, public-repo release pipeline) — it shipped with only the PR title because the changeset was still on an unmerged branch. - Retire .changeset/quality-pass-1-1.md (content now lives in [1.1.0]). - New .changeset/stability-json-search-1-2.md for the next release: blob freeze fix (#1), per-tab crash isolation, search highlighting, JSON tree (#2), db-switcher focus + shortcuts (#3), sidebar scroll hotfix, trial licensing fail-open, AI chat polish. Co-Authored-By: Claude Fable 5 --- .changeset/quality-pass-1-1.md | 40 ------------------------- .changeset/stability-json-search-1-2.md | 32 ++++++++++++++++++++ CHANGELOG.md | 39 +++++++++++++++++++++++- 3 files changed, 70 insertions(+), 41 deletions(-) delete mode 100644 .changeset/quality-pass-1-1.md create mode 100644 .changeset/stability-json-search-1-2.md diff --git a/.changeset/quality-pass-1-1.md b/.changeset/quality-pass-1-1.md deleted file mode 100644 index 0862ef2a..00000000 --- a/.changeset/quality-pass-1-1.md +++ /dev/null @@ -1,40 +0,0 @@ -### New Features - -#### Tabs -- Pin tabs: pinned tabs stay grouped at the front, show a pin badge, and survive "Close Others" / "Close All" - -#### Themes -- New **Graphite** theme, wired through the editor and diagram themes - -#### Licensing -- New in-app license page: "Activate Pro" now opens a dedicated tab with key entry, plan status, what Pro unlocks, and a purchase link — confetti included on activation -- License entry in Settings with a live plan badge (Pro / Trial / Free) - -#### SQL Editor -- Column sorting on query results — numbers sort numerically, NULLs last, and the sorted order carries into the JSON view, charts, and CSV/JSON exports - -### Bug Fixes - -#### Data Table -- Pinned columns now freeze flush to the left edge — no more empty gutter-wide gap after scrolling -- Query results grid is read-only: removed actions that can't apply to ad-hoc results (Filter/Exclude by value, Edit, Set NULL, Duplicate/Delete row, INSERT copy) -- Column stats no longer error with "Invalid identifier" on SQL editor results -- Sidebar rows no longer shift as row counts load in - -#### Window -- Double-clicking the titlebar now maximizes reliably (a duplicate handler was maximizing and instantly restoring) - -### Changes - -#### Performance -- Reconnect feels instant: the overlay drops as soon as the connection is live, and schemas/tables stream into the sidebar instead of blocking the screen -- Table row counts load lazily — the sidebar renders immediately with placeholders and exact counts fill in from a background pass; schema switches get the same speedup -- Faster Postgres connect: one fewer round trip per pooled connection, and the reachability check runs concurrently with the real handshake -- Sidebar stays virtualized while table rows are expanded (no more hitch when expanding columns on large schemas) -- AI streaming responses parse markdown at most once per frame instead of once per token - -#### Settings -- Settings dialog redesigned: grouped sections (Appearance / Behavior / More), clearer hierarchy, keyboard-shortcut chips - -#### Release pipeline -- Releases now publish to the public `stroke` repository — auto-update, Homebrew, and Scoop keep working with the private source repo diff --git a/.changeset/stability-json-search-1-2.md b/.changeset/stability-json-search-1-2.md new file mode 100644 index 00000000..34127a71 --- /dev/null +++ b/.changeset/stability-json-search-1-2.md @@ -0,0 +1,32 @@ +### New Features + +#### Data Table +- Search highlighting: the toolbar search now shows exactly where each match falls — matched text is highlighted inside the visible cells, across every column +- JSON tree view in expanded rows: fold and unfold any nested object or array with proper chevrons, type-colored values, and item/key counts — plus per-field **Copy value** and **Open in JSON viewer** actions that jump straight into the full Monaco viewer. A Tree/Raw toggle is remembered across sessions + +### Bug Fixes + +#### Stability +- Fixed the app freezing when opening tables with very large cells — multi-MB file buffers stored in `jsonb`/`text`/blob columns could lock up the whole app. Cells above 256 KB now load as a truncated preview with the real size shown; the grid, JSON viewer, copy, and export all mark the truncation clearly, and editing or filtering on a truncated cell is blocked so a preview can never be saved back over real data. Applies to PostgreSQL, MySQL, SQLite, and the built-in MCP server +- A crash inside one table view can no longer take down the whole app: rendering and interaction errors are contained to that tab, which shows a "Reload this view" card while every other tab, the sidebar, and the status bar keep working +- SQLite: large blobs no longer render as unbounded hex text (the worst case doubled the data size on screen) + +#### Database Switcher +- The search box is now always there and focused the moment the switcher opens — start typing immediately, with any number of databases +- New shortcuts: **⌘D** opens the database switcher, **⇧⌘C** the connection switcher; arrows, Tab, and Enter drive the list right from the search box + +#### Sidebar +- Restored smooth scrolling to the bottom of large table lists: removed the inline column-expansion rows that broke the virtualized list +- "View data structure" is now "View structure" + +#### Licensing +- Active trials always keep Pro access: only a definitive "trial expired" verdict locks Pro features — a transient license-check failure can no longer lock out trial or paid users + +### Changes + +#### AI Chat +- The conversation column now matches the composer width for a comfortable reading measure, with more breathing room between messages + +#### Performance +- Sorting SQL results by JSON columns no longer re-stringifies every cell on every comparison +- Copy and export of object cells reuse one serialization path and stay fast on large result sets diff --git a/CHANGELOG.md b/CHANGELOG.md index ebca87a6..95068693 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,45 @@ All notable changes to Stroke are listed here, newest first. ## [1.1.0] - 2026-07-03 +### New Features + +#### Tabs +- Pin tabs: pinned tabs stay grouped at the front, show a pin badge, and survive "Close Others" / "Close All" + +#### Themes +- New **Graphite** theme, wired through the editor and diagram themes + +#### Licensing +- New in-app license page: "Activate Pro" now opens a dedicated tab with key entry, plan status, what Pro unlocks, and a purchase link — confetti included on activation +- License entry in Settings with a live plan badge (Pro / Trial / Free) + +#### SQL Editor +- Column sorting on query results — numbers sort numerically, NULLs last, and the sorted order carries into the JSON view, charts, and CSV/JSON exports + +### Bug Fixes + +#### Data Table +- Pinned columns now freeze flush to the left edge — no more empty gutter-wide gap after scrolling +- Query results grid is read-only: removed actions that can't apply to ad-hoc results (Filter/Exclude by value, Edit, Set NULL, Duplicate/Delete row, INSERT copy) +- Column stats no longer error with "Invalid identifier" on SQL editor results +- Sidebar rows no longer shift as row counts load in + +#### Window +- Double-clicking the titlebar now maximizes reliably (a duplicate handler was maximizing and instantly restoring) + ### Changes -- feat:: working on improvement, better sql editor, a11y, new releases and more, (#15) + +#### Performance +- Reconnect feels instant: the overlay drops as soon as the connection is live, and schemas/tables stream into the sidebar instead of blocking the screen +- Table row counts load lazily — the sidebar renders immediately with placeholders and exact counts fill in from a background pass; schema switches get the same speedup +- Faster Postgres connect: one fewer round trip per pooled connection, and the reachability check runs concurrently with the real handshake +- AI streaming responses parse markdown at most once per frame instead of once per token + +#### Settings +- Settings dialog redesigned: grouped sections (Appearance / Behavior / More), clearer hierarchy, keyboard-shortcut chips + +#### Release pipeline +- Releases now publish to the public `stroke` repository — auto-update, Homebrew, and Scoop keep working with the private source repo ## [1.0.0] - 2026-07-01