diff --git a/.changeset/perf-startup-and-platform-fixes.md b/.changeset/perf-startup-and-platform-fixes.md new file mode 100644 index 00000000..7b5d19d1 --- /dev/null +++ b/.changeset/perf-startup-and-platform-fixes.md @@ -0,0 +1,52 @@ +### Changes + +- **The app starts in a fraction of the time it used to.** Stroke was compiling **6.8 MB** of JavaScript and CSS before it could paint a single pixel, and about 3.9 MB of that was the Monaco editor — loaded at boot whether or not you ever opened a SQL tab. The cause was one static import four components deep: the split-pane snapshot imported the JSON view, which imported the Monaco text view, which imported Monaco. Being in a separate chunk file does not make code lazy; only being unreachable from a plain `import` does. Monaco, Shiki and the confetti library are now off that path entirely, along with eight tab pages that were shipped to every user at boot despite being behind "only if opened" guards. **The startup payload is 2.6 MB, down 61%.** Every one of those chunks is still warmed during idle time, so opening a tab is no slower than before — the work simply no longer happens between launching the app and seeing it. + +- **Required columns are marked in the grid header.** A `NOT NULL` column carries a red asterisk before its name, the same mark a required form field carries. The constraint used to be discoverable only by trying — clearing a cell and being told "Cannot set NULL" — which is late to find out. + +### Bug Fixes + +#### Instance Insights +- **The Config tab no longer stalls when it opens.** Postgres reports 350-odd settings and MySQL over 600, and every one of them was built into the page at once. Rows are now constructed a screenful at a time and the rest fill in during idle, so the tab opens immediately and ⌘F still finds every setting a moment later. +- **The session and lock tables stopped rebuilding their column list once per row.** A 200-session table allocated 200 throwaway arrays on every render of the page, and read each cell twice over. +- **The server payloads stopped being deep-proxied.** The settings list, session list and replication stats are replaced whole every refresh and only ever read, so wrapping every row and every field in a reactive proxy cost more than the render it fed. +- **`pg_stat_activity` and `pg_prepared_xacts` are bounded.** They had no `LIMIT` while the lock query beside them had one, so a busy server could hand back an unbounded result — and the session query calls `pg_blocking_pids` once per row. +- **Live charts update instead of rebuilding.** Every five-second refresh tore down all three charts and built them again from nothing; they now merge the new numbers while the shape of the data holds, and rebuild only when it actually changes. + +#### OmniRoute +- **Installing it works on macOS and Linux.** It installed with `npm install -g`, which writes to a prefix owned by root on a default nodejs.org or distribution install — so it failed with a permissions error for exactly the people who followed Stroke's own advice to install Node from nodejs.org, and all the app could do was tell them to go run npm themselves. Stroke now keeps its own copy under its data directory, which needs no elevation anywhere and leaves your global npm prefix alone. +- **No more console window on Windows.** Checking for Node, installing the package and starting the gateway each went through `cmd.exe`, and a windowed app on Windows gets a visible console for any console program it starts. Every process Stroke runs — npm, Docker, ssh tunnels, the local port scan, the licence check — is now started without one. The gateway itself is launched directly through Node, so on Windows there is no console to suppress in the first place, and stopping it actually stops it: under `cmd.exe` the Node process outlived the shell that was killed. +- **The install shows progress.** It pulls roughly 1200 packages and takes minutes, and npm prints nothing at all until it finishes, so the panel sat on "resolving package…" long enough to look hung. + +- **JSONPath suggestions read like an editor's.** Each row used to print the same identifier twice with a generic badge and no sign of which characters matched what you typed — because the widget was throwing away the type, the detail and the value preview the completion engine had already worked out, then rebuilding a worse version from what was left. Suggestions now show what kind of value is behind a key, highlight the part you typed, preview the value on the highlighted row, arm the top match so Enter takes it immediately, and state their own keys along the bottom. There were three copies of this list, one of them good; there is one now. + +#### Keyboard +- **`/` focuses the search box for whatever you are looking at.** The app has around thirty search and filter fields and this works on all of them, including ones added later, because the key finds its target from what is on screen rather than being wired to each field. It follows you into a dialog, ignores hidden fields belonging to tabs you left open, and selects the existing text so typing replaces a stale query. +- **`?` no longer interrupts you mid-sentence.** It opens the shortcuts panel, but only checked for a plain text field — so typing a question mark in the AI chat or certain editor contexts popped the panel open instead. +- **⌘B toggles the sidebar again.** The binding went through a listener on the document in the bubble phase, so anything between the focused element and the document that stopped the event swallowed it first — which is every Monaco editor, the grid canvas, and the dialog overlays. It is now bound in the capture phase, ahead of all of them, and matches ⌘ on macOS and Ctrl elsewhere rather than accepting either (Ctrl+B still moves the caret in a text field on a Mac, as it should). +- **Shortcuts are written down in one place.** `src/lib/shortcuts.js` holds every binding as a single combo string; the macOS and Windows spellings are derived from it rather than typed out twice, which is what let the help dialog drift from what the app actually bound. Adding a shortcut now gets both platforms for free. + +#### Connections +- **The saved-connections filter is always there once you have more than one.** It only appeared past five connections, so it materialised out of nowhere as the list grew and the people most likely to want it had never seen it. It also gained Enter to take the top match, arrow keys into the list, and a clear button big enough to hit. +- **Filtering no longer replays the list's entrance animation on every keystroke.** Rows carried a staggered rise of up to half a second, which is a nice first impression and reads as lag when it fires on each character typed. Both the connections rail and the database picker opposite it were affected. +- **The engine blurb is gone from the connection form header.** "Local file-based database" under a heading that says SQLite is describing a decision you have already made. + +#### SQL editor +- **A running query survives leaving its tab.** Two Query Editor tabs share one editor component, and switching between them swapped its state out from under an in-flight query — the spinner stopped, the results pane showed the other tab's, and whatever came back landed in whichever tab happened to be in front. The query itself never stopped; only the app lost track of which tab had asked. A run now belongs to the tab that started it. +- **JSON has its colours back.** The viewer was rendering correctly-indented monochrome — keys, strings and numbers all the same shade. Every editor theme in the app was written for SQL and named none of the scopes JSON actually produces, so nearly every token fell through to the plain foreground. All 28 themes now colour it, with property names distinct from values so a page of rows can be scanned. +- **Wrap long JSON values, everywhere, from one switch.** An embedding or a document chunk ran off the right edge with no way to read the rest of it. There is now a Wrap button on every JSON view and a matching setting in Settings → General, all driving the same preference — flip it anywhere and every open view reflows. The row detail panel used to keep its own separate switch, so the same preference existed twice and applied to two of six views. Off by default: unwrapped keeps the structure scannable, and one embedding wrapped can bury every row around it. +- **The JSON view no longer freezes on a large result.** It built one object per row, flattened all of them to a single string, then parsed that string straight back into objects — three passes over the whole result and two copies of it in memory, with no limit. `SELECT *` over a million rows spent its time assembling text far too large to read. It now renders the first thousand rows, says so, and Export still carries everything. + +#### Codegen +- **A SQL target, beside Prisma and Drizzle.** The schema as the statements that would rebuild it: enum types, `CREATE TABLE` with nullability, defaults and keys, the indexes, and the foreign keys as `ALTER TABLE` after every table exists — inline `REFERENCES` only works if the parent is created first, which no ordering can promise once the schema has a cycle. It covers every engine, because it is the engine's own language. +- **The whole database in one script.** A scope switch on the SQL target emits every schema at once, schema-qualified, so two schemas owning a `users` table produce two statements rather than a collision. Read only when you ask for it. +- **The Prisma/Drizzle switch is reachable from the keyboard.** It was two plain buttons — no tablist, no arrow keys, invisible to a screen reader — for the primary control on the page. Its pills were also 24px next to 28px buttons; they match now. + +#### Interface +- **Dropdown menus were rebuilt to fit their own panel.** Item corners were rounder than the space they sat in, leaving a crescent of gap at each one; rows were small enough to mis-click; the highlight jumped from row to row with no transition, so running the pointer down a list strobed; and the selected value was marked only by a tick at the far right edge, away from the label you were reading. Fixed in the shared component, so every menu in the app gets it. + +#### Ask AI +- **You can select and copy an answer.** The command palette is rendered outside the app's DOM root, so the rule that makes dialog text selectable never applied to it — and a quick answer you cannot quote is most of the point of asking. +- **The panel stops moving while you type.** It had a maximum height but no height, so the box grew as the answer streamed and as query results appeared, shifting everything already on screen out from under the cursor. A transcript wants a window that scrolls, not one that resizes. +- **It follows the answer to the end.** Auto-scroll measured the page the instant new text arrived, but the markdown renders a moment later — so it scrolled to a bottom that did not exist yet, fell further behind with each token, and gave up entirely once it had drifted far enough, which is exactly when there was most left to read. It now waits for the text to actually land, and scrolling up to re-read something no longer fights the stream. +- **Typing during an answer is smoother.** Every token re-ran a walk up the DOM asking the browser to recompute styles, for the whole length of the answer. diff --git a/src-tauri/src/db/insights.rs b/src-tauri/src/db/insights.rs index 48220308..17aff279 100644 --- a/src-tauri/src/db/insights.rs +++ b/src-tauri/src/db/insights.rs @@ -582,7 +582,8 @@ pub async fn instance_state(state: State<'_, DbState>) -> Result) -> Result { @@ -604,7 +605,8 @@ pub async fn instance_state(state: State<'_, DbState>) -> Result) -> Result Vec { #[cfg(not(any(target_os = "linux", target_os = "macos")))] fn listening_ports(pid: u32) -> Vec { - let Ok(out) = std::process::Command::new("netstat").args(["-ano", "-p", "TCP"]).output() else { + // quiet_std: the local-database scan runs on the connection screen, so a bare + // spawn flashed a console window over the app on Windows. + let Ok(out) = crate::proc::quiet_std( + std::process::Command::new("netstat").args(["-ano", "-p", "TCP"]), + ) + .output() else { return Vec::new(); }; let pid = pid.to_string(); diff --git a/src-tauri/src/db/ssh_tunnel.rs b/src-tauri/src/db/ssh_tunnel.rs index abf5d70b..2e93ccbe 100644 --- a/src-tauri/src/db/ssh_tunnel.rs +++ b/src-tauri/src/db/ssh_tunnel.rs @@ -75,11 +75,15 @@ impl SshTunnel { args.push(dest); - let child = Command::new("ssh") - .args(&args) + let mut cmd = Command::new("ssh"); + cmd.args(&args) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + // quiet: the tunnel is a background process the user never interacts with + // (all three streams are already closed), so on Windows it must not get a + // console window of its own. + let child = crate::proc::quiet(&mut cmd) .spawn() .map_err(|e| { format!( diff --git a/src-tauri/src/docker.rs b/src-tauri/src/docker.rs index b33e26bf..715db561 100644 --- a/src-tauri/src/docker.rs +++ b/src-tauri/src/docker.rs @@ -1,8 +1,17 @@ +use crate::proc::quiet; use std::process::Stdio; use tauri::Emitter; use tokio::io::AsyncBufReadExt; use tokio::process::Command as Cmd; +/// `docker …`, with no console window on Windows. The container scan runs on the +/// connection screen, so a bare spawn flashed a cmd.exe window at app start. +fn docker() -> Cmd { + let mut c = Cmd::new("docker"); + quiet(&mut c); + c +} + // ── DB-level readiness checks ───────────────────────────────────────────────── // TCP-open ≠ database-ready. MySQL/Postgres accept TCP connections seconds before // they finish initialization. We retry actual SQL pings to confirm readiness. @@ -76,7 +85,7 @@ fn emit_log(app: &tauri::AppHandle, event: &str, line: &str, kind: &str) { #[tauri::command] pub async fn docker_check() -> Result { - let out = Cmd::new("docker") + let out = docker() .args(["version", "--format", "{{.Server.Version}}"]) .output() .await @@ -135,7 +144,7 @@ pub async fn docker_run_db( // ── Pull ────────────────────────────────────────────────────────────────── emit_log(&app, &evt, &format!("Pulling {image}…"), "info"); - let mut pull_child = Cmd::new("docker") + let mut pull_child = docker() .args(["pull", image]) .stdout(Stdio::null()) .stderr(Stdio::piped()) @@ -162,7 +171,7 @@ pub async fn docker_run_db( emit_log(&app, &evt, "Image ready. Starting container…", "info"); // ── Run ─────────────────────────────────────────────────────────────────── - let mut run_cmd = Cmd::new("docker"); + let mut run_cmd = docker(); run_cmd .arg("run") .arg("-d") @@ -490,7 +499,7 @@ fn database_from_inspect(c: &Value) -> Option { /// there is nothing to offer, and the connection screen stays quiet about it. #[tauri::command] pub async fn scan_docker_databases() -> Result, String> { - let Ok(ps) = Cmd::new("docker").args(["ps", "--format", "{{.ID}}"]).output().await else { + let Ok(ps) = docker().args(["ps", "--format", "{{.ID}}"]).output().await else { return Ok(Vec::new()); }; if !ps.status.success() { @@ -508,7 +517,7 @@ pub async fn scan_docker_databases() -> Result, String> { // One inspect for every container, line-delimited so a single malformed // entry can't take the whole scan down with it. - let Ok(out) = Cmd::new("docker") + let Ok(out) = docker() .arg("inspect") .arg("--format") .arg("{{json .}}") diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b26dad3d..d0594b6c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,6 +7,7 @@ mod license; mod mcp; mod omniroute; mod metrics; +mod proc; mod providers; mod secrets; mod web_search; diff --git a/src-tauri/src/license.rs b/src-tauri/src/license.rs index 7ea740a7..9f2f4750 100644 --- a/src-tauri/src/license.rs +++ b/src-tauri/src/license.rs @@ -167,14 +167,14 @@ fn machine_id_raw() -> String { } #[cfg(target_os = "windows")] { - if let Ok(out) = std::process::Command::new("reg") - .args([ - "query", - "HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography", - "/v", - "MachineGuid", - ]) - .output() + // quiet_std: the machine-id lookup runs at startup for the licence check. + if let Ok(out) = crate::proc::quiet_std(std::process::Command::new("reg").args([ + "query", + "HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography", + "/v", + "MachineGuid", + ])) + .output() { if let Ok(s) = String::from_utf8(out.stdout) { for line in s.lines() { diff --git a/src-tauri/src/omniroute.rs b/src-tauri/src/omniroute.rs index 82ef79b7..784967a1 100644 --- a/src-tauri/src/omniroute.rs +++ b/src-tauri/src/omniroute.rs @@ -7,9 +7,11 @@ // npm absent, install rejected, port busy) because "could not start OmniRoute" // gives the user nothing to act on. +use crate::proc::quiet; +use std::path::PathBuf; use std::process::Stdio; use std::sync::Mutex; -use tauri::Emitter; +use tauri::{Emitter, Manager}; use tokio::io::AsyncBufReadExt; use tokio::process::{Child, Command as Cmd}; @@ -114,14 +116,11 @@ async fn login_shell_path() -> Option { // -i so the rc file that defines the version manager actually runs. stdin // closed and a hard timeout because an interactive shell is entitled to // wait for input, and the app must not wait with it. - let out = tokio::time::timeout( - std::time::Duration::from_secs(4), - Cmd::new(&shell) - .args(["-ilc", "printf %s \"$PATH\""]) - .stdin(Stdio::null()) - .stderr(Stdio::null()) - .output(), - ) + let mut cmd = Cmd::new(&shell); + cmd.args(["-ilc", "printf %s \"$PATH\""]) + .stdin(Stdio::null()) + .stderr(Stdio::null()); + let out = tokio::time::timeout(std::time::Duration::from_secs(4), quiet(&mut cmd).output()) .await .ok()? .ok()?; @@ -190,6 +189,9 @@ fn node_version_bins(root: &str, suffix: &str) -> Vec { async fn tool(program: &str) -> Cmd { let mut c = Cmd::new(program); c.env("PATH", user_path().await); + // Without this every probe of node/npm/omniroute pops a cmd.exe window over + // the app on Windows, and the settings panel probes on open. + quiet(&mut c); c } @@ -221,6 +223,50 @@ async fn omniroute_command() -> Cmd { } } +// ── The app's own copy of OmniRoute ────────────────────────────────────────── +// +// `npm install -g` writes to a prefix most users cannot write to: a default +// nodejs.org install owns /usr/local and a distro package owns /usr/lib, so the +// install died with EACCES on macOS and Linux for exactly the people who +// followed the app's own advice to get Node from nodejs.org. All the app could +// do was tell them to go run npm in a terminal themselves. +// +// So the app keeps its own copy under its data directory. That needs no +// elevation on any platform, leaves the user's global npm prefix alone, and — +// because the entry script then sits at a known absolute path — lets the server +// start as `node `, with no PATH search and no shell in between. On +// Windows that is what stops a console window from existing, rather than merely +// hiding one. +// +// A pre-existing global install still works: every lookup falls back to it. + +/// `/omniroute` — the npm project the app installs into. +fn managed_root(app: &tauri::AppHandle) -> Result { + Ok(app + .path() + .app_data_dir() + .map_err(|e| format!("Could not locate the app data directory: {e}"))? + .join("omniroute")) +} + +/// The installed package's entry script and version, read straight out of its +/// `package.json`. One file read replaces spawning `omniroute --version`, and +/// the bin path comes from the package rather than being hardcoded here. +fn managed_pkg(app: &tauri::AppHandle) -> Option<(PathBuf, String)> { + let dir = managed_root(app).ok()?.join("node_modules").join("omniroute"); + let text = std::fs::read_to_string(dir.join("package.json")).ok()?; + let json: serde_json::Value = serde_json::from_str(&text).ok()?; + let version = json.get("version")?.as_str()?.to_string(); + // `bin` is either a bare path or a map of command name → path. + let rel = match json.get("bin")? { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Object(m) => m.get("omniroute")?.as_str()?.to_string(), + _ => return None, + }; + let entry = dir.join(rel.trim_start_matches("./")); + entry.is_file().then_some((entry, version)) +} + #[derive(serde::Serialize)] pub struct NodeStatus { pub node: Option, @@ -231,9 +277,17 @@ pub struct NodeStatus { /// What is present on this machine. Never errors: the UI needs to render the /// gaps, not a failure. #[tauri::command] -pub async fn omniroute_env() -> Result { +pub async fn omniroute_env(app: tauri::AppHandle) -> Result { async fn version(mut cmd: Cmd) -> Option { - let out = cmd.arg("--version").output().await.ok()?; + // A tool that never exits would otherwise hang this command forever, and + // with it the Install and Start buttons that both begin by calling it. + let out = tokio::time::timeout( + std::time::Duration::from_secs(10), + cmd.arg("--version").output(), + ) + .await + .ok()? + .ok()?; if !out.status.success() { return None; } @@ -241,17 +295,25 @@ pub async fn omniroute_env() -> Result { if v.is_empty() { None } else { Some(v) } } + let omniroute = match managed_pkg(&app) { + // The app's own copy: a file read, no process at all. + Some((_, v)) => Some(v), + // Otherwise honour a global install the user already had. + None => version(omniroute_command().await).await, + }; + Ok(NodeStatus { node: version(tool("node").await).await, npm: version(npm_command().await).await, - omniroute: version(omniroute_command().await).await, + omniroute, }) } -/// `npm i -g omniroute`, streaming npm's output to the UI as it goes. +/// Install OmniRoute into the app's own directory, streaming npm's output to the +/// UI as it goes. Not `-g`: see the note above `managed_root`. #[tauri::command] pub async fn omniroute_install(app: tauri::AppHandle) -> Result { - let env = omniroute_env().await?; + let env = omniroute_env(app.clone()).await?; if env.node.is_none() { return Err( "Node.js is not installed. OmniRoute runs on Node - install it from nodejs.org (or your package manager), then try again." @@ -265,9 +327,28 @@ pub async fn omniroute_install(app: tauri::AppHandle) -> Result ); } - emit(&app, "npm install -g omniroute", "cmd"); + let root = managed_root(&app)?; + std::fs::create_dir_all(&root) + .map_err(|e| format!("Could not create {}: {e}", root.display()))?; + // npm walks up the tree looking for a package.json when there is none here, + // and would install into whatever project it found above the data directory. + let manifest = root.join("package.json"); + if !manifest.exists() { + std::fs::write( + &manifest, + "{\n \"name\": \"stroke-omniroute\",\n \"private\": true,\n \"version\": \"1.0.0\"\n}\n", + ) + .map_err(|e| format!("Could not write {}: {e}", manifest.display()))?; + } + + emit(&app, &format!("npm install omniroute (in {})", root.display()), "cmd"); + // --loglevel http: OmniRoute pulls ~1200 packages and takes minutes, and at + // npm's default level it prints nothing at all until it finishes. The panel + // shows the newest log line as a one-line status, so without this it sits on + // "resolving package…" for the whole install and reads as hung. let mut child = npm_command().await - .args(["install", "-g", "omniroute"]) + .args(["install", "omniroute", "--no-fund", "--no-audit", "--loglevel", "http"]) + .current_dir(&root) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() @@ -297,14 +378,19 @@ pub async fn omniroute_install(app: tauri::AppHandle) -> Result .await .map_err(|e| format!("npm install failed: {e}"))?; if !status.success() { - return Err( - "npm install failed. A global install may need elevated permissions - run `npm install -g omniroute` in a terminal to see the full error." - .to_string(), - ); + return Err(format!( + "npm could not install OmniRoute into {}. The log above has npm's own reason - a network or registry failure is the usual one.", + root.display() + )); } - let v = omniroute_env().await?.omniroute; - Ok(v.unwrap_or_else(|| "installed".to_string())) + let (_, version) = managed_pkg(&app).ok_or_else(|| { + format!( + "npm reported success but no OmniRoute package landed in {}.", + root.join("node_modules").join("omniroute").display() + ) + })?; + Ok(version) } /// Is the GATEWAY answering - not merely "is the port open". @@ -346,12 +432,27 @@ pub async fn omniroute_start( return Ok(format!("http://127.0.0.1:{port}")); } - if omniroute_env().await?.omniroute.is_none() { - return Err("OmniRoute is not installed yet. Install it first.".to_string()); - } + // Prefer the app's own copy and run its entry script through node directly. + // An absolute path needs no PATH search and no shell, so on Windows there is + // no console to suppress in the first place. A global install predating this + // still starts the old way. + let mut cmd = match managed_pkg(&app) { + Some((entry, _)) => { + emit(&app, &format!("node {} --port {port}", entry.display()), "cmd"); + let mut c = tool("node").await; + c.arg(entry); + c + } + None => { + if omniroute_env(app.clone()).await?.omniroute.is_none() { + return Err("OmniRoute is not installed yet. Install it first.".to_string()); + } + emit(&app, &format!("omniroute --port {port}"), "cmd"); + omniroute_command().await + } + }; - emit(&app, &format!("omniroute --port {port}"), "cmd"); - let mut child = omniroute_command().await + let mut child = cmd .args(["--port", &port.to_string()]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) diff --git a/src-tauri/src/proc.rs b/src-tauri/src/proc.rs new file mode 100644 index 00000000..ff5ba6d8 --- /dev/null +++ b/src-tauri/src/proc.rs @@ -0,0 +1,42 @@ +// Spawning child processes without showing the user a console window. +// +// On Windows a GUI process has no console, so when it starts a console program +// the OS allocates one — and that console is a cmd.exe window that pops up in +// front of the app. Every process this app runs is a background detail nobody +// asked to watch: npm installs, docker, ssh tunnels, port scans. All of them go +// through here. +// +// Unix has no equivalent behaviour and no equivalent flag, so on those platforms +// these are pass-throughs that the optimiser removes entirely. + +/// `CREATE_NO_WINDOW` — run the console program with no console at all. +/// +/// Not `SW_HIDE` via `STARTUPINFO`: that still allocates the console and merely +/// starts it hidden, which leaves a taskbar entry and a window that anything can +/// show again. +/// +/// +#[cfg(windows)] +const CREATE_NO_WINDOW: u32 = 0x0800_0000; + +/// Suppress the console window for an async command. +pub fn quiet(cmd: &mut tokio::process::Command) -> &mut tokio::process::Command { + #[cfg(windows)] + cmd.creation_flags(CREATE_NO_WINDOW); + cmd +} + +/// Suppress the console window for a blocking command. +/// +/// Both callers sit in Windows-only `cfg` blocks (the registry machine-id read +/// and the netstat port scan), so off Windows this compiles to an unused +/// pass-through rather than dead code worth removing. +#[cfg_attr(not(windows), allow(dead_code))] +pub fn quiet_std(cmd: &mut std::process::Command) -> &mut std::process::Command { + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(CREATE_NO_WINDOW); + } + cmd +} diff --git a/src/lib/components/AiSettingsDialog.svelte b/src/lib/components/AiSettingsDialog.svelte index 0bd4f955..6b9d8668 100644 --- a/src/lib/components/AiSettingsDialog.svelte +++ b/src/lib/components/AiSettingsDialog.svelte @@ -726,7 +726,9 @@ {#if omniBusy === 'installing'} Installing… {:else} - Install OmniRoute (npm i -g omniroute) + + Install OmniRoute {/if} {:else if omniEnv && !omniServing} diff --git a/src/lib/components/ChartView.svelte b/src/lib/components/ChartView.svelte index 63a10c00..947073ad 100644 --- a/src/lib/components/ChartView.svelte +++ b/src/lib/components/ChartView.svelte @@ -320,7 +320,9 @@ const axisLabel = 'flex h-full shrink-0 select-none items-center border-r border-border/30 px-2 text-ui-2xs font-semibold uppercase tracking-widest text-muted-foreground/35' const axisValue = 'truncate px-2 font-mono text-ui-xs text-foreground' const axisContent = 'z-[120] max-h-[20rem] min-w-[11rem] p-1' - const axisItem = 'rounded-md py-1.5 pl-2 font-mono text-ui-xs' + // No radius here: Select.Item's own rounded-lg is derived from the panel's + // radius minus its padding, and overriding it breaks that relationship. + const axisItem = 'py-1.5 pl-2 font-mono text-ui-xs' const iconBtn = 'inline-flex size-7 items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground' diff --git a/src/lib/components/CloudflareLogin.svelte b/src/lib/components/CloudflareLogin.svelte index a2913a49..a6536ec6 100644 --- a/src/lib/components/CloudflareLogin.svelte +++ b/src/lib/components/CloudflareLogin.svelte @@ -9,6 +9,8 @@ import ArrowRight from '@lucide/svelte/icons/arrow-right' import DbIcon from './DbIcon.svelte' import SearchableMenu from './SearchableMenu.svelte' + import ProviderAuthPanel from './ProviderAuthPanel.svelte' + import { Button } from '$lib/components/ui/button/index.js' import { cfStartOAuth, cfOAuthStatus, cfLogout } from '$lib/cloudflare.js' import { cloudflareListAccounts, cloudflareListD1Databases } from '$lib/api.js' import { cn } from '$lib/utils.js' @@ -182,45 +184,52 @@
{#if phase === 'idle'} - -
- -

Opens your browser to authorize · same PKCE flow as Wrangler

-
+ + {#snippet mark()}{/snippet} + {#snippet action()} + + {/snippet} + {:else if phase === 'authorizing'} - -
-
-
- - -
-
-

Waiting for Cloudflare…

-

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

-
-
-
- -
-
+ + {#snippet mark()}{/snippet} + {:else if phase === 'fetching'} - -
- - Loading your Cloudflare accounts… -
+ + {#snippet mark()}{/snippet} + + + {:else if phase === 'error'} + + {#snippet mark()}{/snippet} + {#snippet action()} + + {/snippet} + {:else if phase === 'selecting'} @@ -337,56 +346,7 @@
{/if} - {:else if phase === 'error'} -
-
-
- -
-
-

{shownError.title}

-

{shownError.detail}

-
-
-
- -
-
{/if} - diff --git a/src/lib/components/CommandPalette.svelte b/src/lib/components/CommandPalette.svelte index 99abdcfd..0c29bfca 100644 --- a/src/lib/components/CommandPalette.svelte +++ b/src/lib/components/CommandPalette.svelte @@ -169,7 +169,7 @@ const q = (question ?? '').trim() if (!q) return if (!isAiConfigured(get(aiSettings))) { onopensettings(); open = false; return } - askApi = []; askTurns = []; askError = '' + askApi = []; askTurns = []; askError = ''; askStick = true page = 'ask'; paletteSearch = '' await askTurn(q) } @@ -187,6 +187,7 @@ const settings = get(aiSettings) askError = '' askApi.push({ role: 'user', content: q }) + askStick = true askTurns = [...askTurns, { id: uid(), role: 'user', text: q }] askExecuted = new Set() askStreaming = true @@ -295,30 +296,60 @@ // Keep the ask thread pinned to the newest message as it streams / grows. // Coalesced to one adjustment per frame and gated on "near the bottom" so it // never fights manual scroll and doesn't jitter the view on every token. - let askBottomEl = $state(/** @type {HTMLElement | null} */ (null)) - let _askScrollRaf = 0 - /** @param {HTMLElement | null} el */ + /** The transcript container, watched for growth. */ + let askContentEl = $state(/** @type {HTMLElement | null} */ (null)) + /** Whether to keep pinning to the bottom. False once the user scrolls away. */ + let askStick = true + + /** + * The scrolling ancestor (the command list). + * + * Deliberately does NOT require the element to be overflowing yet: at the + * moment the transcript mounts it is shorter than the list, so a check on + * scrollHeight found nothing and the effect below bailed for good. + * @param {HTMLElement | null} el + */ function _nearestScroller(el) { let n = el?.parentElement while (n) { const oy = getComputedStyle(n).overflowY - if ((oy === 'auto' || oy === 'scroll') && n.scrollHeight > n.clientHeight + 1) return n + if (oy === 'auto' || oy === 'scroll') return n n = n.parentElement } return null } + + // Follow the answer as it streams. + // + // Driven by a ResizeObserver on the transcript rather than by askTurns: + // AiMarkdown renders on a 120ms debounce, so a measurement taken when the + // state changed is always behind what is actually on screen. The old effect + // scrolled to a bottom that did not exist yet, fell a little further behind on + // every token, and once the gap passed its 120px "near the bottom" gate it + // stopped following for the rest of the answer — which is exactly when there + // was most left to read. Watching the box means the scroll happens after the + // content it is scrolling to. $effect(() => { - void askTurns - if (page !== 'ask' || !askBottomEl) return - if (_askScrollRaf) return - _askScrollRaf = requestAnimationFrame(() => { - _askScrollRaf = 0 - const scroller = _nearestScroller(askBottomEl) - if (!scroller) return - if (scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < 120) { - scroller.scrollTop = scroller.scrollHeight - } + const content = askContentEl + if (page !== 'ask' || !content) return + const scroller = _nearestScroller(content) + if (!scroller) return + + // A small tolerance, not a large one: this decides "is the user reading the + // live end", and sub-pixel layout means exact equality never holds. + const atBottom = () => scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < 24 + const onScroll = () => { askStick = atBottom() } + scroller.addEventListener('scroll', onScroll, { passive: true }) + + const ro = new ResizeObserver(() => { + if (askStick) scroller.scrollTop = scroller.scrollHeight }) + ro.observe(content) + + return () => { + scroller.removeEventListener('scroll', onScroll) + ro.disconnect() + } }) /** @param {KeyboardEvent} e */ @@ -556,7 +587,7 @@ - + {#if page !== 'ask'} No results. {/if} @@ -572,7 +603,7 @@ {#if page === 'ask'} -
+
{#each askTurns as turn (turn.id)} {#if turn.role === 'user'}
@@ -649,7 +680,6 @@ {/if}
Type below and press ↵ to follow up
-
{/if} diff --git a/src/lib/components/ConfirmDialog.svelte b/src/lib/components/ConfirmDialog.svelte index e488d85c..68d4af7a 100644 --- a/src/lib/components/ConfirmDialog.svelte +++ b/src/lib/components/ConfirmDialog.svelte @@ -27,6 +27,12 @@ /** 'destructive' paints the confirm button red. */ variant = /** @type {'default' | 'destructive'} */ ('default'), disabled = false, + /** + * Enter presses the confirm button, and nothing is focused when the dialog + * opens so that it can. Opt in only where confirming by accident is cheap to + * undo - it is off by default so a stray Return can never drop a table. + */ + confirmOnEnter = false, onconfirm = () => {}, oncancel = () => {}, /** Optional rich body, rendered in place of `description`. */ @@ -45,13 +51,54 @@ open = false oncancel() } + + /** @type {HTMLElement|null} */ + let contentEl = $state(null) + + /** + * Hold focus on the dialog itself instead of letting it land on Cancel, which + * is the first focusable child. With Cancel focused, Enter would click it - + * the exact opposite of the confirm-on-Enter below. Tab still reaches both + * buttons, and Escape still cancels. + * @param {Event} e + */ + function onOpenAutoFocus(e) { + if (!confirmOnEnter) return + e.preventDefault() + try { contentEl?.focus({ preventScroll: true }) } catch { /* not focusable */ } + } + + /** + * Enter confirms - the default-button behaviour every native alert dialog has, + * so a prompt can be answered without reaching for the mouse. + * + * Skipped wherever Enter already means something else: on a focused button the + * browser clicks it (confirming here too would fire Cancel *and* confirm), and + * in a textarea or contenteditable it inserts a newline. A plain input is left + * alone deliberately - Enter submitting a one-line field is what people expect. + * @param {KeyboardEvent} e + */ + function onkeydown(e) { + if (!confirmOnEnter) return + if (e.key !== 'Enter' || e.isComposing) return + if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return + const el = e.target + if (el instanceof HTMLElement && (el.closest('button, a[href], select, textarea') || el.isContentEditable)) return + if (disabled) return + e.preventDefault() + confirm() + }
@@ -100,6 +147,10 @@ > {#if confirmIcon}{/if} {confirmLabel} + + {#if confirmOnEnter} + ↵ + {/if}
diff --git a/src/lib/components/ConnectionModal.svelte b/src/lib/components/ConnectionModal.svelte index 4ace24a9..fa2be620 100644 --- a/src/lib/components/ConnectionModal.svelte +++ b/src/lib/components/ConnectionModal.svelte @@ -221,6 +221,41 @@ (b.lastConnectedAt ?? 0) - (a.lastConnectedAt ?? 0); let saved = $state(loadSavedConnections().sort(byLastConnected)); + /** Filter over the saved rail. Matches the things you would recognise a + * connection by — its name, its engine, and where it points. */ + let savedQuery = $state(""); + /** @type {HTMLInputElement | null} */ + let savedSearchEl = $state(null); + const savedMatches = $derived.by(() => { + const q = savedQuery.trim().toLowerCase(); + if (!q) return saved; + return saved.filter((c) => + [c.name, c.type, c.database, c.host, c.filePath, c.libsqlUrl] + .filter(Boolean) + .some((v) => String(v).toLowerCase().includes(q)), + ); + }); + /** What Enter in the filter acts on — same target the eye is already on. */ + const firstSavedMatch = $derived(savedMatches[0] ?? null); + + /** + * Whether rows still play their staggered entrance. + * + * The stagger is a first-impression flourish, and it is the wrong thing the + * moment the list becomes a filter result: rows enter and leave on every + * keystroke, and replaying a 250ms rise with up to 480ms of accumulated delay + * on each one reads as lag rather than polish. So it retires on the first + * keystroke and comes back the next time the dialog is opened. + */ + let savedStagger = $state(true); + + /** Move focus from the filter into the list, so a match can be reached without + * leaving the keyboard. Rows carry tabindex, so they take focus directly. */ + function focusFirstSavedRow() { + /** @type {HTMLElement | null} */ + const row = document.querySelector('[data-conn-row]'); + row?.focus(); + } let lastId = $state(getLastConnectionId()); let editingId = $state(/** @type {string|null} */ (null)); let connecting = $state(/** @type {string|null} */ (null)); @@ -1071,10 +1106,25 @@ engineMatches[0]?.drivers.find((d) => !DISABLED_TABS.has(d.id)) ?? null, ); + /** Same rule as the rail's `savedStagger`: the local-target cards are filtered + * by this search, so their entrance stagger has to stop once it would be + * replaying on every keystroke rather than introducing the panel. */ + let engineStagger = $state(true); + $effect(() => { if (open && step === "pick" && engineSearchEl) engineSearchEl.focus(); }); + // A fresh open is a fresh first impression: the rail's entrance stagger is + // armed again, and a filter left over from last time is not what you want to + // be looking at. + $effect(() => { + if (!open) return; + savedStagger = true; + engineStagger = true; + savedQuery = ""; + }); + // ── Connections rail: width + collapsed state, remembered ────────────────── const RAIL_KEY = "stroke:conn-rail"; const RAIL_MIN = 260; @@ -1121,6 +1171,21 @@ ); let localPhase = $state(/** @type {'idle'|'scanning'|'done'} */ ("idle")); + /** + * ⌘R / Ctrl+R — rescan. Reloads the saved list as well as the local scan, so + * one key means "show me what is actually there now". + * @param {KeyboardEvent} e + */ + function onRefreshKey(e) { + if (!open) return; + if (!(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey) return; + if (e.key.toLowerCase() !== "r") return; + e.preventDefault(); + e.stopPropagation(); + saved = loadSavedConnections().sort(byLastConnected); + void refreshLocal(); + } + async function refreshLocal() { localPhase = "scanning"; // Discovery is a convenience on both sides. A failure means "nothing found", @@ -1603,19 +1668,16 @@ + { if (!open) return; - if ( - (e.ctrlKey || e.metaKey) && - !e.altKey && - !e.shiftKey && - e.key.toLowerCase() === "r" - ) { - e.preventDefault(); - saved = loadSavedConnections().sort(byLastConnected); - void refreshLocal(); - } if ( (e.ctrlKey || e.metaKey) && !e.altKey && @@ -1897,7 +1959,7 @@ {#if saved.length > 0} {saved.length}{savedQuery ? `${savedMatches.length}/${saved.length}` : saved.length} {/if}
+ + {#if saved.length > 1} +
+
+ + + (savedStagger = false)} + onkeydown={(e) => { + if (e.key === "Escape" && savedQuery) { + // Clear first, close the dialog second — Escape on a + // non-empty filter should undo the filter, not the dialog. + e.preventDefault(); + e.stopPropagation(); + savedQuery = ""; + return; + } + // Enter takes the top match, the same way clicking it would. + if (e.key === "Enter" && firstSavedMatch) { + e.preventDefault(); + resetForm(firstSavedMatch); + return; + } + // Down arrow hands off to the list, so a filtered result can + // be reached without going back to the mouse. + if (e.key === "ArrowDown" && savedMatches.length) { + e.preventDefault(); + focusFirstSavedRow(); + } + }} + /> + {#if savedQuery} + + {/if} +
+
+ {/if} +
- {#if saved.length > 0} + {#if savedQuery && savedMatches.length === 0} + +
+

+ No connection matches “{savedQuery}”. +

+ +
+ {:else if saved.length > 0}
- {#each saved as conn, i (conn.id)} + {#each savedMatches as conn, i (conn.id)} {@const isSel = conn.id === editingId} {@const busy2 = connecting === conn.id} {@const cid = @@ -1944,18 +2090,32 @@ ? `${conn.type}-memory` : conn.type}
resetForm(conn)} ondblclick={() => { if (!connecting) void connectWith(conn); }} - onkeydown={(e) => e.key === "Enter" && resetForm(conn)} + onkeydown={(e) => { + if (e.key === "Enter") { resetForm(conn); return; } + // Arrow keys walk the list; Up off the top row returns + // to the filter, so the whole rail is one keyboard path. + if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return; + e.preventDefault(); + const rows = [...document.querySelectorAll("[data-conn-row]")]; + const next = rows.indexOf(e.currentTarget) + (e.key === "ArrowDown" ? 1 : -1); + if (next < 0) savedSearchEl?.focus(); + else /** @type {HTMLElement|undefined} */ (rows[next])?.focus(); + }} title="Click to edit · double-click to connect" > {#if isSel} @@ -2079,6 +2239,9 @@ id={activeDriver.id} class={cn("size-5 shrink-0", engineTint(activeDriver.id))} /> +

-

- {activeDriver.desc} -

{/if} @@ -2114,6 +2274,7 @@ placeholder="Search databases and local studios…" aria-label="Search databases" class="pl-9 pr-8" + oninput={() => (engineStagger = false)} onkeydown={(e) => { if (e.key !== "Enter" || !firstEngineMatch) return; e.preventDefault(); @@ -2228,9 +2389,12 @@ disabled={!t.conn || !!connecting} title={t.hint} onclick={() => void connectLocal(t)} - style="animation-delay: {Math.min(i, 8) * 35}ms" + style={engineStagger + ? `animation-delay: ${Math.min(i, 8) * 35}ms` + : ""} class={cn( - "cn-stagger-in group flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-left transition-[color,background-color,border-color,transform] duration-150 ease-out", + "group flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-left transition-[color,background-color,border-color,transform] duration-150 ease-out", + engineStagger && "cn-stagger-in", t.conn ? "border-border/50 text-foreground/90 hover:border-border hover:bg-muted/40 hover:text-foreground active:scale-[0.98] disabled:opacity-60" : "cursor-not-allowed border-border/30 text-muted-foreground/40", diff --git a/src/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte index bda8ec3a..13d04bde 100644 --- a/src/lib/components/DataTable.svelte +++ b/src/lib/components/DataTable.svelte @@ -4144,7 +4144,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; if (visibleColumns.length > 0) { drawHeaderRow(ctx, { W, cPanel, cFg, cMuted, cGrid, cBorder, cMutedBg, cAccent, cPrimary, cRing, - AMBER, AMBER_FG, BLUE_FG, usedW, + AMBER, AMBER_FG, BLUE_FG, RED, usedW, }) } @@ -4852,13 +4852,25 @@ import FilterX from "@lucide/svelte/icons/filter-x"; } const indReserve = indicators.length * 18 - // Column name - primary, medium weight. + // Required marker - a red asterisk ahead of the name on NOT NULL columns. + // The same mark a required form field carries, so it reads without a legend, + // and it answers "will this row insert" from the header instead of from the + // column menu. Its width comes out of the name's budget rather than being + // painted over it, so a long name still truncates against the right edge. ctx.font = _fonts.header - ctx.fillStyle = withAlpha(c.cFg, sorted ? 1 : 0.9) - const nameMaxW = w - CELL_PAD_X - sortReserve - indReserve - 8 + const required = !!meta && !meta.nullable + const reqW = required ? Math.ceil(textWidth(ctx, '*')) + Math.round(4 * canvasZoom) : 0 + + // Column name - primary, medium weight. + const nameMaxW = w - CELL_PAD_X - sortReserve - indReserve - reqW - 8 const name = truncText(ctx, col.name, Math.max(0, nameMaxW)) - ctx.fillText(name, x + CELL_PAD_X, cy + 0.5) - let tx = x + CELL_PAD_X + textWidth(ctx, name) + 7 + if (required) { + ctx.fillStyle = withAlpha(c.RED, 0.85) + ctx.fillText('*', x + CELL_PAD_X, cy + 0.5) + } + ctx.fillStyle = withAlpha(c.cFg, sorted ? 1 : 0.9) + ctx.fillText(name, x + CELL_PAD_X + reqW, cy + 0.5) + let tx = x + CELL_PAD_X + reqW + textWidth(ctx, name) + 7 // PK / FK glyphs (vertically centred, accent-coloured). for (const ind of indicators) { diff --git a/src/lib/components/DisconnectDialog.svelte b/src/lib/components/DisconnectDialog.svelte index 1e3810cc..09485646 100644 --- a/src/lib/components/DisconnectDialog.svelte +++ b/src/lib/components/DisconnectDialog.svelte @@ -20,5 +20,6 @@ confirmLabel="Disconnect" confirmIcon="unplug" variant="destructive" + confirmOnEnter onconfirm={ondisconnect} /> diff --git a/src/lib/components/EChartPanel.svelte b/src/lib/components/EChartPanel.svelte index 088f8f55..a11836c8 100644 --- a/src/lib/components/EChartPanel.svelte +++ b/src/lib/components/EChartPanel.svelte @@ -11,6 +11,19 @@ */ renderer = /** @type {'svg' | 'canvas'} */ ('canvas'), class: cls = '', + /** + * Merge option updates in place instead of rebuilding the chart, for as long + * as the option's structure is unchanged. Worth setting on a panel that is + * fed a fresh option on a timer. + * + * OPT-IN, and deliberately so. A merge cannot express a property going away, + * so it is only safe when the caller's option builder emits the same set of + * keys every time. The generic builders in `chart-utils.js` do NOT: e.g. a + * scatter series picks up `...(large ? { large: true } : {})` past 2000 + * points, and merging a later small result over it would leave the chart + * stuck in large mode. Those callers keep the full rebuild. + */ + mergeUpdates = false, } = $props() /** @type {HTMLDivElement | null} */ @@ -23,6 +36,8 @@ let io = null /** @type {number} rAF handle for coalescing resize bursts */ let resizeRaf = 0 + /** Fingerprint of the last applied option; '' until one is applied. */ + let lastShape = '' $effect(() => { const container = el @@ -85,18 +100,42 @@ io?.disconnect(); io = null ro?.disconnect(); ro = null chart?.dispose(); chart = null + lastShape = '' // a fresh chart has no prior option to merge into } }) + /** + * Structural fingerprint of an option: which series it declares (type + name) + * and the shape of its axes (count + type). Two options with the same + * fingerprint differ only in their data, which echarts can merge in place. + * Anything a merge could not express — a dropped series, a swapped axis kind — + * changes the fingerprint and falls back to a full rebuild. + * @param {any} o + */ + function shapeKey(o) { + const asList = (/** @type {any} */ v) => (Array.isArray(v) ? v : v ? [v] : []) + const series = asList(o?.series).map((s) => `${s?.type ?? ''}:${s?.name ?? ''}`).join('|') + const axis = (/** @type {any} */ v) => asList(v).map((a) => a?.type ?? '').join(',') + return `${series}#${axis(o?.xAxis)}#${axis(o?.yAxis)}` + } + // Keep chart in sync with option changes. + // notMerge tears the chart down and rebuilds every series. That is the correct + // default - it is the only update that can express a property going away - but + // it is pure waste for a panel on a refresh timer whose option keeps the same + // shape and only moves its numbers. Opted-in callers merge while the + // fingerprint holds and rebuild the moment it changes, which also lets echarts + // animate between the two states instead of restarting the enter animation. // lazyUpdate batches setOption calls that arrive in the same task - reduces // unnecessary redraws when multiple reactive updates fire together. $effect(() => { const c = chart const o = option - if (c && o && Object.keys(o).length > 0) { - c.setOption(o, { notMerge: true, lazyUpdate: true }) - } + if (!c || !o || Object.keys(o).length === 0) return + const shape = shapeKey(o) + const notMerge = !mergeUpdates || shape !== lastShape + lastShape = shape + c.setOption(o, { notMerge, lazyUpdate: true }) }) onDestroy(() => { diff --git a/src/lib/components/ExtensionsPage.svelte b/src/lib/components/ExtensionsPage.svelte index 5ea0225d..1f6dc70f 100644 --- a/src/lib/components/ExtensionsPage.svelte +++ b/src/lib/components/ExtensionsPage.svelte @@ -343,7 +343,7 @@ v && setPluginConfig(selected.id, { timeZone: v })}> {tzLabel(selected.id)} - {#each TIMEZONE_OPTIONS as tz (tz.value)}{tz.label}{/each} + {#each TIMEZONE_OPTIONS as tz (tz.value)}{tz.label}{/each} {/snippet} @@ -360,7 +360,7 @@ v && setPluginConfig(selected.id, { currency: v })}> {c.currency} - {#each CURRENCIES as cur (cur)}{cur}{/each} + {#each CURRENCIES as cur (cur)}{cur}{/each} {/snippet} @@ -373,7 +373,7 @@ v && setPluginConfig(selected.id, { unit: v })}> {DURATION_UNITS.find((u) => u.value === c.unit)?.label ?? "Auto"} - {#each DURATION_UNITS as u (u.value)}{u.label}{/each} + {#each DURATION_UNITS as u (u.value)}{u.label}{/each} {/snippet} @@ -392,7 +392,7 @@ v && setPluginConfig(selected.id, { palette: v })}> {HEAT_PALETTES.find((p) => p.value === c.palette)?.label ?? "Blue"} - {#each HEAT_PALETTES as p (p.value)}{p.label}{/each} + {#each HEAT_PALETTES as p (p.value)}{p.label}{/each} {/snippet} diff --git a/src/lib/components/InstanceInsightsPage.svelte b/src/lib/components/InstanceInsightsPage.svelte index 16147eb4..8185113a 100644 --- a/src/lib/components/InstanceInsightsPage.svelte +++ b/src/lib/components/InstanceInsightsPage.svelte @@ -39,15 +39,20 @@ let autoRefresh = $state(false) let refreshing = $state(false) let lastUpdated = $state(/** @type {number | null} */ (null)) - let version = $state(/** @type {any} */ (null)) - let activity = $state(/** @type {any} */ (null)) - let stateData = $state(/** @type {any} */ (null)) - let config = $state(/** @type {any[]} */ ([])) - let replication = $state(/** @type {any} */ (null)) + // $state.raw for every server payload: each one is replaced wholesale by its + // refresh fn and only ever read, never mutated in place. Deep $state would + // proxy every row object and every field — `config` alone is 350+ rows on + // Postgres and 600+ on MySQL, and `stateData` carries the session/lock lists — + // so the proxies cost more to build than the render they feed. + let version = $state.raw(/** @type {any} */ (null)) + let activity = $state.raw(/** @type {any} */ (null)) + let stateData = $state.raw(/** @type {any} */ (null)) + let config = $state.raw(/** @type {any[]} */ ([])) + let replication = $state.raw(/** @type {any} */ (null)) let configSearch = $state('') let error = $state('') /** @type {{ t: number, sessions: any, counters: any }[]} */ - let samples = $state([]) + let samples = $state.raw([]) const MAX_SAMPLES = 40 const REFRESH_MS = 5000 @@ -118,6 +123,11 @@ refreshing = true error = '' try { + // All five, in parallel, on open. Deferring config until its tab is opened + // was tried and reverted: the sub-tab bar shows a "restart pending" count + // derived from the full settings list, and that badge exists precisely to + // send you to a tab you would not otherwise open. The fetch is cheap now + // that the payload is $state.raw and the list renders behind a budget. await Promise.all([loadVersion(), refreshActivity(), refreshState(), refreshConfig(), refreshReplication()]) lastUpdated = Date.now() // Per-second rates (TPS, tuples, block I/O) are deltas between two samples, @@ -356,6 +366,75 @@ return [...map.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([group, rows]) => ({ group, rows })) }) + // ── Config render budget ─────────────────────────────────────────────────── + // The unfiltered list is 350+ rows on Postgres and 600+ on MySQL, and each row + // is a ~20-node subtree. Building all of them the instant the Config tab opens + // is a visible hitch for a list where about fifteen rows fit on screen. + // `content-visibility` already skips their layout and paint; this skips their + // construction too. + // + // Nothing ends up hidden: the budget refills itself during idle time until the + // whole list is in the DOM, so ctrl-F and assistive tech still see every row a + // moment after the tab opens. The scroll sentinel below is the safety net for + // a user who scrolls faster than idle fills. + const CONFIG_PAGE = 60 + let configRenderLimit = $state(CONFIG_PAGE) + + // Fill the rest of the list in idle slots, one page per slot. This is the + // difference between "the first paint is cheap" and "the list is incomplete" — + // only the former is wanted. + $effect(() => { + if (subtab !== 'config' || !active) return + if (configRenderLimit >= configFiltered.length) return + const ric = window.requestIdleCallback ?? ((/** @type {any} */ fn) => setTimeout(fn, 60)) + const cancel = window.cancelIdleCallback ?? clearTimeout + const handle = ric(() => { configRenderLimit += CONFIG_PAGE }, { timeout: 500 }) + return () => cancel(handle) + }) + + // A new filter is a new list: start it at one page instead of inheriting the + // budget the previous scroll had grown to. + $effect(() => { + void configGroup + void configStatus + void configSearch + configRenderLimit = CONFIG_PAGE + }) + + /** `configSections`, truncated to the current budget. */ + const configSectionsShown = $derived.by(() => { + let budget = configRenderLimit + const out = [] + for (const sec of configSections) { + if (budget <= 0) break + out.push(sec.rows.length <= budget ? sec : { ...sec, rows: sec.rows.slice(0, budget) }) + budget -= sec.rows.length + } + return out + }) + const configHasMore = $derived(configFiltered.length > configRenderLimit) + + /** + * Sentinel that extends the config render budget as it approaches the viewport. + * @type {import('svelte/action').Action} + */ + function revealMoreConfig(node) { + const io = new IntersectionObserver( + (entries) => { + if (!entries[0].isIntersecting) return + configRenderLimit += CONFIG_PAGE + // Re-arm. If the sentinel is still in view after the list grew, there is + // no intersection *change* for the observer to report, and the list + // would stall short of the end. Re-observing redelivers immediately. + io.unobserve(node) + io.observe(node) + }, + { rootMargin: '300px' }, + ) + io.observe(node) + return { destroy: () => io.disconnect() } + } + function clearConfigFilters() { configGroup = 'all'; configStatus = 'all'; configSearch = '' } @@ -805,7 +884,7 @@
{:else}
- {#each configSections as sec (sec.group || '_flat')} + {#each configSectionsShown as sec (sec.group || '_flat')}
{#if sec.group}
@@ -955,6 +1034,11 @@
{/each} + {#if configHasMore} + + + {/if}
{/if} @@ -1069,7 +1153,10 @@
{#if c.ready} -
+ +
{:else}

Collecting samples…

@@ -1110,12 +1197,16 @@ {#if !rows || rows.length === 0}
{empty}
{:else} + + {@const cols = keysOf(rows)}
- {#each keysOf(rows) as k (k)} + {#each cols as k (k)} {/each} @@ -1124,16 +1215,18 @@ {#each rows as row, i (i)} - {#each keysOf(rows) as k (k)} - {/each} diff --git a/src/lib/components/JsonCellLightbox.svelte b/src/lib/components/JsonCellLightbox.svelte index fcdb0f1b..3b69bb26 100644 --- a/src/lib/components/JsonCellLightbox.svelte +++ b/src/lib/components/JsonCellLightbox.svelte @@ -1,4 +1,6 @@ @@ -181,6 +188,7 @@ {data.colName}
+ + + {/each} + + + +
+ {#snippet key(/** @type {string} */ k)} + {k} + {/snippet} + {@render key('↑')}{@render key('↓')} move + {@render key('↵')}{@render key('tab')} accept + {@render key('esc')} dismiss + {items.length} +
+
diff --git a/src/lib/components/JsonViewer.svelte b/src/lib/components/JsonViewer.svelte index faf8d44b..d4ed8ac9 100644 --- a/src/lib/components/JsonViewer.svelte +++ b/src/lib/components/JsonViewer.svelte @@ -8,15 +8,35 @@ import Copy from '@lucide/svelte/icons/copy' import Download from '@lucide/svelte/icons/download' import CheckCheck from '@lucide/svelte/icons/check-check' - import { evalJsonPath, getCompletions, applyCompletion, describeResult } from '$lib/jsonpath.js' + import JsonWrapToggle from './JsonWrapToggle.svelte' + import JsonPathSuggest from './JsonPathSuggest.svelte' + import { appJsonWordWrap } from '$lib/stores/settings.js' + import { cn } from '$lib/utils.js' + import { evalJsonPath, getCompletionItems, applyCompletion, describeResult } from '$lib/jsonpath.js' let { json = '[]', + /** + * The already-parsed value, when the caller has one. + * + * Passing it skips a full `JSON.parse` of `json`. The callers build these + * objects, stringify them for the editor, and this component used to parse + * that string straight back into the same shape purely so JSONPath had + * something to walk — a third pass over the whole payload, and a second + * copy of it in memory, to recover data the caller was already holding. + */ + data = /** @type {any} */ (undefined), + /** Rows actually rendered, when the caller bounded what it handed over. */ + shownRows = 0, rowCount = 0, onshowtable = () => {}, ondownload = /** @type {(() => void) | undefined} */ (undefined), } = $props() + /** True when the caller trimmed the result to keep this view responsive. */ + const truncated = $derived(shownRows > 0 && rowCount > shownRows) + + /** @type {HTMLElement | null} */ let container = $state(null) /** @type {monaco.editor.IStandaloneCodeEditor | null} */ @@ -33,6 +53,7 @@ let pathInput = $state(null) const parsedJson = $derived.by(() => { + if (data !== undefined) return data try { return JSON.parse(json) } catch { return null } }) @@ -48,15 +69,24 @@ return JSON.stringify(pathResult.value, null, 2) }) + // The rich items, not just their insert strings. `kind`, `detail` and + // `preview` were being computed and then thrown away, which is why every row + // read `.key auth_name .auth_name` — the widget was reconstructing a worse + // version of data it already had. const completions = $derived.by(() => { if (!pathFocused || parsedJson === null) return [] - return getCompletions(parsedJson, jsonPath).slice(0, 8) + return getCompletionItems(parsedJson, jsonPath).slice(0, 12) }) + // Nothing selected means Enter does nothing, which is the wrong default for a + // list that is already filtered to what you typed. The top match is armed, so + // Tab or Enter takes it straight away. $effect(() => { - if (!pathFocused || completions.length === 0) activeIdx = -1 + void completions + activeIdx = completions.length ? 0 : -1 }) + /** @param {string} completion */ function pickCompletion(completion) { jsonPath = applyCompletion(jsonPath, completion) @@ -75,7 +105,7 @@ activeIdx = (activeIdx - 1 + completions.length) % completions.length } else if ((e.key === 'Tab' || e.key === 'Enter') && activeIdx >= 0) { e.preventDefault() - pickCompletion(completions[activeIdx]) + pickCompletion(completions[activeIdx].insert) } else if (e.key === 'Escape') { activeIdx = -1 pathFocused = false @@ -118,7 +148,7 @@ fontWeight: 'normal', padding: { top: 12, bottom: 12 }, scrollBeyondLastLine: false, - wordWrap: 'off', + wordWrap: $appJsonWordWrap ? 'on' : 'off', renderLineHighlight: 'none', lineNumbers: 'on', // 4 (not 3) so the right-aligned numbers get a character of inset instead @@ -166,6 +196,9 @@ if (!editor) return if (editor.getValue() !== displayedJson) editor.setValue(displayedJson) }) + + // Wrap is an app setting, so a change made anywhere reflows this editor too. + $effect(() => { editor?.updateOptions({ wordWrap: $appJsonWordWrap ? 'on' : 'off' }) })
@@ -191,40 +224,31 @@ {describeResult(pathResult.value)} {/if} - {#if pathFocused && completions.length > 0} - -
    e.preventDefault()} - > - {#each completions as completion, i (completion)} -
  • - -
  • - {/each} -
+ pickCompletion(insert)} + /> {/if}
- {#if rowCount > 0} + {#if truncated} + + {shownRows.toLocaleString()} of {rowCount.toLocaleString()} rows + {:else if rowCount > 0} {rowCount} rows
{/if} + + - - {/each} - + pickCompletion(insert)} + /> {/if}
diff --git a/src/lib/components/JsonWrapToggle.svelte b/src/lib/components/JsonWrapToggle.svelte new file mode 100644 index 00000000..4a311507 --- /dev/null +++ b/src/lib/components/JsonWrapToggle.svelte @@ -0,0 +1,44 @@ + + + diff --git a/src/lib/components/KeyboardShortcutsDialog.svelte b/src/lib/components/KeyboardShortcutsDialog.svelte index ace73361..b9d43bab 100644 --- a/src/lib/components/KeyboardShortcutsDialog.svelte +++ b/src/lib/components/KeyboardShortcutsDialog.svelte @@ -13,142 +13,38 @@ import Settings from "@lucide/svelte/icons/settings"; import X from "@lucide/svelte/icons/x"; + import { SHORTCUT_GROUPS, keycaps, comboText, IS_MAC } from "$lib/shortcuts.js"; + let { open = $bindable(false) } = $props(); - const isMac = - typeof navigator !== "undefined" && /mac/i.test(navigator.platform); + // Platform flag comes from the index too, so this dialog and the bindings + // agree on what machine they are running on. + const isMac = IS_MAC; const mod = isMac ? "⌘" : "Ctrl"; - const opt = isMac ? "⌥" : "Alt"; + + // The shortcut list lives in $lib/shortcuts.js. It used to be written out here + // as per-platform keycap arrays, which meant this dialog could — and did — + // describe a binding the app no longer had. Groups now carry an icon NAME so + // that module can stay plain data; the components stay here. + /** @type {Record} */ + const GROUP_ICONS = { + navigation: Navigation, + monitor: Monitor, + terminal: Terminal, + "code-2": Code2, + "table-2": Table2, + bot: Bot, + palette: Palette, + settings: Settings, + }; /** - * @typedef {{ keys: string[]; desc: string }} Shortcut - * @typedef {{ label: string; icon: any; shortcuts: Shortcut[] }} Group + * @typedef {{ combo: string; desc: string }} Shortcut + * @typedef {{ label: string; icon: string; shortcuts: Shortcut[] }} Group */ /** @type {Group[]} */ - const groups = [ - { - label: "Navigation", - icon: Navigation, - shortcuts: [ - { keys: [mod, "K"], desc: "Command menu" }, - { keys: [mod, "⇧", "P"], desc: "Command menu" }, - { keys: [mod, opt, "1–9"], desc: "Switch to saved connection" }, - { keys: [mod, "T"], desc: "Search tables" }, - { keys: [mod, "N"], desc: "New tab" }, - { keys: [mod, "W"], desc: "Close tab" }, - { keys: [mod, "Tab"], desc: "Next tab" }, - { keys: [mod, "⇧", "Tab"], desc: "Previous tab" }, - { keys: [mod, "⇧", "T"], desc: "Reopen closed tab" }, - { keys: [mod, "1–9"], desc: "Go to tab (9 = last)" }, - { keys: [opt, "⇧", "T"], desc: "Toggle tab bar" }, - { 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: [mod, opt, "D"], desc: "Disconnect" }, - { keys: [mod, "/"], desc: "Keyboard shortcuts" }, - { keys: ["F11"], desc: "Toggle fullscreen" }, - ], - }, - { - label: "Views", - icon: Monitor, - shortcuts: [ - { keys: [mod, "⇧", "D"], desc: "Data view" }, - { keys: [mod, "⇧", "S"], desc: "SQL editor" }, - { keys: [mod, "⇧", "O"], desc: "ORM Runner" }, - { keys: [mod, "⇧", "X"], desc: "Extensions" }, - { keys: [mod, "⇧", "E"], desc: "Toggle AI panel" }, - { keys: [mod, "I"], desc: "Toggle AI sidebar" }, - { keys: [mod, "⇧", "L"], desc: "Activity log" }, - { keys: [mod, "R"], desc: "Refresh current view" }, - { keys: [mod, "⇧", "V"], desc: "Cycle table data view" }, - { keys: [opt, "1–5"], desc: "Table / JSON / Record / Text / Chart view" }, - { keys: [mod, opt, "F"], desc: "Find & replace in table" }, - ], - }, - { - label: "SQL Editor", - icon: Terminal, - shortcuts: [ - { keys: [mod, "↵"], desc: "Run all statements" }, - { keys: [mod, "R"], desc: "Run statement at cursor" }, - { keys: [mod, "L"], desc: "Select current statement" }, - { keys: [mod, "S"], desc: "Save query" }, - { keys: [mod, "J"], desc: "Toggle output panel" }, - { keys: [mod, "⇧", "B"], desc: "Query history" }, - ], - }, - { - label: "ORM Runner", - icon: Code2, - shortcuts: [ - { keys: [mod, "↵"], desc: "Run query" }, - { keys: [mod, "S"], desc: "Format code" }, - ], - }, - { - label: "Data Table", - icon: Table2, - shortcuts: [ - { keys: [mod, "F"], desc: "Search rows" }, - { keys: [opt, "⇧", "F"], desc: "Open filter menu" }, - { keys: [opt, "⇧", "S"], desc: "Open sort menu" }, - { keys: [opt, "⇧", "C"], desc: "Open columns menu" }, - { keys: [opt, "⇧", "R"], desc: "Reset table view (clear filters/sort/search)" }, - { keys: ["↵", "F2"], desc: "Edit cell" }, - { keys: ["Esc"], desc: "Cancel edit" }, - { keys: [mod, "↵"], desc: "Navigate to FK row" }, - { keys: [mod, "C"], desc: "Copy cell value" }, - { keys: [mod, "⌫"], desc: "Delete selected rows" }, - { keys: [mod, "A"], desc: "Select all rows" }, - { keys: [mod, "Z"], desc: "Undo cell edit" }, - { keys: [mod, "⇧", "Z"], desc: "Redo cell edit" }, - { keys: [mod, "↑"], desc: "Scroll to top" }, - { keys: [mod, "↓"], desc: "Scroll to bottom" }, - { keys: [mod, "←"], desc: "Previous page" }, - { keys: [mod, "→"], desc: "Next page" }, - { keys: [mod, "⇧", "←"], desc: "First page" }, - { keys: [mod, "⇧", "→"], desc: "Last page" }, - { keys: [opt, "⇧", "1–5"], desc: "Jump to pinned table" }, - ], - }, - { - label: "AI Chat", - icon: Bot, - shortcuts: [ - { keys: ["↵"], desc: "Send message" }, - { keys: ["⇧", "↵"], desc: "New line" }, - { keys: [mod, "⇧", "B"], desc: "Toggle conversation list" }, - { keys: [mod, "⇧", "T"], desc: "New conversation" }, - ], - }, - { - label: "Appearance", - icon: Palette, - shortcuts: [ - { keys: [mod, "B"], desc: "Toggle sidebar" }, - { keys: [mod, "⇧", "T"], desc: "Toggle tab bar" }, - { keys: [mod, "⇧", "B"], desc: "Toggle status bar" }, - { keys: [mod, "M"], desc: "Cycle theme" }, - { keys: [mod, "⇧", "M"], desc: "Previous theme" }, - { keys: [mod, "+"], desc: "Zoom in" }, - { keys: [mod, "−"], desc: "Zoom out" }, - { keys: [mod, "0"], desc: "Reset zoom" }, - ], - }, - { - label: "General", - icon: Settings, - shortcuts: [ - { keys: [mod, "?"], desc: "Keyboard shortcuts" }, - { keys: ["?"], desc: "Keyboard shortcuts" }, - { keys: [mod, ","], desc: "Settings" }, - { keys: ["Esc"], desc: "Dismiss / close" }, - ], - }, - ]; + const groups = SHORTCUT_GROUPS; let query = $state(""); let selectedGroup = $state(groups[0].label); @@ -176,7 +72,7 @@ shortcuts: g.shortcuts.filter( (s) => s.desc.toLowerCase().includes(q) || - s.keys.join(" ").toLowerCase().includes(q), + comboText(s.combo).includes(q), ), })) .filter((g) => g.shortcuts.length > 0); @@ -264,7 +160,7 @@ {#if !isSearching}
+ + + {#snippet mark()}{/snippet} + {#snippet action()} + + {/snippet} + + { if (e.key === 'Enter') saveToken() }} + /> {:else} -
- -

Opens your browser to authorize · secure PKCE flow

-
+ + {#snippet mark()}{/snippet} + {#snippet action()} + + {/snippet} + {/if} {:else if phase === 'authorizing'} -
-
- -
- - -
-
-

Waiting for {meta?.name}…

-

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

-
-
- -
- -
-
- Times out in 5 min - -
-
+ + {#snippet mark()}{/snippet} + {#snippet action()} + + {/snippet} + {:else if phase === 'fetching'} -
- Loading your databases… -
+ + {#snippet mark()}{/snippet} + + + {:else if phase === 'error'} + + {#snippet mark()}{/snippet} + {#snippet action()} + + {/snippet} + {:else if phase === 'selecting' || phase === 'building' || phase === 'password'} @@ -449,55 +455,6 @@
{/if} - {:else if phase === 'error'} -
-
-
- -
-
-

{shownError.title}

-

{shownError.detail}

-
-
-
- -
-
{/if}
- diff --git a/src/lib/components/RowExpandViewer.svelte b/src/lib/components/RowExpandViewer.svelte index 46573b21..c64e7793 100644 --- a/src/lib/components/RowExpandViewer.svelte +++ b/src/lib/components/RowExpandViewer.svelte @@ -1,4 +1,5 @@ + + bestScore || (score === bestScore && score > 0 && best && c.docIndex < best.docIndex)) { + best = c + bestScore = score + } + } + return bestScore > 0 ? best : null +} + +/** + * Is the user typing? "/" and "?" are ordinary characters in a text field, so + * neither may be stolen while one has focus. + * + * Monaco and CodeMirror are checked by container rather than by tag: both put a + * real textarea behind the cursor, but so does a plain form, and the editors + * also route keys through elements that are not textareas at all. + * + * @param {EventTarget | null} target + * @returns {boolean} + */ +export function isTypingTarget(target) { + if (!(target instanceof HTMLElement)) return false + if (target.closest('.monaco-editor, .cm-editor')) return true + if (target.isContentEditable) return true + const tag = target.tagName + return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' +} + +/** + * The search box for what is currently on screen, if there is one. + * + * Scoped to the topmost dialog when one is open: a dialog owns the screen, and + * focusing a field on the page behind it would type into something the user + * cannot see. + * + * @param {Document | HTMLElement} [root] + * @returns {HTMLInputElement | null} + */ +export function findSearchInput(root = document) { + const doc = root instanceof Document ? root : root.ownerDocument + if (!doc) return null + + const dialogs = [...doc.querySelectorAll('[data-slot="dialog-content"], [role="dialog"]')] + .filter((d) => d instanceof HTMLElement && d.offsetParent !== null) + const scope = dialogs.length ? /** @type {HTMLElement} */ (dialogs[dialogs.length - 1]) : root + + /** @type {SearchCandidate[]} */ + const candidates = [] + /** @type {HTMLInputElement[]} */ + const nodes = [] + + const all = /** @type {HTMLElement} */ (/** @type {unknown} */ (scope)).querySelectorAll?.('input') ?? [] + let i = 0 + for (const el of all) { + if (!(el instanceof HTMLInputElement)) continue + if (el.disabled || el.readOnly) continue + // offsetParent is null for display:none and for anything inside it, which is + // how the app hides inactive tabs — exactly the fields that must not win. + if (el.offsetParent === null) continue + if (el.getClientRects().length === 0) continue + candidates.push({ + marked: el.hasAttribute('data-search-input'), + type: (el.type || 'text').toLowerCase(), + placeholder: el.placeholder || '', + label: el.getAttribute('aria-label') || '', + docIndex: i, + }) + nodes.push(el) + i++ + } + + const best = pickSearchInput(candidates) + return best ? (nodes[best.docIndex] ?? null) : null +} diff --git a/src/lib/focus-search.test.js b/src/lib/focus-search.test.js new file mode 100644 index 00000000..cb2dc8fb --- /dev/null +++ b/src/lib/focus-search.test.js @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest' +import { scoreSearchInput, pickSearchInput } from './focus-search.js' + +/** @param {Partial} over */ +const c = (over = {}) => ({ + marked: false, + type: 'text', + placeholder: '', + label: '', + docIndex: 0, + ...over, +}) + +describe('scoreSearchInput', () => { + it('ignores a field that claims nothing', () => { + // A password box, a row editor, a port number — "/" must never land there. + expect(scoreSearchInput(c({ placeholder: 'localhost' }))).toBe(0) + expect(scoreSearchInput(c({ type: 'password' }))).toBe(0) + }) + + it('trusts an explicit marker over any guess', () => { + const marked = scoreSearchInput(c({ marked: true })) + expect(marked).toBeGreaterThan(scoreSearchInput(c({ type: 'search' }))) + expect(marked).toBeGreaterThan(scoreSearchInput(c({ placeholder: 'Search tables…' }))) + }) + + it('trusts a declared type over wording', () => { + // type="search" is a statement; a placeholder that happens to say "filter" + // is a coincidence of language. + expect(scoreSearchInput(c({ type: 'search' }))) + .toBeGreaterThan(scoreSearchInput(c({ placeholder: 'Filter connections…' }))) + }) + + it('reads the placeholders the app actually uses', () => { + for (const p of ['Search tables…', 'Filter…', 'Filter keys…', 'Search settings…', 'Search logs…']) { + expect(scoreSearchInput(c({ placeholder: p })), p).toBeGreaterThan(0) + } + }) + + it('falls back to the accessible name when there is no placeholder', () => { + expect(scoreSearchInput(c({ label: 'Filter saved connections' }))).toBeGreaterThan(0) + }) + + it('does not match a word that merely contains one', () => { + // "Researcher" contains "search"; a word boundary keeps it out. + expect(scoreSearchInput(c({ placeholder: 'Researcher name' }))).toBe(0) + }) +}) + +describe('pickSearchInput', () => { + it('returns nothing when the screen has no search box', () => { + expect(pickSearchInput([c({ placeholder: 'Port' }), c({ type: 'password' })])).toBeNull() + }) + + it('prefers the marked field over a better-worded one', () => { + const best = pickSearchInput([ + c({ placeholder: 'Search everything…', docIndex: 0 }), + c({ marked: true, placeholder: '', docIndex: 1 }), + ]) + expect(best?.docIndex).toBe(1) + }) + + it('breaks ties by document order', () => { + // Toolbars sit above the content they filter, so the first match down the + // page belongs to the surface in front of you. + const best = pickSearchInput([ + c({ placeholder: 'Search rows…', docIndex: 3 }), + c({ placeholder: 'Search columns…', docIndex: 1 }), + ]) + expect(best?.docIndex).toBe(1) + }) +}) diff --git a/src/lib/jsonpath.js b/src/lib/jsonpath.js index 0b10e962..e58a21b8 100644 --- a/src/lib/jsonpath.js +++ b/src/lib/jsonpath.js @@ -282,8 +282,14 @@ export function evalJsonPath(root, path) { * }} CompletionItem */ -/** @param {string} path @returns {{ prefix: string, token: string }} */ -function splitPath(path) { +/** + * Split a typed path into the part that is settled and the fragment still being + * typed. Exported so the completion widget can bold the characters you actually + * typed inside each suggestion — matching without showing the match is what made + * the old list read as unrelated noise. + * @param {string} path @returns {{ prefix: string, token: string }} + */ +export function splitPath(path) { if (!path || path === '$') return { prefix: '$', token: '' } for (let i = path.length - 1; i >= 1; i--) { const ch = path[i] diff --git a/src/lib/markdown-highlight.js b/src/lib/markdown-highlight.js index fbb9229e..0f689124 100644 --- a/src/lib/markdown-highlight.js +++ b/src/lib/markdown-highlight.js @@ -1,6 +1,16 @@ /** @typedef {import('$lib/themes/registry.js').ThemeId} ThemeId */ -import { highlightCode, resolveShikiLang } from '$lib/shiki-highlighter.js' +// Imported lazily, not statically: this module is reached from AiMarkdown, which +// CommandPalette pulls into the boot chunk. A static import would drag Shiki (and +// the grammar/theme graph behind it) into app startup even for users who never +// render a markdown code block. `highlightMarkdownHtml` is already async, so the +// extra await costs nothing at the call site; the module promise dedups. +/** @type {Promise | null} */ +let shikiModule = null +function loadShiki() { + if (!shikiModule) shikiModule = import('$lib/shiki-highlighter.js') + return shikiModule +} /** @param {string} className */ function langFromCodeClass(className) { @@ -21,6 +31,11 @@ export async function highlightMarkdownHtml(html, theme = 'dark') { if (!root) return html const pres = [...root.querySelectorAll('pre')] + // Nothing to highlight → never pay for the Shiki chunk at all. Still returns + // the parsed markup, not the input, so the output stays byte-identical to what + // the highlighting path produces for the same input. + if (pres.length === 0) return root.innerHTML + const { highlightCode, resolveShikiLang } = await loadShiki() await Promise.all( pres.map(async (pre) => { const code = pre.querySelector('code') diff --git a/src/lib/orm-schema.js b/src/lib/orm-schema.js index 6794ec01..79e72f20 100644 --- a/src/lib/orm-schema.js +++ b/src/lib/orm-schema.js @@ -756,3 +756,147 @@ export function renderDrizzleSchema(model) { if (ormImports.size) header.push(`import { ${[...ormImports].sort().join(', ')} } from 'drizzle-orm'`) return `${header.join('\n')}\n\n${body.join('\n').trimEnd()}\n` } + +// ── SQL (DDL) ──────────────────────────────────────────────────────────────── +// +// The schema as the statements that would rebuild it: CREATE TABLE, the +// indexes, and the foreign keys. Unlike the ORM targets this one describes +// every engine, because it is the engine's own language. +// +// Foreign keys are emitted as ALTER TABLE after every table exists, rather than +// inline REFERENCES. Inline constraints only work if the parent table is +// created first, and a schema with a cycle (or simply alphabetical ordering +// that puts the child first) cannot satisfy that at all. + +/** Every engine can be described as DDL — it is their own language. */ +export const SQL_ENGINES = [ + 'postgres', 'cockroachdb', 'mysql', 'mariadb', + 'sqlite', 'libsql', 'd1', 'mssql', 'duckdb', 'clickhouse', +] + +/** Identifier quoting per family. @param {string} name @param {string} family */ +function sqlIdent(name, family) { + if (family === 'mysql') return `\`${name.replace(/`/g, '``')}\`` + if (family === 'mssql') return `[${name.replace(/]/g, ']]')}]` + return `"${name.replace(/"/g, '""')}"` +} + +/** A literal safe to paste into DDL. @param {string} text */ +function sqlText(text) { + return `'${String(text).replace(/'/g, "''")}'` +} + +/** + * The live schema as the DDL that would recreate it. + * @param {OrmSchemaModel} model + * @param {{ qualify?: boolean }} [opts] qualify - prefix names with the schema, + * which a whole-database dump needs and a single-schema one does not. + * @returns {string} + */ +export function renderSqlSchema(model, opts = {}) { + const fam = model.family + const id = (/** @type {string} */ n) => sqlIdent(n, fam) + // Postgres and MSSQL namespace tables; MySQL's "schema" IS the database and + // SQLite has none, so qualifying there would name something that isn't real. + const canQualify = fam === 'postgres' || fam === 'mssql' + const qualify = !!opts.qualify && canQualify + const rel = (/** @type {string} */ t) => (qualify ? `${id(model.schema)}.${id(t)}` : id(t)) + + const out = [] + out.push(`-- SQL schema generated by Stroke from the live database.`) + out.push(`-- Schema: ${model.schema}`) + if (model.skipped.length) { + out.push(`-- Views not shown (${model.skipped.length}): ${model.skipped.slice(0, 8).join(', ')}${model.skipped.length > 8 ? ', …' : ''}`) + } + out.push('') + + if (qualify) { + out.push(`CREATE SCHEMA IF NOT EXISTS ${id(model.schema)};`) + out.push('') + } + + // Enum types have to exist before any column can be declared with one. + if (fam === 'postgres' && model.enums.length) { + for (const e of model.enums) { + const name = qualify ? `${id(model.schema)}.${id(e.name)}` : id(e.name) + out.push(`CREATE TYPE ${name} AS ENUM (${e.values.map(sqlText).join(', ')});`) + } + out.push('') + } + + for (const t of model.tables) { + const lines = [`CREATE TABLE ${rel(t.name)} (`] + const parts = [] + for (const c of t.columns) { + let def = ` ${id(c.name)} ${c.dataType || c.baseType || 'text'}` + if (c.isArray && fam === 'postgres' && !/\[\]$/.test(def)) def += '[]' + if (!c.nullable) def += ' NOT NULL' + // The introspected default is already engine-native (`now()`, + // `nextval(...)`), so it is emitted verbatim rather than re-quoted. + if (c.defaultValue != null && c.defaultValue !== '') def += ` DEFAULT ${c.defaultValue}` + parts.push(def) + } + if (t.primaryKey.length) { + parts.push(` PRIMARY KEY (${t.primaryKey.map(id).join(', ')})`) + } + lines.push(parts.join(',\n')) + lines.push(');') + out.push(lines.join('\n')) + + // Single-column uniques are already on the column; anything else is an index. + for (const idx of t.indexes) { + if (idx.columns.some((c) => !t.columns.find((x) => x.name === c))) continue + if (t.primaryKey.length && idx.columns.join(',') === t.primaryKey.join(',')) continue + const kind = idx.unique ? 'CREATE UNIQUE INDEX' : 'CREATE INDEX' + out.push(`${kind} ${id(idx.name)} ON ${rel(t.name)} (${idx.columns.map(id).join(', ')});`) + } + out.push('') + } + + const fks = [] + for (const t of model.tables) { + for (const c of t.columns) { + if (!c.references) continue + fks.push( + `ALTER TABLE ${rel(t.name)}\n` + + ` ADD CONSTRAINT ${id(`${t.name}_${c.name}_fkey`)}\n` + + ` FOREIGN KEY (${id(c.name)}) REFERENCES ${rel(c.references.table)} (${id(c.references.column)});`, + ) + } + } + if (fks.length) { + out.push('-- Foreign keys, after every table exists.') + out.push(...fks) + out.push('') + } + + return `${out.join('\n').trimEnd()}\n` +} + +/** + * Every schema in the database, as one script. + * + * Concatenation is valid here in a way it is not for the ORM targets: DDL + * names are schema-qualified, so two schemas owning a `users` table produce two + * distinct statements rather than a collision. + * @param {OrmSchemaModel[]} models + */ +export function renderSqlDatabase(models) { + if (!models.length) return '-- No schemas to describe.\n' + const head = [ + '-- SQL schema generated by Stroke from the live database.', + `-- Whole database: ${models.length} ${models.length === 1 ? 'schema' : 'schemas'} (${models.map((m) => m.schema).join(', ')})`, + '', + ] + const parts = models.map((m) => { + const banner = `-- ${'─'.repeat(70)}\n-- ${m.schema}\n-- ${'─'.repeat(70)}` + // Drop the per-schema preamble: the header above already said all of it. + const body = renderSqlSchema(m, { qualify: true }) + .split('\n') + .filter((l) => !l.startsWith('-- SQL schema generated') && !l.startsWith('-- Schema: ')) + .join('\n') + .trim() + return `${banner}\n\n${body}` + }) + return `${head.join('\n')}\n${parts.join('\n\n')}\n` +} diff --git a/src/lib/orm-schema.test.js b/src/lib/orm-schema.test.js index 2636597a..13899d4c 100644 --- a/src/lib/orm-schema.test.js +++ b/src/lib/orm-schema.test.js @@ -3,6 +3,8 @@ import { buildOrmSchemaModel, renderPrismaSchema, renderDrizzleSchema, + renderSqlSchema, + renderSqlDatabase, toCamel, toPascal, } from './orm-schema.js' @@ -303,3 +305,80 @@ describe('other engines', () => { expect(out).not.toContain('@db.Uuid') }) }) + +describe('renderSqlSchema', () => { + it('writes a table with its columns, nullability and primary key', () => { + const out = renderSqlSchema(pgFixture()) + expect(out).toContain('CREATE TABLE "users" (') + expect(out).toMatch(/"id"\s+\S+\s+NOT NULL/) + expect(out).toContain('PRIMARY KEY (') + }) + + it('creates enum types before the tables that use them', () => { + const out = renderSqlSchema(pgFixture()) + const type = out.indexOf('CREATE TYPE') + const table = out.indexOf('CREATE TABLE') + expect(type).toBeGreaterThan(-1) + expect(type).toBeLessThan(table) + }) + + it('emits foreign keys as ALTER after every table exists', () => { + const out = renderSqlSchema(pgFixture()) + const lastCreate = out.lastIndexOf('CREATE TABLE') + const firstFk = out.indexOf('ADD CONSTRAINT') + expect(firstFk).toBeGreaterThan(-1) + // Inline REFERENCES would require the parent to be created first, which no + // ordering can guarantee once the schema has a cycle. + expect(firstFk).toBeGreaterThan(lastCreate) + expect(out).toContain('FOREIGN KEY') + }) + + it('leaves views out, as the ORM targets do', () => { + expect(renderSqlSchema(pgFixture())).not.toContain('"active_users"') + }) + + it('quotes identifiers the way each engine does', () => { + const my = buildOrmSchemaModel({ + schema: 'app', + dbType: 'mysql', + tables: [{ name: 'users', kind: 'table' }], + structures: [{ table: 'users', columns: [{ name: 'id', dataType: 'int', isNullable: false }] }], + }) + const out = renderSqlSchema(my) + expect(out).toContain('CREATE TABLE `users`') + expect(out).not.toContain('CREATE TABLE "users"') + }) + + it('only schema-qualifies where a schema is a real namespace', () => { + // Postgres qualifies; MySQL's "schema" IS the database, so a prefix there + // would name something that does not exist. + expect(renderSqlSchema(pgFixture(), { qualify: true })).toContain('"public"."users"') + const my = buildOrmSchemaModel({ + schema: 'app', + dbType: 'mysql', + tables: [{ name: 'users', kind: 'table' }], + structures: [{ table: 'users', columns: [{ name: 'id', dataType: 'int', isNullable: false }] }], + }) + expect(renderSqlSchema(my, { qualify: true })).not.toContain('`app`.`users`') + }) +}) + +describe('renderSqlDatabase', () => { + it('keeps same-named tables from different schemas apart', () => { + const a = pgFixture() + const out = renderSqlDatabase([a, { ...a, schema: 'audit' }]) + expect(out).toContain('"public"."users"') + expect(out).toContain('"audit"."users"') + expect(out).toContain('CREATE SCHEMA IF NOT EXISTS "audit"') + }) + + it('states the preamble once, not per schema', () => { + const a = pgFixture() + const out = renderSqlDatabase([a, { ...a, schema: 'audit' }]) + expect(out.match(/SQL schema generated by Stroke/g)).toHaveLength(1) + }) + + it('says so rather than returning nothing when there are no schemas', () => { + expect(renderSqlDatabase([])).toContain('No schemas') + }) +}) diff --git a/src/lib/settings-consistency.test.js b/src/lib/settings-consistency.test.js index 5318ca7e..e7c4350c 100644 --- a/src/lib/settings-consistency.test.js +++ b/src/lib/settings-consistency.test.js @@ -91,4 +91,81 @@ describe('settings option lists stay in step with their consumers', () => { const offenders = files.filter((f) => /\.download\s*=/.test(read(f))) expect(offenders).toEqual([]) }) + + it('defaults the agent fonts to a size the picker actually offers', () => { + // loadSettings only keeps a stored value that is in AGENT_FONT_SIZES, and + // the picker is built from the same list. A default outside it would show + // an empty select and silently snap back on the next load. + const sizes = listBlock(settings, 'AGENT_FONT_SIZES') + .match(/\d+/g) + .map(Number) + for (const name of ['DEFAULT_AGENT_CHAT_FONT', 'DEFAULT_AGENT_CODE_FONT', 'LEGACY_AGENT_CHAT_FONT', 'LEGACY_AGENT_CODE_FONT']) { + const m = settings.match(new RegExp(`${name} = (\\d+)`)) + expect(m, `${name} not found — was it renamed?`).toBeTruthy() + expect(sizes, `${name} = ${m[1]} is not offered by the picker`).toContain(Number(m[1])) + } + }) +}) + +describe('JSON word wrap reaches every JSON view', () => { + const settings = read('./stores/settings.js') + + it('is a real setting, not just a store', () => { + // Each of these is a separate place the key has to appear: the defaults, + // the parse (absent means off), the reactive store, and the sync that + // pushes a loaded value into it. Miss one and the toggle silently forgets. + expect(settings).toMatch(/jsonWordWrap: false/) + expect(settings).toMatch(/parsed\.jsonWordWrap === true/) + expect(settings).toMatch(/export const appJsonWordWrap/) + expect(settings).toMatch(/setStore\(appJsonWordWrap/) + }) + + it('is honoured by all five JSON surfaces', () => { + // The app shows JSON in five places, each with its own editor. A wrap + // preference that only some of them read is worse than none: the switch + // appears to do nothing depending on where you are looking. RowExpandViewer + // in particular used to keep its own localStorage key for this. + for (const file of [ + 'JsonViewer', + 'JsonViewerPage', + 'JsonCellLightbox', + 'TableJsonView', + 'RowExpandViewer', + ]) { + expect(read(`./components/${file}.svelte`), file).toMatch(/appJsonWordWrap/) + } + }) + + it('has one home for the preference, not several', () => { + // The old per-component key, and the layout-store version that preceded the + // setting. Either coming back means two switches for one preference. + expect(read('./components/RowExpandViewer.svelte')).not.toContain('stroke:json-word-wrap') + expect(read('./stores/layout.js')).not.toContain('jsonWordWrap') + }) + + it('is offered in the settings panel', () => { + const dialog = read('./components/SettingsDialog.svelte') + expect(dialog).toMatch(/toggleJsonWordWrap/) + expect(dialog).toMatch(/settings\.jsonWordWrap/) + }) +}) + +describe('the JSONPath suggestion widget is shared', () => { + it('is used by every viewer that offers completions', () => { + // There were three copies. The JSON tab had a good one; the SQL and table + // views had a poorer one built from `getCompletions`, which discards the + // kind, detail and preview the completion engine already computed — so each + // row printed the same identifier twice and highlighted none of it. + for (const file of ['JsonViewer', 'JsonViewerPage', 'TableJsonView']) { + expect(read(`./components/${file}.svelte`), file).toMatch(/JsonPathSuggest/) + } + }) + + it('leaves no viewer on the lossy completion call', () => { + // getCompletions maps the rich items down to their insert strings. Anything + // still calling it is rendering a suggestion list it cannot describe. + for (const file of ['JsonViewer', 'JsonViewerPage', 'TableJsonView']) { + expect(read(`./components/${file}.svelte`), file).not.toMatch(/getCompletions\b/) + } + }) }) diff --git a/src/lib/shortcuts.js b/src/lib/shortcuts.js new file mode 100644 index 00000000..f1327cb5 --- /dev/null +++ b/src/lib/shortcuts.js @@ -0,0 +1,192 @@ +/** + * The one place a keyboard shortcut is written down. + * + * Every shortcut is a single combo string in the grammar `createHotkey` already + * parses — `Mod+Shift+T`, `Alt+1–5`, `F11`. Both platforms come out of that one + * string: `Mod` prints as ⌘ on macOS and Ctrl everywhere else, `Alt` as ⌥ or + * Alt. The two spellings used to be written out separately in the help dialog, + * which is exactly what let them drift from what the app actually binds — so now + * there is only one of them, and adding a shortcut gets both platforms for free. + * + * Keys that print as a symbol (`Enter` → ↵, `Plus` → +) are named here rather + * than typed as the symbol, so a combo can always be split on '+' without + * `Mod++` turning into nonsense. + */ + +import { detectOs } from './platform.js' + +export const IS_MAC = detectOs() === 'macos' + +/** Modifier → the keycap this platform prints. */ +const GLYPH = IS_MAC + ? { Mod: '⌘', Meta: '⌘', Alt: '⌥', Ctrl: '⌃', Shift: '⇧' } + : { Mod: 'Ctrl', Meta: 'Win', Alt: 'Alt', Ctrl: 'Ctrl', Shift: '⇧' } + +/** Keys written by name, printed as a glyph. Platform-independent. */ +const KEYCAP = { + Enter: '↵', + Backspace: '⌫', + Escape: 'Esc', + Up: '↑', + Down: '↓', + Left: '←', + Right: '→', + Plus: '+', + Minus: '−', +} + +/** + * The keycaps to render for a combo. Anything that is neither a modifier nor a + * named key passes through as written, so `F11` and `1–9` need no entry. + * @param {string} combo + * @returns {string[]} + */ +export function keycaps(combo) { + return combo.split('+').map((part) => GLYPH[part] ?? KEYCAP[part] ?? part) +} + +/** + * A combo as flat text, for searching. Includes both the printed keycaps and + * the written names, so "cmd b", "⌘ b" and "mod b" all find the same row. + * @param {string} combo + */ +export function comboText(combo) { + return `${keycaps(combo).join(' ')} ${combo.replace(/\+/g, ' ')}`.toLowerCase() +} + +/** + * @typedef {{ combo: string, desc: string }} Shortcut + * @typedef {{ label: string, icon: string, shortcuts: Shortcut[] }} ShortcutGroup + */ + +/** + * Grouped for the help dialog, in the order it shows them. `icon` is a name the + * dialog maps to a component — this module stays plain data so it can be read + * from anywhere without dragging Svelte in. + * @type {ShortcutGroup[]} + */ +export const SHORTCUT_GROUPS = [ + { + label: 'Navigation', + icon: 'navigation', + shortcuts: [ + { combo: 'Mod+K', desc: 'Command menu' }, + { combo: 'Mod+Shift+P', desc: 'Command menu' }, + { combo: 'Mod+Alt+1–9', desc: 'Switch to saved connection' }, + { combo: 'Mod+T', desc: 'Search tables' }, + { combo: 'Mod+N', desc: 'New tab' }, + { combo: 'Mod+W', desc: 'Close tab' }, + { combo: 'Mod+Tab', desc: 'Next tab' }, + { combo: 'Mod+Shift+Tab', desc: 'Previous tab' }, + { combo: 'Mod+Shift+T', desc: 'Reopen closed tab' }, + { combo: 'Mod+1–9', desc: 'Go to tab (9 = last)' }, + { combo: 'Alt+Shift+T', desc: 'Toggle tab bar' }, + { combo: 'Mod+B', desc: 'Toggle sidebar' }, + { combo: 'Mod+Shift+F', desc: 'Focus table filter' }, + { combo: 'Mod+D', desc: 'Switch database' }, + { combo: 'Mod+Shift+C', desc: 'Switch connection' }, + { combo: 'Mod+Alt+D', desc: 'Data view' }, + { combo: '/', desc: 'Focus the search box on screen' }, + { combo: 'Mod+/', desc: 'Keyboard shortcuts' }, + { combo: 'F11', desc: 'Toggle fullscreen' }, + ], + }, + { + label: 'Views', + icon: 'monitor', + shortcuts: [ + { combo: 'Mod+Shift+D', desc: 'Disconnect' }, + { combo: 'Mod+Shift+S', desc: 'SQL editor' }, + { combo: 'Mod+Shift+O', desc: 'ORM Runner' }, + { combo: 'Mod+Shift+X', desc: 'Extensions' }, + { combo: 'Mod+Shift+E', desc: 'Toggle AI panel' }, + { combo: 'Mod+I', desc: 'Toggle AI sidebar' }, + { combo: 'Mod+Shift+L', desc: 'Activity log' }, + { combo: 'Mod+R', desc: 'Refresh current view' }, + { combo: 'Mod+Shift+V', desc: 'Cycle table data view' }, + { combo: 'Alt+1–5', desc: 'Table / JSON / Record / Text / Chart view' }, + { combo: 'Mod+Alt+F', desc: 'Find & replace in table' }, + ], + }, + { + label: 'SQL Editor', + icon: 'terminal', + shortcuts: [ + { combo: 'Mod+Enter', desc: 'Run all statements' }, + { combo: 'Mod+R', desc: 'Run statement at cursor' }, + { combo: 'Mod+L', desc: 'Select current statement' }, + { combo: 'Mod+S', desc: 'Save query' }, + { combo: 'Mod+J', desc: 'Toggle output panel' }, + { combo: 'Mod+Shift+B', desc: 'Query history' }, + ], + }, + { + label: 'ORM Runner', + icon: 'code-2', + shortcuts: [ + { combo: 'Mod+Enter', desc: 'Run query' }, + { combo: 'Mod+S', desc: 'Format code' }, + ], + }, + { + label: 'Data Table', + icon: 'table-2', + shortcuts: [ + { combo: 'Mod+F', desc: 'Search rows' }, + { combo: 'Alt+Shift+F', desc: 'Open filter menu' }, + { combo: 'Alt+Shift+S', desc: 'Open sort menu' }, + { combo: 'Alt+Shift+C', desc: 'Open columns menu' }, + { combo: 'Alt+Shift+R', desc: 'Reset table view (clear filters/sort/search)' }, + { combo: 'Enter', desc: 'Edit cell' }, + { combo: 'F2', desc: 'Edit cell' }, + { combo: 'Escape', desc: 'Cancel edit' }, + { combo: 'Mod+Enter', desc: 'Navigate to FK row' }, + { combo: 'Mod+C', desc: 'Copy cell value' }, + { combo: 'Mod+Backspace', desc: 'Delete selected rows' }, + { combo: 'Mod+A', desc: 'Select all rows' }, + { combo: 'Mod+Z', desc: 'Undo cell edit' }, + { combo: 'Mod+Shift+Z', desc: 'Redo cell edit' }, + { combo: 'Mod+Up', desc: 'Scroll to top' }, + { combo: 'Mod+Down', desc: 'Scroll to bottom' }, + { combo: 'Mod+Left', desc: 'Previous page' }, + { combo: 'Mod+Right', desc: 'Next page' }, + { combo: 'Mod+Shift+Left', desc: 'First page' }, + { combo: 'Mod+Shift+Right', desc: 'Last page' }, + { combo: 'Alt+Shift+1–5', desc: 'Jump to pinned table' }, + ], + }, + { + label: 'AI Chat', + icon: 'bot', + shortcuts: [ + { combo: 'Enter', desc: 'Send message' }, + { combo: 'Shift+Enter', desc: 'New line' }, + { combo: 'Mod+Shift+B', desc: 'Toggle conversation list' }, + { combo: 'Mod+Shift+T', desc: 'New conversation' }, + ], + }, + { + label: 'Appearance', + icon: 'palette', + shortcuts: [ + { combo: 'Mod+B', desc: 'Toggle sidebar' }, + { combo: 'Mod+Shift+T', desc: 'Toggle tab bar' }, + { combo: 'Mod+Shift+B', desc: 'Toggle status bar' }, + { combo: 'Mod+M', desc: 'Cycle theme' }, + { combo: 'Mod+Shift+M', desc: 'Previous theme' }, + { combo: 'Mod+Plus', desc: 'Zoom in' }, + { combo: 'Mod+Minus', desc: 'Zoom out' }, + { combo: 'Mod+0', desc: 'Reset zoom' }, + ], + }, + { + label: 'General', + icon: 'settings', + shortcuts: [ + { combo: 'Mod+?', desc: 'Keyboard shortcuts' }, + { combo: '?', desc: 'Keyboard shortcuts' }, + { combo: 'Mod+,', desc: 'Settings' }, + { combo: 'Escape', desc: 'Dismiss / close' }, + ], + }, +] diff --git a/src/lib/stores/ai-settings.js b/src/lib/stores/ai-settings.js index 41b322cb..afe66a6e 100644 --- a/src/lib/stores/ai-settings.js +++ b/src/lib/stores/ai-settings.js @@ -18,9 +18,10 @@ export const PROVIDERS = [ { id: 'mistral', label: 'Mistral', url: 'https://api.mistral.ai/v1', keysUrl: 'https://console.mistral.ai/api-keys' }, { id: 'google', label: 'Google', url: 'https://generativelanguage.googleapis.com/v1beta/openai', keysUrl: 'https://aistudio.google.com/apikey' }, { id: 'ollama', label: 'Ollama', url: 'http://localhost:11434/v1', keysUrl: null }, - // OmniRoute is a gateway you run yourself (`npm i -g omniroute && omniroute`); it - // fronts many providers behind one OpenAI-compatible endpoint on :20128, and its - // key comes from its own local dashboard rather than a hosted console. + // OmniRoute is a gateway that runs locally; Stroke installs and starts its own + // copy (see src-tauri/src/omniroute.rs). It fronts many providers behind one + // OpenAI-compatible endpoint on :20128, and its key comes from its own local + // dashboard rather than a hosted console. { id: 'omniroute', label: 'OmniRoute', url: 'http://localhost:20128/v1', keysUrl: 'http://localhost:20128/dashboard' }, { id: 'custom', label: 'Custom', url: '', keysUrl: null }, ] diff --git a/src/lib/stores/settings.js b/src/lib/stores/settings.js index 92180168..7271bbb0 100644 --- a/src/lib/stores/settings.js +++ b/src/lib/stores/settings.js @@ -15,7 +15,7 @@ const STORAGE_KEY = 'stroke:settings' /** @typedef {'geist' | 'serif' | 'apple' | 'inter' | 'mono' | 'fira' | 'plex' | 'space' | 'source'} FontId */ /** @typedef {'regular' | 'light' | 'bold'} IconStyleId */ /** @typedef {'lucide' | 'hugeicons' | 'phosphor'} IconSetId */ -/** @typedef {{ theme: ThemeId, zoom: number, font: FontId, iconStyle: IconStyleId, iconSet: IconSetId, tableStyle: TableStyleId, mcpAutoStart: boolean, launchAtLogin: boolean, autoReconnectOnStartup: boolean, previewDmlBeforeApply: boolean, defaultDataView: string, paginationMode: string, maxQueryHistory: number, connectTimeoutMs: number, socketTimeoutMs: number, maxAllowedPacket: number, sessionTimezone: string, vimMode: boolean, cmdkAiEnabled: boolean, liveModeEnabled: boolean, nullSortOrder: string, agentChatFontSize: number, agentCodeFontSize: number, agentThinkingStyle: string, agentShowQueryCards: boolean, agentWebAccess: boolean, tableTextAlign: string, telemetry: boolean }} AppSettings */ +/** @typedef {{ theme: ThemeId, zoom: number, font: FontId, iconStyle: IconStyleId, iconSet: IconSetId, tableStyle: TableStyleId, mcpAutoStart: boolean, launchAtLogin: boolean, autoReconnectOnStartup: boolean, previewDmlBeforeApply: boolean, defaultDataView: string, paginationMode: string, maxQueryHistory: number, connectTimeoutMs: number, socketTimeoutMs: number, maxAllowedPacket: number, sessionTimezone: string, vimMode: boolean, cmdkAiEnabled: boolean, liveModeEnabled: boolean, nullSortOrder: string, agentChatFontSize: number, agentCodeFontSize: number, agentThinkingStyle: string, agentShowQueryCards: boolean, agentWebAccess: boolean, tableTextAlign: string, telemetry: boolean, jsonWordWrap: 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] @@ -201,8 +201,31 @@ export const DEFAULT_NULL_SORT = 'unset' /** Selectable font sizes (px) for the AI/agent chat + code blocks. */ export const AGENT_FONT_SIZES = /** @type {const} */ ([12, 13, 14, 15, 16]) -export const DEFAULT_AGENT_CHAT_FONT = 14 -export const DEFAULT_AGENT_CODE_FONT = 13 +export const DEFAULT_AGENT_CHAT_FONT = 16 +export const DEFAULT_AGENT_CODE_FONT = 16 + +/** + * Chat and code both used to default to the app's 14/13px UI scale, which is the + * right size for dense chrome and the wrong one for prose you actually read. The + * defaults below moved to 16px; these are what they used to be, so a stored value + * that still matches can be recognised as "never chosen" and moved up with them. + * @see migrateAgentFont + */ +const LEGACY_AGENT_CHAT_FONT = 14 +const LEGACY_AGENT_CODE_FONT = 13 + +/** + * Existing installs have the old default written into localStorage - not because + * anyone picked it, but because saving any unrelated setting persists the whole + * object. Left alone they would keep 14/13 forever and never see the new default. + * A value that still equals the old default is treated as unset; anything else is + * a real choice and is left exactly as it is. + * @param {unknown} stored @param {number} legacy @param {number} next + */ +function migrateAgentFont(stored, legacy, next) { + if (!AGENT_FONT_SIZES.includes(/** @type {never} */ (stored))) return next + return stored === legacy ? next : /** @type {number} */ (stored) +} /** Thinking-indicator visual styles for the agent chat. */ export const THINKING_STYLES = /** @type {const} */ ([ { id: 'shimmer', label: 'Shimmer' }, @@ -250,6 +273,10 @@ export const DEFAULT_SETTINGS = { sessionTimezone: DEFAULT_SESSION_TIMEZONE, vimMode: false, cmdkAiEnabled: false, + // Soft-wrap in every JSON viewer. Off by default: unwrapped keeps the + // structure scannable down the left edge, and one embedding value can run to + // tens of thousands of characters — wrapped, it buries every row around it. + jsonWordWrap: false, // On by default, and stated plainly in Settings. What it sends is a fixed // list of event names, the version and the OS — never a query, a table name // or anything about a connection. See src/lib/telemetry.js. @@ -294,6 +321,12 @@ export const appVimMode = writable(false) /** Reactive: experimental ⌘K "Ask AI" enabled (off by default; synced by applySettings). */ export const appCmdkAi = writable(false) +/** Reactive JSON soft-wrap preference (synced by applySettings). + * Every JSON viewer subscribes, so flipping it in Settings — or from the Wrap + * button on any one of them — reflows all of them at once instead of leaving + * each open view on whatever it happened to be created with. */ +export const appJsonWordWrap = writable(false) + /** Reactive: experimental Live mode (auto-refresh) status-bar toggle enabled (off by default). */ export const appLiveMode = writable(false) @@ -436,15 +469,16 @@ export function loadSettings() { // Absent means on: only an explicit false opts out. const telemetry = parsed.telemetry !== false const cmdkAiEnabled = parsed.cmdkAiEnabled === true + const jsonWordWrap = parsed.jsonWordWrap === true const liveModeEnabled = parsed.liveModeEnabled === true const nullSortOrder = NULL_SORT_IDS.includes(parsed.nullSortOrder) ? parsed.nullSortOrder : DEFAULT_NULL_SORT - const agentChatFontSize = AGENT_FONT_SIZES.includes(parsed.agentChatFontSize) ? parsed.agentChatFontSize : DEFAULT_AGENT_CHAT_FONT - const agentCodeFontSize = AGENT_FONT_SIZES.includes(parsed.agentCodeFontSize) ? parsed.agentCodeFontSize : DEFAULT_AGENT_CODE_FONT + const agentChatFontSize = migrateAgentFont(parsed.agentChatFontSize, LEGACY_AGENT_CHAT_FONT, DEFAULT_AGENT_CHAT_FONT) + const agentCodeFontSize = migrateAgentFont(parsed.agentCodeFontSize, LEGACY_AGENT_CODE_FONT, DEFAULT_AGENT_CODE_FONT) const agentThinkingStyle = THINKING_STYLE_IDS.includes(parsed.agentThinkingStyle) ? parsed.agentThinkingStyle : DEFAULT_THINKING_STYLE const agentShowQueryCards = parsed.agentShowQueryCards !== false const agentWebAccess = parsed.agentWebAccess === true const tableTextAlign = TABLE_ALIGN_IDS.includes(parsed.tableTextAlign) ? parsed.tableTextAlign : DEFAULT_TABLE_ALIGN - _settingsCache = { theme, zoom, font, iconStyle, iconSet, tableStyle, mcpAutoStart, launchAtLogin, autoReconnectOnStartup, previewDmlBeforeApply, defaultDataView, paginationMode, maxQueryHistory, connectTimeoutMs, socketTimeoutMs, maxAllowedPacket, sessionTimezone, vimMode, cmdkAiEnabled, liveModeEnabled, nullSortOrder, agentChatFontSize, agentCodeFontSize, agentThinkingStyle, agentShowQueryCards, agentWebAccess, tableTextAlign, telemetry } + _settingsCache = { theme, zoom, font, iconStyle, iconSet, tableStyle, mcpAutoStart, launchAtLogin, autoReconnectOnStartup, previewDmlBeforeApply, defaultDataView, paginationMode, maxQueryHistory, connectTimeoutMs, socketTimeoutMs, maxAllowedPacket, sessionTimezone, vimMode, cmdkAiEnabled, liveModeEnabled, nullSortOrder, agentChatFontSize, agentCodeFontSize, agentThinkingStyle, agentShowQueryCards, agentWebAccess, tableTextAlign, telemetry, jsonWordWrap } return { ..._settingsCache } } catch { return { ...DEFAULT_SETTINGS } @@ -544,6 +578,7 @@ export function applySettings(settings) { setStore(appPreviewDml, settings.previewDmlBeforeApply !== false) setStore(appVimMode, settings.vimMode === true) setStore(appCmdkAi, settings.cmdkAiEnabled === true) + setStore(appJsonWordWrap, settings.jsonWordWrap === true) setStore(appLiveMode, settings.liveModeEnabled === true) setStore(appAgentQueryCards, settings.agentShowQueryCards !== false) setStore(appAgentWebAccess, settings.agentWebAccess === true) diff --git a/src/lib/studio-tabs.test.js b/src/lib/studio-tabs.test.js index ea6ad30a..fc6210d6 100644 --- a/src/lib/studio-tabs.test.js +++ b/src/lib/studio-tabs.test.js @@ -82,3 +82,21 @@ describe('find helpers', () => { expect(findAiTab([createSqlTab('SELECT 1;')])).toBeNull() }) }) + +describe('cloneSqlTabState', () => { + it('preserves the running flag', () => { + // A tab's snapshot is what the shell restores when you switch back to it. + // Dropping this flag is what made a long query look cancelled the moment + // you left its tab: the spinner stopped and the results pane reverted. + const cloned = cloneSqlTabState({ + sqlText: 'select 1', + sqlColumns: [], + sqlRows: [], + sqlQueryMs: 0, + sqlMessage: '', + sqlLoading: true, + sqlError: '', + }) + expect(cloned.sqlLoading).toBe(true) + }) +}) diff --git a/src/lib/themes/monaco-presets.js b/src/lib/themes/monaco-presets.js index de86394c..e0a8e8a1 100644 --- a/src/lib/themes/monaco-presets.js +++ b/src/lib/themes/monaco-presets.js @@ -796,12 +796,52 @@ export const MONACO_THEME_SPECS = { } /** @param {ThemeId} id */ +/** + * JSON token colours, derived from whatever the theme already says. + * + * Every preset here was written for SQL — `keyword`, `string`, `number`, + * `identifier`. Monaco's JSON tokenizer emits its own scopes (`string.key.json` + * for a property name, `string.value.json` for a string value, `keyword.json` + * for true/false/null), and a theme that names none of them leaves the JSON + * viewer to fall back to the editor foreground for nearly everything — a wall + * of one colour, correctly indented and unreadable. + * + * Keys deliberately take the keyword colour rather than the string colour: + * in a result set the property names are the structure and the values are the + * data, and colouring both the same is what made them impossible to scan. + * + * @param {object[]} rules the theme's own rules, read for its palette + */ +function jsonRules(rules) { + const of = (/** @type {string} */ token) => + /** @type {any} */ (rules.find((r) => /** @type {any} */ (r).token === token))?.foreground + const string = of('string') + const number = of('number') + const keyword = of('keyword') + const operator = of('operator') + const out = [] + const add = (/** @type {string} */ token, /** @type {string|undefined} */ fg) => { + if (fg) out.push({ token, foreground: fg }) + } + add('string.key.json', keyword) + add('string.value.json', string) + add('number.json', number) + add('keyword.json', keyword) + add('delimiter.bracket.json', operator) + add('delimiter.array.json', operator) + add('delimiter.colon.json', operator) + add('delimiter.comma.json', operator) + return out +} + export function monacoThemeDefinition(id) { const spec = MONACO_THEME_SPECS[id] return { base: spec.base, inherit: true, - rules: spec.rules, + // JSON scopes appended after the theme's own, so a preset that names one + // explicitly still wins. + rules: [...jsonRules(spec.rules), ...spec.rules], colors: darkChrome(spec.chrome), } }
#{humanize(k)}
{i + 1} + {#each cols as k (k)} + {@const raw = row[k]} + {@const text = cell(raw)} + {#if k === 'state' || k === 'command'} - {#if row[k]} - {row[k]} + {#if raw} + {raw} {:else} – {/if} {:else} - {cell(row[k])} + {text} {/if}