diff --git a/.changeset/array-editor-and-improvements.md b/.changeset/array-editor-and-improvements.md new file mode 100644 index 00000000..f9006154 --- /dev/null +++ b/.changeset/array-editor-and-improvements.md @@ -0,0 +1,19 @@ +### New Features + +#### Data Table +- **Array cell editor** — Postgres array columns (`text[]`, `int[]`, …) get a dedicated add / remove / reorder editor instead of being edited as raw JSON, and now display as `{a,b}` array literals. +- **Grid styles** — choose from six table grid styles in Settings: Lines, Bordered, Striped, Dotted, Dots, and Minimal. +- **DML preview** — review (and edit) the prettified SQL for a change before it's applied. + +#### Interface +- **Provider sign-in** — refreshed the connection screen and provider sign-in flow. +- **More keyboard shortcuts** — reopen closed tab, jump to tab 1–9, toggle the tab bar, and disconnect. +- **Auto-reconnect** — optionally reconnect to your last database on startup. + +### Bug Fixes + +#### Data +- **Array columns rendered as garbage** — Postgres array values were decoded from the binary wire format as lossy UTF-8 (□ boxes); they now decode into proper arrays. + +#### Security +- **Saved credentials not persisting** — the OS keychain integration now enables a real per-platform backend, so AI keys and OAuth tokens actually persist (previously a missing backend feature silently used a non-persistent in-memory store on some setups). diff --git a/package-lock.json b/package-lock.json index 63b53fc9..35414231 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "stroke", - "version": "1.7.0", + "version": "1.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "stroke", - "version": "1.7.0", + "version": "1.10.0", "dependencies": { "@dagrejs/dagre": "^3.0.0", "@fontsource-variable/geist": "^5.2.9", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ebbd12de..ab9f55dd 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,6 +8,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "ahash" version = "0.7.8" @@ -649,6 +660,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -865,6 +885,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.62" @@ -930,6 +959,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "combine" version = "4.6.7" @@ -1258,6 +1297,24 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes", + "block-padding", + "cbc", + "dbus", + "fastrand", + "hkdf", + "num", + "once_cell", + "sha2", + "zeroize", +] + [[package]] name = "der" version = "0.7.10" @@ -2624,6 +2681,16 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2813,7 +2880,13 @@ version = "3.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" dependencies = [ + "byteorder", + "dbus-secret-service", "log", + "secret-service", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", "zeroize", ] @@ -3162,6 +3235,19 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "ntapi" version = "0.4.3" @@ -3171,6 +3257,20 @@ dependencies = [ "winapi", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -3232,6 +3332,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -4667,6 +4778,25 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "hkdf", + "num", + "once_cell", + "rand 0.8.6", + "serde", + "sha2", + "zbus 4.4.0", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -5289,6 +5419,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "string_cache" version = "0.9.0" @@ -5737,7 +5873,7 @@ dependencies = [ "thiserror 2.0.18", "url", "windows 0.61.3", - "zbus", + "zbus 5.15.0", ] [[package]] @@ -7659,6 +7795,16 @@ dependencies = [ "rustix 1.1.4", ] +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "yoke" version = "0.8.2" @@ -7682,6 +7828,38 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-process", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "ordered-stream", + "rand 0.8.6", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + [[package]] name = "zbus" version = "5.15.0" @@ -7712,9 +7890,22 @@ dependencies = [ "uuid", "windows-sys 0.61.2", "winnow 1.0.3", - "zbus_macros", - "zbus_names", - "zvariant", + "zbus_macros 5.15.0", + "zbus_names 4.3.2", + "zvariant 5.11.0", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils 2.1.0", ] [[package]] @@ -7727,9 +7918,20 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.117", - "zbus_names", - "zvariant", - "zvariant_utils", + "zbus_names 4.3.2", + "zvariant 5.11.0", + "zvariant_utils 3.3.1", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant 4.2.0", ] [[package]] @@ -7740,7 +7942,7 @@ checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" dependencies = [ "serde", "winnow 1.0.3", - "zvariant", + "zvariant 5.11.0", ] [[package]] @@ -7789,6 +7991,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "zerotrie" @@ -7873,6 +8089,19 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive 4.2.0", +] + [[package]] name = "zvariant" version = "5.11.0" @@ -7883,8 +8112,21 @@ dependencies = [ "enumflags2", "serde", "winnow 1.0.3", - "zvariant_derive", - "zvariant_utils", + "zvariant_derive 5.11.0", + "zvariant_utils 3.3.1", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils 2.1.0", ] [[package]] @@ -7897,7 +8139,18 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.117", - "zvariant_utils", + "zvariant_utils 3.3.1", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 58e88af3..dc2788a6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -45,8 +45,12 @@ base64 = "0.22" hex = "0.4" getrandom = "0.2" # Secure credential storage in the OS keychain (macOS Keychain, Windows -# Credential Manager, Linux Secret Service via pure-Rust zbus — no libdbus). -keyring = "3" +# Credential Manager, Linux Secret Service via D-Bus). A backend feature MUST be +# enabled per platform — without one, keyring v3 silently uses an in-memory mock +# that never persists (writes return Ok but read back empty). The sync +# secret-service backend is used on Linux (blocking, so it can't dead-nest inside +# the async Tauri command runtime); crypto-rust encrypts the D-Bus session. +keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service", "crypto-rust"] } duckdb = { version = "1.3.2", features = ["bundled"] } tiberius = { version = "0.12.3", default-features = false, features = ["rustls", "tds73", "chrono"] } tokio-util = { version = "0.7.18", features = ["compat"] } diff --git a/src-tauri/src/db/query.rs b/src-tauri/src/db/query.rs index a5a1d4ca..80db4720 100644 --- a/src-tauri/src/db/query.rs +++ b/src-tauri/src/db/query.rs @@ -225,6 +225,60 @@ fn cell_to_json(row: &sqlx::postgres::PgRow, idx: usize) -> Value { } } + // Array types (varchar[]/text[]/int[]/…). Without this they fall through to the + // raw-bytes branch below, which reinterprets Postgres's *binary array wire format* + // (dimension/length header + element data) as lossy UTF-8 → garbage □ boxes. + // Decode into a Vec and return a JSON array; the frontend renders it {a,b}-style. + // sqlx validates the element PgType on each try_get, so only the matching arm + // returns — numeric arms are tried before the String arm so text[] isn't misread. + if type_name.ends_with("[]") { + macro_rules! try_arr { + ($t:ty) => { + if let Ok(v) = row.try_get::>>, _>(idx) { + return match v { + Some(a) => json!(a), + None => Value::Null, + }; + } + }; + } + // Types that JSON can't hold natively (Decimal/dates/Uuid) → stringify each + // element so it matches the scalar `to_string()` formatting above. + macro_rules! try_arr_str { + ($t:ty) => { + if let Ok(v) = row.try_get::>>, _>(idx) { + return match v { + Some(a) => json!(a + .into_iter() + .map(|x| x.map(|y| y.to_string())) + .collect::>()), + None => Value::Null, + }; + } + }; + } + try_arr!(bool); + try_arr!(i16); + try_arr!(i32); + try_arr!(i64); + try_arr!(f32); + try_arr!(f64); + try_arr_str!(Decimal); + try_arr_str!(DateTime); + try_arr_str!(NaiveDateTime); + try_arr_str!(NaiveDate); + try_arr_str!(NaiveTime); + try_arr_str!(Uuid); + // Text-like arrays (varchar/text/char/name), tried last. + if let Ok(v) = row.try_get::>>, _>(idx) { + return match v { + Some(a) => json!(a), + None => Value::Null, + }; + } + // Unknown element type (e.g. enum[]) — fall through to the raw branch. + } + // Use raw wire-protocol bytes for all remaining types (TEXT, VARCHAR, enums, domains…). // Skipping try_get::() avoids sqlx's runtime pg_catalog introspection for // custom/enum types, which would fire a `SELECT enumlabel FROM pg_enum WHERE …` query @@ -404,6 +458,28 @@ struct PgColumnMeta { } impl PgColumnMeta { + /// If this column is a Postgres array, return the quoted array type to cast a + /// literal to (e.g. `"pg_catalog"."_varchar"`), else None. Array types are + /// named with a leading underscore in pg_type; information_schema reports the + /// data_type as the literal "ARRAY" with the array name in udt_name. + fn array_cast_ref(&self) -> Result, String> { + let is_array = self.data_type.starts_with('_') || self.data_type.eq_ignore_ascii_case("ARRAY"); + if !is_array { + return Ok(None); + } + // Prefer udt_name when it's a real array name; fall back to data_type. + let arr_name = self + .udt_name + .as_deref() + .filter(|n| n.starts_with('_')) + .unwrap_or(self.data_type.as_str()); + if !arr_name.starts_with('_') { + return Ok(None); // couldn't resolve the concrete array type + } + let udt_schema = self.udt_schema.as_deref().unwrap_or("pg_catalog"); + Ok(Some(pg_cast_type_ref(udt_schema, arr_name)?)) + } + fn set_assignment_sql(&self, column: &str) -> Result { validate_ident(column)?; if self.data_type.eq_ignore_ascii_case("USER-DEFINED") { @@ -415,6 +491,14 @@ impl PgColumnMeta { let type_ref = pg_cast_type_ref(udt_schema, udt_name)?; return Ok(format!(r#""{column}" = $1::{type_ref}"#)); } + // Array columns. PostgreSQL names every array type with a leading + // underscore (_varchar, _int4, _text, …) — that's what pg_type.typname + // returns here; information_schema instead reports the literal "ARRAY". + // The editor sends a Postgres array literal ({"a","b"}); cast it to the + // real array type so PostgreSQL parses it instead of rejecting it as text. + if let Some(arr) = self.array_cast_ref()? { + return Ok(format!(r#""{column}" = $1::{arr}"#)); + } // json/jsonb bindings arrive as text strings; an explicit cast tells // PostgreSQL to interpret the parameter as json/jsonb instead of text. let norm = normalize_pg_type(&self.data_type); @@ -437,6 +521,10 @@ impl PgColumnMeta { let type_ref = pg_cast_type_ref(udt_schema, udt_name)?; return Ok(format!("${bind_idx}::{type_ref}")); } + // Array columns — cast the array-literal string to the real array type. + if let Some(arr) = self.array_cast_ref()? { + return Ok(format!("${bind_idx}::{arr}")); + } let norm = normalize_pg_type(&self.data_type); if norm == "json" || norm == "jsonb" { return Ok(format!("${bind_idx}::{norm}")); diff --git a/src-tauri/src/secrets.rs b/src-tauri/src/secrets.rs index d76c5bc8..ab1ca909 100644 --- a/src-tauri/src/secrets.rs +++ b/src-tauri/src/secrets.rs @@ -1,6 +1,17 @@ use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; use tauri::Manager; +// Session cache: once loaded (or written) it is authoritative for the process, +// so a value is always readable immediately after it is stored — even if the OS +// keychain read-back is flaky, unavailable, or (mis)configured as the mock store. +// Durable persistence still goes to the keychain/file below; this only guarantees +// read-after-write within a run. +static CACHE: OnceLock>>> = OnceLock::new(); +fn cache() -> &'static Mutex>> { + CACHE.get_or_init(|| Mutex::new(None)) +} + // All secrets (AI keys, provider OAuth tokens, Cloudflare tokens) live in a // single JSON blob stored in the OS keychain — macOS Keychain, Windows // Credential Manager, or Linux Secret Service. A legacy plaintext file @@ -31,37 +42,65 @@ fn write_keychain(map: &HashMap) -> Result<(), String> { entry.set_password(&json).map_err(|e| e.to_string()) } -pub(crate) fn read_all(app: &tauri::AppHandle) -> HashMap { - // 1) Preferred: the OS keychain (encrypted at rest). +/// Read the keychain back and confirm it holds exactly what we intended to store. +/// This is what makes a silently-non-persisting backend (e.g. keyring's mock +/// store) detectable: a write can "succeed" yet not round-trip, in which case we +/// must keep the plaintext file rather than delete it and lose the data. +fn keychain_holds(expected: &HashMap) -> bool { + keychain_entry() + .and_then(|e| e.get_password().ok()) + .map(|json| parse_map(&json) == *expected) + .unwrap_or(false) +} + +/// Load the durable store: keychain first (encrypted at rest), then the legacy +/// plaintext file, migrating the file into the keychain when that round-trips. +fn load_durable(app: &tauri::AppHandle) -> HashMap { if let Some(entry) = keychain_entry() { - match entry.get_password() { - Ok(json) => return parse_map(&json), - // No keychain entry yet → fall through to migrate any legacy file. - Err(keyring::Error::NoEntry) => {} - // Keychain unavailable this session → fall through to the file store. - Err(_) => {} + if let Ok(json) = entry.get_password() { + let map = parse_map(&json); + if !map.is_empty() { + return map; + } } } - // 2) Legacy plaintext file: read it, migrate into the keychain, remove the - // plaintext copy. Also the fallback store when the keychain is absent. + // Legacy plaintext file: the fallback store, and the pre-keychain format. let path = legacy_path(app); let map = std::fs::read_to_string(&path) .ok() .map(|s| parse_map(&s)) .unwrap_or_default(); - if !map.is_empty() && write_keychain(&map).is_ok() { + // Migrate into the keychain only if it verifiably persisted; otherwise leave + // the file exactly where it is. + if !map.is_empty() && write_keychain(&map).is_ok() && keychain_holds(&map) { let _ = std::fs::remove_file(&path); } map } +pub(crate) fn read_all(app: &tauri::AppHandle) -> HashMap { + let mut guard = cache().lock().unwrap_or_else(|e| e.into_inner()); + if let Some(map) = guard.as_ref() { + return map.clone(); + } + let map = load_durable(app); + *guard = Some(map.clone()); + map +} + pub(crate) fn write_all(app: &tauri::AppHandle, map: &HashMap) -> Result<(), String> { - // Prefer the keychain; on success make sure no plaintext copy lingers. - if write_keychain(map).is_ok() { + // Session cache is authoritative first, so reads right after this always see + // the new value regardless of what the durable backend does. + *cache().lock().unwrap_or_else(|e| e.into_inner()) = Some(map.clone()); + + // Prefer the keychain, but only trust it — and drop the plaintext copy — once + // a read-back proves the data actually persisted. + if write_keychain(map).is_ok() && keychain_holds(map) { let _ = std::fs::remove_file(legacy_path(app)); return Ok(()); } - // Keychain unavailable — fall back to the file so secrets aren't lost. + // Keychain missing, mock, or not round-tripping — persist to the file so + // secrets survive a restart. let path = legacy_path(app); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; diff --git a/src/app.css b/src/app.css index ff915d6c..a504eb9f 100644 --- a/src/app.css +++ b/src/app.css @@ -10,6 +10,12 @@ /* Non-theme tokens only — color palettes live on html[data-theme] in app-themes.css */ :root { + /* Tell the engine to render native controls (the { if (e.key === 'Enter') { e.preventDefault(); addItem() } }} + /> + {/if} +
+ + +
+ + {/each} + + {/if} + + + +
+ + {#if items.length > 0} + + {/if} +
+ + +
+
+ + +{/if} diff --git a/src/lib/components/CloudflareLogin.svelte b/src/lib/components/CloudflareLogin.svelte index 61b9b920..a452dbb3 100644 --- a/src/lib/components/CloudflareLogin.svelte +++ b/src/lib/components/CloudflareLogin.svelte @@ -6,6 +6,7 @@ import AlertTriangle from '@lucide/svelte/icons/alert-triangle' import ChevronDown from '@lucide/svelte/icons/chevron-down' import RefreshCw from '@lucide/svelte/icons/refresh-cw' + import ArrowRight from '@lucide/svelte/icons/arrow-right' import Search from '@lucide/svelte/icons/search' import DbIcon from './DbIcon.svelte' import { cfStartOAuth, cfOAuthStatus, cfLogout } from '$lib/cloudflare.js' @@ -43,6 +44,21 @@ : databases, ) + /** Turn a raw backend error into a calm title + one-line explanation. */ + function friendlyError(msg) { + const m = String(msg ?? '') + if (/not signed in|no.*token|unauthor/i.test(m)) + return { title: 'Session expired', detail: 'Your Cloudflare sign-in is no longer valid. Sign in again to continue.' } + if (/timed out/i.test(m)) + return { title: 'Authorization timed out', detail: 'The browser sign-in took too long. Start again when you are ready.' } + if (/cancel|denied/i.test(m)) + return { title: 'Authorization not completed', detail: 'The browser closed before authorizing. Try again to connect.' } + if (/port .*in use|bind any callback/i.test(m)) + return { title: 'Callback port in use', detail: m } + return { title: 'Something went wrong', detail: m || 'Please try again.' } + } + const shownError = $derived(friendlyError(errorMsg)) + onMount(async () => { const status = await cfOAuthStatus() if (status.connected) { @@ -131,39 +147,36 @@
{#if phase === 'idle'} - -
-
-
- -
-
-

Connect with Cloudflare

-

Authorize Stroke to browse your D1 databases.

-
-
-
- -

Same OAuth flow as the Wrangler CLI · secure PKCE.

-
+ +
+ +

Opens your browser to authorize · same PKCE flow as Wrangler

{:else if phase === 'authorizing'} -
-
- +
+
+
+ + +
+
+

Waiting for Cloudflare…

+

+ Finish authorizing in the browser tab, then come back here. +

+
-
-

Waiting for Cloudflare…

-

Authorize Stroke in the browser tab, then return here.

+
+
@@ -284,17 +297,45 @@ {/if} {:else if phase === 'error'} -
-
- - {errorMsg || 'Authorization failed.'} +
+
+
+ +
+
+

{shownError.title}

+

{shownError.detail}

+
+ > + Try again +
{/if}
+ + diff --git a/src/lib/components/ConnectionModal.svelte b/src/lib/components/ConnectionModal.svelte index 3f07e673..35ae9a74 100644 --- a/src/lib/components/ConnectionModal.svelte +++ b/src/lib/components/ConnectionModal.svelte @@ -98,7 +98,15 @@ // Provider (sign-in) ids are surfaced as cards on their own tab, so keep them // out of the manual Type dropdown. const PROVIDER_IDS = ['neon', 'supabase', 'planetscale', 'prisma'] - const manualDriverItems = driverItems.filter((d) => !PROVIDER_IDS.includes(d.value)) + // Cloudflare D1 signs in like a hosting provider (OAuth), so it belongs on the + // Provider tab too — but it drives the CloudflareLogin flow, not ProviderConnect. + const PROVIDER_CARDS = [ + ...PROVIDERS, + { id: 'd1', name: 'Cloudflare D1', engine: 'sqlite', blurb: 'Edge SQLite — sign in with Cloudflare' }, + ] + // Every sign-in provider (incl. D1) is reached from the Provider tab, so keep + // them all out of the manual Type dropdown. + const manualDriverItems = driverItems.filter((d) => ![...PROVIDER_IDS, 'd1'].includes(d.value)) // Subtle per-engine icon tint (color-500/600), theme-aware via Tailwind tokens. const ENGINE_TINT = { @@ -203,6 +211,8 @@ const activeDriver = $derived(ALL_DRIVERS.find(d => d.id === dbType) ?? ALL_DRIVERS[0]) const isProvider = $derived(PROVIDER_IDS.includes(dbType)) + // D1 shares the Provider tab (via CloudflareLogin) but is not a ProviderConnect id. + const isProviderTab = $derived(isProvider || dbType === 'd1') // Engines that expose the "Connection string | Manual fields" toggle. const URI_TOGGLE_ENGINES = ['postgres', 'cockroachdb', 'mysql', 'mariadb'] @@ -222,7 +232,7 @@ function setEntryMode(mode) { if (entryMode === mode) return entryMode = mode - if (mode === 'manual' && isProvider) switchDriver('postgres') + if (mode === 'manual' && isProviderTab) switchDriver('postgres') } function sshPayload() { @@ -281,7 +291,7 @@ sshEnabled = false; sshHost = ''; sshPort = '22'; sshUsername = ''; sshKeyPath = '' readOnly = false } - entryMode = PROVIDER_IDS.includes(dbType) ? 'provider' : 'manual' + entryMode = (PROVIDER_IDS.includes(dbType) || dbType === 'd1') ? 'provider' : 'manual' fieldMode = 'fields' advancedOpen = false flashedFields = new Set() @@ -322,6 +332,31 @@ }) } + /** + * Cloudflare D1 picked from the Provider tab: fill the D1 fields and connect + * immediately, mirroring the one-click flow of the other hosting providers. + * @param {{accountId: string, databaseId: string, databaseName: string, token: string}} info + */ + async function connectD1Selection(info) { + error = '' + accountId = info.accountId + databaseId = info.databaseId + apiToken = info.token + if (!name || name === 'Cloudflare D1') name = info.databaseName + // Reuse an existing saved entry for this exact D1 database instead of piling + // up duplicates — connectWith upserts it. + const existing = saved.find((s) => s.type === 'd1' && s.databaseId === info.databaseId) + await connectWith({ + id: existing?.id ?? newConnectionId(), + type: 'd1', + name: info.databaseName, + accountId: info.accountId, + databaseId: info.databaseId, + apiToken: info.token, + readOnly: readOnly || undefined, + }) + } + function switchDriver(id) { dbType = id if (id === 'postgres') port = '5432' @@ -1031,42 +1066,6 @@ class={cn(inp, 'font-mono text-[11px]')} />
- - {:else if dbType === 'd1'} - - { - accountId = info.accountId - databaseId = info.databaseId - apiToken = info.token - if (!name || name === 'Cloudflare D1') name = info.databaseName - }} - ondisconnect={() => { accountId = ''; databaseId = ''; apiToken = '' }} - /> - -
- - ↓ Manual setup - - -
-
-
- - -
-
- - -
-
-
- - -
-
-
- {:else if dbType === 'clickhouse'} @@ -1220,7 +1219,7 @@
- {#each PROVIDERS as p, i (p.id)} + {#each PROVIDER_CARDS as p, i (p.id)} {@const active = dbType === p.id}
+ {:else if dbType === 'd1'} +
+ {#key dbType} + { accountId = ''; databaseId = ''; apiToken = '' }} + /> + {/key} + +
+ + + Enter account & token manually + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
{@render advancedSection()}
{/if} diff --git a/src/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte index 130a7884..982621f8 100644 --- a/src/lib/components/DataTable.svelte +++ b/src/lib/components/DataTable.svelte @@ -5,7 +5,7 @@ import { zoomState } from '$lib/stores/canvas-zoom.svelte.js' // Zoom is driven through the app-level settings so the canvas scales together // with the rest of the UI (applySettings mirrors the app zoom into zoomState). - import { increaseZoom, decreaseZoom, resetZoom, appPreviewDml } from '$lib/stores/settings.js' + import { increaseZoom, decreaseZoom, resetZoom, appPreviewDml, appTableStyle, TABLE_STYLES, normalizeTableStyle } from '$lib/stores/settings.js' import { toast } from "$lib/components/ui/sonner/toast.svelte.js"; import { Checkbox } from "$lib/components/ui/checkbox/index.js"; import * as ContextMenu from "$lib/components/ui/context-menu/index.js"; @@ -98,6 +98,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; import Sparkles from "@lucide/svelte/icons/sparkles"; import MediaLightbox from "./MediaLightbox.svelte"; import RowExpandViewer from "./RowExpandViewer.svelte"; + import ArrayCellEditor from "./ArrayCellEditor.svelte"; import FkSubviewPanel from "./FkSubviewPanel.svelte"; // JsonCellLightbox (Monaco-based) is imported lazily at its render site below. import CellQuickLook from "./CellQuickLook.svelte"; @@ -380,6 +381,14 @@ import FilterX from "@lucide/svelte/icons/filter-x"; let contextRowIdx = $state(0); let contextColIdx = $state(0); let contextMenuOpen = $state(false); + + // Array cell editor (Prisma-style add/remove for SQL array columns). + let arrayEditorOpen = $state(false); + let arrayEditorRow = $state(0); + let arrayEditorCol = $state(0); + let arrayEditorColName = $state(""); + let arrayEditorType = $state(""); + let arrayEditorValue = $state(/** @type {any[]} */ ([])); let pendingContextMenu = $state(false); /** Block item activation from the right-click pointerup that opened the menu */ let suppressMenuSelect = $state(false); @@ -624,6 +633,21 @@ import FilterX from "@lucide/svelte/icons/filter-x"; ); // Truncated cells only hold a preview — filtering on it would build wrong SQL. const menuCellOversize = $derived(!!oversizeCellInfo(rows[contextRowIdx]?.[contextColIdx])); + // SQL array column? (value already decoded to a JS array, or type ends with []). + const menuColType = $derived( + String(columns[contextColIdx]?.dataType ?? columns[contextColIdx]?.data_type ?? _colCache[contextColIdx]?.colType ?? ""), + ); + // The dedicated array editor writes a Postgres array literal ({a,b}) cast to the + // real array type — that's native to PostgreSQL & CockroachDB (Neon/Supabase/ + // Prisma all speak the pg wire protocol, so they route through the same path). + // Other engines either have no native arrays (MySQL/SQLite/MSSQL) or use a + // different literal (ClickHouse/DuckDB [..]), so restrict the editor to pg-family + // to avoid producing a write the backend can't apply. + const isPgArrayDialect = $derived(dialect === "postgres" || dialect === "cockroachdb"); + const menuCellIsArray = $derived( + isPgArrayDialect && + (Array.isArray(rows[contextRowIdx]?.[contextColIdx]) || /\[\]\s*$/.test(menuColType)), + ); // Extension-provided transforms applicable to the right-clicked cell. const menuTransforms = $derived.by(() => { void $pluginState; @@ -652,6 +676,9 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // Oversize sentinels carry a preview instead of the real (multi-MB) // value — render the truncation marker + head, not the sentinel wrapper. const over = oversizeCellInfo(value); + // JSON/JSONB objects and arrays render as JSON here. SQL *array columns* get + // the pgAdmin {a,b} form instead, but that decision needs the column type, so + // it lives in drawCell (arrayDisplay) — a jsonb array must stay ["a","b"]. const s = over ? oversizeCellText(over) : JSON.stringify(value); _formatCache.set(value, s); return s; @@ -659,6 +686,41 @@ import FilterX from "@lucide/svelte/icons/filter-x"; return String(value); } + // Render a JS array as a Postgres array literal for display: {a,b}, {} for + // empty, NULL for null elements. Elements are quoted only when they contain a + // delimiter/quote/brace/whitespace or would be ambiguous — matching pgAdmin. + function pgArrayElem(el) { + if (el === null || el === undefined) return "NULL"; + // Nested arrays (multi-dim) recurse; objects (e.g. json[]) fall back to JSON. + if (Array.isArray(el)) return pgArrayText(el); + if (typeof el === "object") return JSON.stringify(el); + const s = String(el); + if (s === "" || /[",{}\\\s]/.test(s) || /^null$/i.test(s)) { + return '"' + s.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"'; + } + return s; + } + function pgArrayText(arr) { + return "{" + arr.map(pgArrayElem).join(",") + "}"; + } + // Cached pgAdmin-style display for SQL *array columns* only (drawCell passes the + // value after confirming the column type ends with []). Cached per value object + // so the scroll hot path never rebuilds the string. jsonb arrays never reach + // this — they render as ["a","b"] via formatCell. + /** @type {WeakMap} */ + const _arrayDisplayCache = new WeakMap(); + function arrayDisplay(arr) { + const hit = _arrayDisplayCache.get(arr); + if (hit !== undefined) return hit; + const s = pgArrayText(arr); + _arrayDisplayCache.set(arr, s); + return s; + } + /** True when a column's SQL type is an array (ends with []). */ + function isSqlArrayType(colType) { + return /\[\]\s*$/.test(colType ?? ""); + } + /** Truncated version for DOM rendering — keeps long values out of the render tree */ function displayCell(value) { const s = formatCell(value); @@ -673,6 +735,12 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // cell text, badges, tints, and the header annotator strip. $effect(() => { void $pluginState; void _colStats; scheduleDraw(); }); + // Resolved canvas-grid style preset (Settings → Appearance). Read once per frame + // by draw() and passed into the row context, so it never adds per-cell reactivity. + const _tableStyle = $derived(TABLE_STYLES[normalizeTableStyle($appTableStyle)]); + // Repaint the grid the moment the user switches preset. + $effect(() => { void $appTableStyle; scheduleDraw(); }); + // ── Search-match highlighting ────────────────────────────────────────────── // The toolbar search filters rows server-side (ILIKE, case-insensitive); // this paints where each match falls inside the visible cell text. Matching @@ -1549,6 +1617,30 @@ import FilterX from "@lucide/svelte/icons/filter-x"; futureEdits = []; } + /** Open the dedicated array editor for a cell (from the context menu). */ + function openArrayEditor(rowIdx, colIdx) { + const col = columns[colIdx]; + if (!col) return; + const v = effectiveCellValue(rowIdx, colIdx); + arrayEditorRow = rowIdx; + arrayEditorCol = colIdx; + arrayEditorColName = col.name ?? "array"; + arrayEditorType = String(col.dataType ?? col.data_type ?? _colCache[colIdx]?.colType ?? "").replace(/\[\]\s*$/, ""); + arrayEditorValue = Array.isArray(v) ? v : []; + arrayEditorOpen = true; + } + + /** Save the edited array — stage a Postgres array literal (backend casts it). */ + function commitArrayEditor(next) { + const rowIdx = arrayEditorRow, colIdx = arrayEditorCol; + if (!canEditColumn(colIdx)) return; + const prevValue = effectiveCellValue(rowIdx, colIdx); + const literal = pgArrayText(next); // {a,b} — quoting/escaping handled + stageEdit(rowIdx, colIdx, literal); + pastEdits = [...pastEdits.slice(-49), { rowIdx, colIdx, oldValue: prevValue, newValue: literal }]; + futureEdits = []; + } + /** Run an extension transform on a cell and copy the result to the clipboard. */ async function runCellTransform(rowIdx, colIdx, transform) { const value = effectiveCellValue(rowIdx, colIdx); @@ -1834,9 +1926,11 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // Width adapts to the longest label (7px/char estimate + padding), clamped 110–180px const VIRTUAL_COL_W = $derived.by(() => { if (virtualColWidthOverride !== null) return Math.round(virtualColWidthOverride * canvasZoom) - if (!virtualRelCols.length) return Math.round(300 * canvasZoom) + if (!virtualRelCols.length) return Math.round(200 * canvasZoom) + // Fit the badge with comfortable side gaps instead of a fixed ~300px slab, so + // the centered pill reads as intentional rather than floating in dead space. const maxChars = Math.max(...virtualRelCols.map(v => v.label.length)) - const base = Math.min(380, Math.max(300, maxChars * 10 + 60)) + const base = Math.min(260, Math.max(150, maxChars * 8 + 44)) return Math.round(base * canvasZoom) }) const virtualRelCols = $derived.by(() => { @@ -2407,10 +2501,18 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // scroll event to cover momentum/inertia, then stops to save GPU time. let _scrollLoopId = 0 let _scrollLoopDeadline = 0 + // Last position the loop actually painted — lets it skip identical frames during + // the momentum tail / step scrolling instead of re-running a full redraw for a + // frame where nothing moved. Content changes (hover/edits) go through + // scheduleDraw(), not this loop, so skipping unchanged-position frames is safe. + let _loopLastTop = -1 + let _loopLastLeft = -1 function startScrollLoop() { _scrollLoopDeadline = performance.now() + 200 if (_scrollLoopId) return + _loopLastTop = -1 + _loopLastLeft = -1 function loop() { const el = tableContainer if (!el || !_ctx || _fatalError || performance.now() > _scrollLoopDeadline) { @@ -2421,14 +2523,20 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // integer left edge, so drawing content at a fractional scrollLeft puts text // and gridlines on sub-pixel x — WebKit then re-antialiases them every frame, // which reads as horizontal "vibration". Integer offsets render stably. - _scrollTop = Math.round(el.scrollTop) - _scrollLeft = Math.round(el.scrollLeft) - try { - draw() - } catch (err) { - reportFatal(err) - _scrollLoopId = 0 - return + const st = Math.round(el.scrollTop) + const sl = Math.round(el.scrollLeft) + if (st !== _loopLastTop || sl !== _loopLastLeft) { + _scrollTop = st + _scrollLeft = sl + _loopLastTop = st + _loopLastLeft = sl + try { + draw() + } catch (err) { + reportFatal(err) + _scrollLoopId = 0 + return + } } _scrollLoopId = requestAnimationFrame(loop) } @@ -2461,6 +2569,9 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // ping-pong between separate effects. /** @type {CanvasRenderingContext2D | null} */ let _ctx = null + // Reused scratch buffer for per-frame vertical grid separators (see draw()) — a + // module-lifetime array so the scroll hot path does zero allocation for it. + const _vSepsBuf = /** @type {number[]} */ ([]) /** @type {ReturnType | null} */ let _readColor = null /** Canvas font strings measured from the DOM so they exactly match the app's @@ -2871,9 +2982,46 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // Frame-constant draw context — built ONCE per frame and shared by every // visible row, instead of a fresh ~20-field literal per row (that churned // thousands of short-lived objects/sec during scroll → GC jank). + // Grid style preset + its dot size (integer, DPR-agnostic — canvas is already + // scaled by canvasZoom), resolved once per frame. + const tableStyle = _tableStyle + const dotSize = Math.max(2, Math.round(2 * canvasZoom)) + + // Vertical separator x-positions are identical for every row (they depend only + // on columns + scroll, not the row), so collect them ONCE per frame here rather + // than re-deriving them inside every drawBodyRow. Keeps the per-row grid pass to + // a single loop over this array — flat regardless of how many million rows exist. + // Reuse one buffer across frames so the scroll hot path allocates nothing here. + const vSeps = _vSepsBuf + vSeps.length = 0 + for (const col of geom.cols) { + if (col.pinned) continue + const dx = col.contentX - _scrollLeft + if (dx >= W) break + if (dx + col.w <= 0) continue + const ex = dx + col.w - 0.5 + if (ex <= frozenW) continue + vSeps.push(ex) + } + for (let vi = 0; vi < virtualRelCols.length; vi++) { + const ex = geom.totalWidth + vexprTotalW + vi * VIRTUAL_COL_W - _scrollLeft + VIRTUAL_COL_W - 0.5 + if (ex <= frozenW || ex >= W) continue + vSeps.push(ex) + } + for (const col of geom.cols) { + if (!col.pinned) continue + const ex = colDrawnX(col, geom, _scrollLeft) + col.w - 0.5 + if (ex <= 0 || ex >= W) continue + vSeps.push(ex) + } + if (gutterWidth > 0) { + const gex = (gutterWidth - _scrollLeft) - 0.5 + if (gex > 0 && gex < W) vSeps.push(gex) + } + const bodyC = { - cFg, cText, cMuted, cGrid, cMutedBg, cRing, cAccent, cPanel, usedW, navName, - AMBER, BLUE_FG, RED, cPrimary, frozenW, + cFg, cText, cMuted, cGrid, cBorder, cMutedBg, cRing, cAccent, cPanel, usedW, navName, + AMBER, BLUE_FG, RED, cPrimary, frozenW, tableStyle, dotSize, vSeps, rangeColNames, rangeFirstCol, rangeLastCol, rangeR0, rangeR1, } @@ -2917,6 +3065,11 @@ import FilterX from "@lucide/svelte/icons/filter-x"; } else if (hoveredRow === idx) { ctx.fillStyle = withAlpha(c.cMutedBg, 0.18) ctx.fillRect(0, ry, c.usedW, rh) + } else if (c.tableStyle.zebra && (idx & 1)) { + // Zebra striping — a soft tint on odd rows. Below every interactive state + // above so selection/hover/focus always win; O(1), no per-row allocation. + ctx.fillStyle = withAlpha(c.cMutedBg, 0.07) + ctx.fillRect(0, ry, c.usedW, rh) } // Non-pinned cells. geom.cols is ordered by ascending contentX, so once a @@ -2998,17 +3151,19 @@ import FilterX from "@lucide/svelte/icons/filter-x"; const isVHov = hoveredRow === idx && hoveredColName === `__vrel__${vi}` if (!_fonts) return - // Badge: compact tag style — no border at rest, border on hover/active + // Badge: compact tag style — no border at rest, border on hover/active. const badgeFontPx = Math.max(10, _fonts.cellPx - 1) - const bPadX = 8 - const bH = Math.round(badgeFontPx * 1.55) - const bR = 3 + const bPadX = 10 + const bH = Math.round(badgeFontPx * 1.7) + const bR = Math.round(bH / 2) // pill — fully rounded, reads as a chip ctx.font = `500 ${badgeFontPx}px ${_fonts.family}` - const maxLabelW = VIRTUAL_COL_W - 24 + // Consistent side gutters so the pill is centered with breathing room. + const gutter = Math.round(14 * canvasZoom) + const maxLabelW = VIRTUAL_COL_W - gutter * 2 - bPadX * 2 const labelTxt = truncText(ctx, vc.label, maxLabelW) const textW = textWidth(ctx, labelTxt) - const bW = Math.min(textW + bPadX * 2, VIRTUAL_COL_W - 16) + const bW = Math.min(textW + bPadX * 2, VIRTUAL_COL_W - gutter * 2) const bX = cellX + (VIRTUAL_COL_W - bW) / 2 const bY = ry + (rh - bH) / 2 @@ -3016,16 +3171,16 @@ import FilterX from "@lucide/svelte/icons/filter-x"; ctx.fillStyle = isActive ? withAlpha(c.cPrimary, 0.15) - : isVHov ? withAlpha(c.cMutedBg, 0.55) : withAlpha(c.cMutedBg, 0.2) + : isVHov ? withAlpha(c.cMutedBg, 0.6) : withAlpha(c.cMutedBg, 0.3) roundRect(ctx, bX, bY, bW, bH, bR); ctx.fill() if (isActive || isVHov) { - ctx.strokeStyle = isActive ? withAlpha(c.cPrimary, 0.45) : withAlpha(c.cMuted, 0.22) + ctx.strokeStyle = isActive ? withAlpha(c.cPrimary, 0.45) : withAlpha(c.cMuted, 0.25) ctx.lineWidth = 1 roundRect(ctx, bX + 0.5, bY + 0.5, bW - 1, bH - 1, bR); ctx.stroke() } - ctx.fillStyle = isActive ? c.cPrimary : withAlpha(c.cFg, 0.6) + ctx.fillStyle = isActive ? c.cPrimary : withAlpha(c.cFg, 0.72) ctx.textBaseline = 'middle'; ctx.textAlign = 'center' ctx.fillText(labelTxt, bX + bW / 2, ry + rh / 2 + 0.5) } @@ -3036,42 +3191,37 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // per cell. This collapses ~(cols+3) draw-call flushes per row down to one, // the single biggest scroll-perf win alongside O(1) text measurement. const vw = _viewportWidth - const frozenW = c.frozenW ?? gutterWidth - ctx.strokeStyle = c.cGrid - ctx.lineWidth = 1 - ctx.beginPath() - // Non-pinned column separators (skip ones hidden behind the frozen region). - // Same ascending-contentX ordering → break past the right edge. - for (const col of geom.cols) { - if (col.pinned) continue - const dx = col.contentX - _scrollLeft - if (dx >= vw) break - if (dx + col.w <= 0) continue - const ex = dx + col.w - 0.5 - if (ex <= frozenW) continue - ctx.moveTo(ex, ry); ctx.lineTo(ex, ry + rh) - } - // Virtual relationship column separators. - for (let vi = 0; vi < virtualRelCols.length; vi++) { - const ex = geom.totalWidth + vexprTotalW + vi * VIRTUAL_COL_W - _scrollLeft + VIRTUAL_COL_W - 0.5 - if (ex <= frozenW || ex >= vw) continue - ctx.moveTo(ex, ry); ctx.lineTo(ex, ry + rh) - } - // Pinned column separators (frozen, drawn on top of their fills). - for (const col of geom.cols) { - if (!col.pinned) continue - const ex = colDrawnX(col, geom, _scrollLeft) + col.w - 0.5 - if (ex <= 0 || ex >= vw) continue - ctx.moveTo(ex, ry); ctx.lineTo(ex, ry + rh) - } - // Gutter separator. - if (gutterWidth > 0) { - const gex = (gutterWidth - _scrollLeft) - 0.5 - if (gex > 0 && gex < vw) { ctx.moveTo(gex, ry); ctx.lineTo(gex, ry + rh) } + // Vertical separators were collected once for the frame; the row only chooses + // how to render them per the active grid-style preset. All branches stay a + // single batched path/fill, so this is O(visible cols) no matter the row count. + const ts = c.tableStyle + const seps = c.vSeps + // "Bordered" preset draws with the stronger border token for a high-contrast grid. + const gridColor = ts.strong ? c.cBorder : c.cGrid + + if (ts.dots) { + // "Connection dot" grid — a small square at each cell join (column separator + // × the row's bottom edge) instead of full lines. One batched fill per row. + const ds = c.dotSize + const dy = ry + rh - ds + const half = (ds / 2) | 0 + ctx.fillStyle = gridColor + ctx.beginPath() + ctx.rect(0, dy, ds, ds) // left edge join + for (let k = 0; k < seps.length; k++) ctx.rect((seps[k] - half) | 0, dy, ds, ds) + ctx.fill() + } else { + ctx.strokeStyle = gridColor + ctx.lineWidth = 1 + if (ts.dash) ctx.setLineDash(ts.dash) + ctx.beginPath() + if (ts.cols) { + for (let k = 0; k < seps.length; k++) { ctx.moveTo(seps[k], ry); ctx.lineTo(seps[k], ry + rh) } + } + if (ts.rows) { ctx.moveTo(0, ry + rh - 0.5); ctx.lineTo(vw, ry + rh - 0.5) } + ctx.stroke() + if (ts.dash) ctx.setLineDash([]) // reset so other strokes stay solid } - // Bottom row line. - ctx.moveTo(0, ry + rh - 0.5); ctx.lineTo(vw, ry + rh - 0.5) - ctx.stroke() } /** @param {CanvasRenderingContext2D} ctx */ @@ -3156,9 +3306,13 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // Cell text — directive display wins; masked cells reveal on hover. const revealed = dir?.mask && isHover + // SQL array columns render pgAdmin-style ({a,b}); jsonb arrays stay JSON. + const isArrayCol = Array.isArray(value) && isSqlArrayType(cached?.colType) const text = dir ? String(revealed ? (dir.reveal ?? dir.display) : (dir.display ?? displayCell(value))) - : displayCell(value) + : isArrayCol + ? arrayDisplay(value) + : displayCell(value) // Text color — directive link/fg may override (but never over a stronger // dirty/fk/focused state highlight). @@ -4823,6 +4977,12 @@ import FilterX from "@lucide/svelte/icons/filter-x"; Enter {/if} + {#if menuCellIsArray && menuEditable && !readonly} + runMenuAction(() => openArrayEditor(contextRowIdx, contextColIdx))}> + + Edit array… + + {/if} runMenuAction(() => copyCellValue(contextRowIdx, contextColIdx))}> Copy @@ -5007,6 +5167,14 @@ import FilterX from "@lucide/svelte/icons/filter-x"; {/await} {/if} + + { try { const status = await providerOAuthStatus(provider) @@ -209,23 +230,34 @@ {/if} {:else if phase === 'authorizing'} -
+
-
- + +
+ +
-

Waiting for {meta?.name}…

-

Authorize Stroke in the browser tab, then return here. Times out in 5 min.

+

Waiting for {meta?.name}…

+

+ Finish authorizing in the browser tab, then come back here. +

- + +
+ +
+
+ Times out in 5 min + +
{:else if phase === 'fetching'} @@ -354,16 +386,44 @@ {/if} {:else if phase === 'error'} -
-
- - {errorMsg || 'Something went wrong.'} +
+
+
+ +
+
+

{shownError.title}

+

{shownError.detail}

+
+ > + Try again +
{/if}
+ + diff --git a/src/lib/components/ReportIssueDialog.svelte b/src/lib/components/ReportIssueDialog.svelte index f39bd20e..d0feea0b 100644 --- a/src/lib/components/ReportIssueDialog.svelte +++ b/src/lib/components/ReportIssueDialog.svelte @@ -126,14 +126,14 @@ type="button" onclick={() => (issueType = t.id)} class={cn( - "inline-flex h-[28px] flex-1 items-center justify-center gap-1.5 rounded-md border text-[11px] font-medium transition-colors", + "inline-flex h-[28px] min-w-0 flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border px-1 text-[11px] font-medium transition-colors", issueType === t.id ? "border-border/50 bg-muted/60 text-foreground" : "border-border/20 text-muted-foreground/35 hover:border-border/30 hover:bg-muted/20 hover:text-muted-foreground/70", )} > - {t.label} + {t.label} {/each}
@@ -182,12 +182,12 @@
-
+
{/if} + {#if show('Table style', 'Grid style for the data table')} +
+
+

Table style

+

Grid style for the data table — lines, dotted, or connection dots.

+
+ { if (v) setTableStyle(/** @type {import('$lib/stores/settings.js').TableStyleId} */ (v)); }}> + + + + {TABLE_STYLES[settings.tableStyle]?.label ?? "Lines"} + + + + {#each tableStyleEntries as [id, preset] (id)} + + {#snippet children()} + + + + {preset.label} + {preset.description} + + + {/snippet} + + {/each} + + +
+ {/if} + {@render secLabel('Display')} {#if show('Zoom', 'Scale the whole interface')}
diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte index 12d8c71f..16172f5e 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -690,7 +690,7 @@
{ diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index f26f1cc6..1593883e 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -310,6 +310,10 @@ /** @type {StudioTab[]} */ let tabs = $state([]) + // Recently-closed tabs (most-recent last), for Reopen Closed Tab (Ctrl/⌘+Shift+T). + // Stores lightweight descriptors, not live tab objects; capped so it can't grow. + let closedTabStack = $state(/** @type {any[]} */ ([])) + const CLOSED_TAB_STACK_MAX = 20 let activeTabId = $state(/** @type {string | null} */ (null)) // ── Split-pane / editor-group layout ───────────────────────────────────── @@ -1283,11 +1287,44 @@ let rowSearch = $state('') toggleStatusBar() }) + // Reopen the most recently closed tab (browser-style). createHotkey('Mod+Shift+T', (e) => { + if (!connection) return + e.preventDefault() + reopenLastClosedTab() + }) + + // Tab-bar visibility toggle moved here so Mod+Shift+T can reopen closed tabs. + createHotkey('Alt+Shift+T', (e) => { e.preventDefault() toggleTabBar() }) + // Disconnect the current connection (opens the confirm dialog). + createHotkey('Mod+Alt+D', (e) => { + if (!connection) return + e.preventDefault() + showDisconnectDialog = true + }) + + // Open the keyboard-shortcuts reference (Ctrl/⌘+/ — same key as Mod+? without shift). + createHotkey('Mod+/', (e) => { + if (commandOpen || showConnectionModal || showSettingsModal || showShortcutsModal) return + e.preventDefault() + showShortcutsModal = true + }) + + // Jump straight to tab N (Ctrl/⌘+1..8); 9 always jumps to the last tab — + // the same convention as browsers and editors. + for (let n = 1; n <= 9; n++) { + createHotkey(`Mod+${n}`, (e) => { + if (!connection || tabs.length === 0) return + e.preventDefault() + const idx = n === 9 ? tabs.length - 1 : Math.min(n - 1, tabs.length - 1) + void activateTab(tabs[idx].id) + }) + } + createHotkey('Mod+Shift+L', (e) => { e.preventDefault() openLogsTab() @@ -1977,6 +2014,7 @@ let rowSearch = $state('') const key = tabTableKey(closing) if (key) clearPendingChanges(key) } + rememberClosedTab(closing) const nextTabs = tabs.filter((t) => t.id !== id) if (nextTabs.length === 0) { tabs = [createWelcomeTab()] @@ -1991,6 +2029,28 @@ let rowSearch = $state('') } } + /** Push a closed tab onto the reopen stack (welcome tabs aren't worth restoring). */ + function rememberClosedTab(tab) { + if (!tab || tab.kind === 'welcome') return + // Snapshot with a shallow state clone so later edits to the live tree can't + // mutate what we'll restore. `id`/`pinned` are dropped — reopen mints fresh. + const { id: _id, pinned: _pinned, ...rest } = tab + const snapshot = { ...rest, state: tab.state ? { ...tab.state } : tab.state } + closedTabStack = [...closedTabStack, snapshot].slice(-CLOSED_TAB_STACK_MAX) + } + + /** Ctrl/⌘+Shift+T — reopen the most recently closed tab. */ + function reopenLastClosedTab() { + const entry = closedTabStack[closedTabStack.length - 1] + if (!entry) return + closedTabStack = closedTabStack.slice(0, -1) + saveActiveTabState() + dropWelcomeTabs() + const tab = { ...entry, id: crypto.randomUUID(), pinned: false, state: entry.state ? { ...entry.state } : entry.state } + tabs = [...tabs, tab] + void activateTab(tab.id) + } + /** @param {string} id — keep this tab (and pinned tabs), close everything else */ async function closeOtherTabs(id) { const keep = tabs.find((t) => t.id === id) diff --git a/src/lib/components/UpdateDialog.svelte b/src/lib/components/UpdateDialog.svelte index 07811727..61c5d9bc 100644 --- a/src/lib/components/UpdateDialog.svelte +++ b/src/lib/components/UpdateDialog.svelte @@ -178,13 +178,16 @@ await invoke('restart_app') } - /** Open the online changelog in the user's browser. */ + // Tagged so web analytics can attribute changelog views to the desktop app. + const CHANGELOG_URL = 'https://stroke.click/changelog?utm_source=stroke-app&utm_medium=update-dialog&utm_campaign=changelog' + + /** Open the online changelog in the user's browser (never the in-app tab). */ async function openChangelog() { try { const { openUrl } = await import('@tauri-apps/plugin-opener') - await openUrl('https://stroke.click/changelog') + await openUrl(CHANGELOG_URL) } catch { - window.open('https://stroke.click/changelog', '_blank', 'noopener,noreferrer') + window.open(CHANGELOG_URL, '_blank', 'noopener,noreferrer') } } @@ -325,17 +328,18 @@

{errorMsg}

{:else if status === 'up-to-date' || checking} -

+

{checking ? 'Checking GitHub for a newer release…' : "You're on the latest version."}

{#if !checking} {/if} {/if} diff --git a/src/lib/stores/settings.js b/src/lib/stores/settings.js index 054f3b0d..89eb4066 100644 --- a/src/lib/stores/settings.js +++ b/src/lib/stores/settings.js @@ -14,7 +14,7 @@ const STORAGE_KEY = 'stroke:settings' /** @typedef {'geist' | 'serif' | 'apple'} FontId */ /** @typedef {'regular' | 'light' | 'bold'} IconStyleId */ /** @typedef {'lucide' | 'hugeicons'} IconSetId */ -/** @typedef {{ theme: ThemeId, zoom: number, font: FontId, iconStyle: IconStyleId, iconSet: IconSetId, mcpAutoStart: boolean, launchAtLogin: boolean, autoReconnectOnStartup: boolean, previewDmlBeforeApply: boolean }} AppSettings */ +/** @typedef {{ theme: ThemeId, zoom: number, font: FontId, iconStyle: IconStyleId, iconSet: IconSetId, tableStyle: TableStyleId, mcpAutoStart: boolean, launchAtLogin: boolean, autoReconnectOnStartup: boolean, previewDmlBeforeApply: boolean }} AppSettings */ /** UI zoom scale (font + layout). 1 = 100%. */ export const ZOOM_STEPS = [0.8, 0.85, 0.9, 0.95, 1, 1.05, 1.1, 1.15, 1.25, 1.5] @@ -89,6 +89,36 @@ function normalizeIconSet(/** @type {unknown} */ id) { return ICON_SETS[/** @type {IconSetId} */ (id)] ? /** @type {IconSetId} */ (id) : DEFAULT_ICON_SET } +/** + * @typedef {'lines'|'dotted'|'dots'|'minimal'|'bordered'|'striped'} TableStyleId + * @typedef {{ label: string, description: string, + * rows: boolean, cols: boolean, dash: number[]|null, dots: boolean, strong?: boolean, zebra?: boolean }} TableStyleDef + */ + +/** + * Data-grid style presets for the canvas table. Each preset only changes how the + * per-row grid pass draws separators — it's applied in DataTable's virtualized + * draw(), so it costs O(visible cells) and never scales with total row count. + * - rows/cols: draw horizontal / vertical separators + * - dash: canvas setLineDash pattern (null = solid) + * - dots: draw a small dot at each cell join instead of lines + * @type {Record} + */ +export const TABLE_STYLES = { + lines: { label: 'Lines', description: 'Solid grid lines (classic)', rows: true, cols: true, dash: null, dots: false }, + bordered: { label: 'Bordered', description: 'Bold high-contrast grid lines', rows: true, cols: true, dash: null, dots: false, strong: true }, + striped: { label: 'Striped', description: 'Alternating even/odd row shading', rows: true, cols: false, dash: null, dots: false, zebra: true }, + dotted: { label: 'Dotted', description: 'Fine dotted grid, softer feel', rows: true, cols: true, dash: [1, 3], dots: false }, + dots: { label: 'Dots', description: 'Corner dots + soft row shading', rows: false, cols: false, dash: null, dots: true, zebra: true }, + minimal: { label: 'Minimal', description: 'Row separators only, no columns', rows: true, cols: false, dash: null, dots: false }, +} +/** @type {TableStyleId} */ +export const DEFAULT_TABLE_STYLE = 'lines' +/** @returns {TableStyleId} */ +export function normalizeTableStyle(/** @type {unknown} */ id) { + return TABLE_STYLES[/** @type {TableStyleId} */ (id)] ? /** @type {TableStyleId} */ (id) : DEFAULT_TABLE_STYLE +} + /** @type {AppSettings} */ export const DEFAULT_SETTINGS = { theme: DEFAULT_THEME_ID, @@ -96,6 +126,7 @@ export const DEFAULT_SETTINGS = { font: DEFAULT_FONT, iconStyle: DEFAULT_ICON_STYLE, iconSet: DEFAULT_ICON_SET, + tableStyle: DEFAULT_TABLE_STYLE, mcpAutoStart: false, launchAtLogin: false, autoReconnectOnStartup: true, @@ -121,6 +152,10 @@ export const appThemeId = writable(/** @type {ThemeId} */ (DEFAULT_THEME_ID)) /** Reactive: show a SQL preview/confirm before applying grid writes (synced by applySettings). */ export const appPreviewDml = writable(true) +/** Reactive canvas-table grid style preset (synced by applySettings). DataTable + * subscribes to repaint when it changes. */ +export const appTableStyle = writable(/** @type {TableStyleId} */ (DEFAULT_TABLE_STYLE)) + const LAST_DARK_KEY = 'stroke:last-dark-theme' const LAST_LIGHT_KEY = 'stroke:last-light-theme' @@ -192,7 +227,8 @@ export function loadSettings() { const font = normalizeFont(parsed.font) const iconStyle = normalizeIconStyle(parsed.iconStyle) const iconSet = normalizeIconSet(parsed.iconSet) - _settingsCache = { theme, zoom, font, iconStyle, iconSet, mcpAutoStart, launchAtLogin, autoReconnectOnStartup, previewDmlBeforeApply } + const tableStyle = normalizeTableStyle(parsed.tableStyle) + _settingsCache = { theme, zoom, font, iconStyle, iconSet, tableStyle, mcpAutoStart, launchAtLogin, autoReconnectOnStartup, previewDmlBeforeApply } return { ..._settingsCache } } catch { return { ...DEFAULT_SETTINGS } @@ -261,6 +297,12 @@ export function applySettings(settings) { // Grid-write DML preview toggle — DataTable subscribes to gate its confirm dialog. appPreviewDml.set(settings.previewDmlBeforeApply !== false) + // Canvas table grid style — data attribute for any CSS hooks; DataTable reads + // the store and repaints the virtualized grid pass. + const tableStyle = normalizeTableStyle(settings.tableStyle) + root.setAttribute('data-table-style', tableStyle) + appTableStyle.set(tableStyle) + // Keep the canvas-table zoom in lockstep with the app zoom so Cmd +/-/0 (and // the zoom buttons) scale the grid alongside the rest of the UI. The canvas // renderer reads zoomState directly and repaints on change.