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/.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 c5fd6eb1..95068693 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,17 +6,48 @@ All notable changes to Stroke are listed here, newest first. ## [1.1.0] - 2026-07-03 -### Changes -- feat:: working on improvement, better sql editor, a11y, new releases and more, (#15) +### New Features +#### Tabs +- Pin tabs: pinned tabs stay grouped at the front, show a pin badge, and survive "Close Others" / "Close All" -## [1.0.0] - 2026-07-01 +#### 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:: perf, ux, themes and optimizations (#13) +#### 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 -## [Unreleased] +#### 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 ### 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. 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 a310275f..67be204a 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 = @@ -1063,7 +929,7 @@ onviewstructure(tableName)}> - View data structure + View structure onviewddl(tableName)}> @@ -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} 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', ) /**