Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/stability-json-search-1-2.md
Original file line number Diff line number Diff line change
@@ -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
27 changes: 24 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.)
Expand Down
41 changes: 36 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 18 additions & 2 deletions src-tauri/src/db/mysql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Option<serde_json::Value>, _>(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::<Option<String>, _>(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::<Option<Vec<u8>>, _>(idx) {
return v.map(|b| json!(format!("[{} bytes]", b.len()))).unwrap_or(Value::Null);
Expand Down
24 changes: 24 additions & 0 deletions src-tauri/src/db/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Option<serde_json::Value>, _>(idx) {
return v.unwrap_or(Value::Null);
}
Expand All @@ -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) = <String as Decode<Postgres>>::decode(raw) {
return json!(text);
}
Expand Down
49 changes: 49 additions & 0 deletions src-tauri/src/db/sql_util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand Down
28 changes: 23 additions & 5 deletions src-tauri/src/db/sqlite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Option<String>, _>(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::<Option<Vec<u8>>, _>(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()
}
Expand Down
38 changes: 36 additions & 2 deletions src-tauri/src/mcp/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Option<String>, _>(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::<Option<Value>, _>(idx) {
return v.unwrap_or(Value::Null);
}
Expand All @@ -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) = <String as Decode<Postgres>>::decode(raw) {
return json!(text);
}
Expand Down
11 changes: 11 additions & 0 deletions src/lib/cell-expand.js
Original file line number Diff line number Diff line change
@@ -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
*/
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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 {
Expand Down
Loading
Loading