From 7b2603db8ee8d171a3ddcd0666983837c66509f5 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:08:59 +0545 Subject: [PATCH 01/77] Refuse to open a SQLite database with no file An empty filename is not an error to SQLite: it opens a private temporary database and deletes it when the connection closes, so tables made in a connection saved without a file were gone after the next disconnect. --- src-tauri/src/db/connection.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src-tauri/src/db/connection.rs b/src-tauri/src/db/connection.rs index 316dbdbb..f0e6f9bb 100644 --- a/src-tauri/src/db/connection.rs +++ b/src-tauri/src/db/connection.rs @@ -1182,6 +1182,8 @@ async fn resolve_pg_ssh(config: PgConfig) -> Result<(PgConfig, Option // ── SQLite connect / test ───────────────────────────────────────────────────── +pub(crate) const NO_SQLITE_FILE: &str = "This SQLite connection has no database file. Choose one (or create a new one) in the connection's settings: without a file, SQLite keeps the data in a temporary file it deletes on disconnect, so nothing created would be kept."; + fn sqlite_url(path: &str) -> String { if path == ":memory:" { "sqlite::memory:".to_string() @@ -1191,6 +1193,13 @@ fn sqlite_url(path: &str) -> String { } pub(crate) async fn open_sqlite(config: &SqliteConfig) -> Result { + // An empty filename is not an error to SQLite: it opens a private temporary + // database and deletes it when the connection closes. A saved connection + // with no file therefore connected fine, and every table made in it was + // gone after the next disconnect. + if config.file_path.trim().is_empty() { + return Err(NO_SQLITE_FILE.into()); + } let opts: SqliteConnectOptions = sqlite_url(&config.file_path) .parse() .map_err(|e| format!("SQLite connection failed: {e}"))?; From 9fa8ba08a10066a033f621ae9cd77ed3586e5e01 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:08:59 +0545 Subject: [PATCH 02/77] Create a new SQLite file at a path that does not exist yet --- src-tauri/src/db/connection.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/db/connection.rs b/src-tauri/src/db/connection.rs index f0e6f9bb..21c59ec0 100644 --- a/src-tauri/src/db/connection.rs +++ b/src-tauri/src/db/connection.rs @@ -1203,7 +1203,11 @@ pub(crate) async fn open_sqlite(config: &SqliteConfig) -> Result Date: Tue, 6 Oct 2026 14:08:59 +0545 Subject: [PATCH 03/77] Refuse a DuckDB connection with no file An empty path opened an in-memory database, which loses everything on disconnect the same way. In-memory stays available as `:memory:`. --- src-tauri/src/db/connection.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/db/connection.rs b/src-tauri/src/db/connection.rs index 21c59ec0..a8d5177c 100644 --- a/src-tauri/src/db/connection.rs +++ b/src-tauri/src/db/connection.rs @@ -1430,8 +1430,14 @@ pub async fn connect_redis(state: State<'_, DbState>, config: RedisConfig) -> Re /// Open a DuckDB connection on a blocking thread (the driver is synchronous). pub(crate) async fn open_duckdb(config: &DuckdbConfig) -> Result { let path = config.file_path.clone(); + // Same trap as SQLite's: an empty path opened an in-memory database, so a + // "Local DuckDB" saved without a file lost everything on disconnect. + // In-memory is `:memory:`, chosen on purpose. + if path.trim().is_empty() { + return Err("This DuckDB connection has no database file. Choose one (or create a new one) in the connection's settings: without a file, everything created would be lost on disconnect.".into()); + } tokio::task::spawn_blocking(move || { - let conn = if path == ":memory:" || path.is_empty() { + let conn = if path == ":memory:" { duckdb::Connection::open_in_memory() } else { duckdb::Connection::open(&path) From e483f767fb5e91bbe20fab70ee4664e6174bff69 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:08:59 +0545 Subject: [PATCH 04/77] Let the connection form create a new SQLite or DuckDB file --- src/lib/components/ConnectionModal.svelte | 51 ++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/src/lib/components/ConnectionModal.svelte b/src/lib/components/ConnectionModal.svelte index 5be967ec..d3e0f989 100644 --- a/src/lib/components/ConnectionModal.svelte +++ b/src/lib/components/ConnectionModal.svelte @@ -2433,6 +2433,36 @@ } } + /** + * Where a new database file goes. The connection then creates it on first + * open; leaving the path empty used to "work" against a temporary database + * that the engine deletes on disconnect. + * @param {'sqlite' | 'duckdb'} kind + */ + async function newDatabaseFile(kind) { + try { + const { save } = await import("@tauri-apps/plugin-dialog"); + const path = await save({ + title: kind === "sqlite" ? "New SQLite database" : "New DuckDB database", + defaultPath: kind === "sqlite" ? "database.db" : "database.duckdb", + filters: kind === "sqlite" + ? [{ name: "SQLite", extensions: ["db", "sqlite", "sqlite3"] }] + : [{ name: "DuckDB", extensions: ["duckdb", "ddb", "db"] }], + }); + if (typeof path === "string" && path) filePath = path; + } catch { + /* browser/non-Tauri env */ + } + } + + /** A file-backed connection with no file: the reason it cannot connect, or null. @param {any} conn */ + function missingFileReason(conn) { + if ((conn?.type === "sqlite" || conn?.type === "duckdb") && !String(conn.filePath ?? "").trim()) { + return "Choose a database file, or create a new one. Without a file nothing would be kept: the database would be thrown away on disconnect."; + } + return null; + } + async function pickDuckdbFile() { try { const { open } = await import("@tauri-apps/plugin-dialog"); @@ -3956,7 +3986,18 @@ Browse + +

+ {filePath.trim() ? "A new file is created if it doesn't exist." : "Pick a database file, or create a new one."} +

@@ -4117,11 +4158,19 @@ Browse +

- A new file is created if it doesn't exist. + {filePath.trim() ? "A new file is created if it doesn't exist." : "Pick a database file, or create a new one."}

From e722a2b82c33ba3bd925372d34ef5690f1df31a4 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:08:59 +0545 Subject: [PATCH 05/77] Stop the connection form connecting a file database with no file --- src/lib/components/ConnectionModal.svelte | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/components/ConnectionModal.svelte b/src/lib/components/ConnectionModal.svelte index d3e0f989..e7ee34ce 100644 --- a/src/lib/components/ConnectionModal.svelte +++ b/src/lib/components/ConnectionModal.svelte @@ -2201,6 +2201,8 @@ // target, and in connection-string mode that is what parses the URI into // fields. It reads state and allocates an object - nothing that can fail. const payload = formPayload(); + const noFile = missingFileReason(payload); + if (noFile) { failWith(noFile); return; } // Only for a row that does not exist yet: connecting to a connection you // already have saved is not a filing decision, and being asked about it on // the way in is the wrong question at the wrong time. From d041710885e5e95b930ceee3768710234b5f9bda Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:08:59 +0545 Subject: [PATCH 06/77] Add a changeset for the SQLite file fix --- .changeset/sqlite-needs-a-file.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .changeset/sqlite-needs-a-file.md diff --git a/.changeset/sqlite-needs-a-file.md b/.changeset/sqlite-needs-a-file.md new file mode 100644 index 00000000..deb6b068 --- /dev/null +++ b/.changeset/sqlite-needs-a-file.md @@ -0,0 +1,3 @@ +### Bug Fixes +- A SQLite or DuckDB connection saved without a database file no longer opens a temporary database that is thrown away on disconnect, taking every table made in it. It now says it needs a file +- A new SQLite file can be made from the connection form with New, next to Browse From 92f16119a5ba4a4539a90fc28f935d561fb16a2c Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:08:59 +0545 Subject: [PATCH 07/77] Expose the user PATH lookup to the rest of the crate --- src-tauri/src/omniroute.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/omniroute.rs b/src-tauri/src/omniroute.rs index f19f9c33..676b6a63 100644 --- a/src-tauri/src/omniroute.rs +++ b/src-tauri/src/omniroute.rs @@ -67,7 +67,7 @@ fn emit(app: &tauri::AppHandle, line: &str, kind: &str) { static USER_PATH: std::sync::OnceLock = std::sync::OnceLock::new(); /// PATH to run tool lookups under. Resolved once per process. -async fn user_path() -> String { +pub(crate) async fn user_path() -> String { if let Some(p) = USER_PATH.get() { return p.clone(); } From e7a78e8be81212ab8f0d07c7148c5faabc85f123 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:08:59 +0545 Subject: [PATCH 08/77] Expose the Postgres and MySQL SSH resolvers to the rest of the crate --- src-tauri/src/db/connection.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/db/connection.rs b/src-tauri/src/db/connection.rs index a8d5177c..61c9386c 100644 --- a/src-tauri/src/db/connection.rs +++ b/src-tauri/src/db/connection.rs @@ -1166,7 +1166,7 @@ pub async fn connect( /// Establish an SSH tunnel if `config.ssh` is set, return a direct config pointing /// at the local forwarded port. The tunnel's lifetime must outlive the connection. -async fn resolve_pg_ssh(config: PgConfig) -> Result<(PgConfig, Option), String> { +pub(crate) async fn resolve_pg_ssh(config: PgConfig) -> Result<(PgConfig, Option), String> { if let Some(ref ssh_cfg) = config.ssh { let tunnel = SshTunnel::establish(ssh_cfg, &config.host, config.port).await?; let local_port = tunnel.local_port; @@ -1340,7 +1340,7 @@ pub async fn connect_mysql( Ok(()) } -async fn resolve_mysql_ssh(config: MysqlConfig) -> Result<(MysqlConfig, Option), String> { +pub(crate) async fn resolve_mysql_ssh(config: MysqlConfig) -> Result<(MysqlConfig, Option), String> { if let Some(ref ssh_cfg) = config.ssh { let tunnel = SshTunnel::establish(ssh_cfg, &config.host, config.port).await?; let local_port = tunnel.local_port; From 1a151d6ab5faf98b8dfa3386b2c5d4ca3716d077 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:08:59 +0545 Subject: [PATCH 09/77] Add portable-pty for the terminal tab --- src-tauri/Cargo.lock | 128 +++++++++++++++++++++++++++++++++++++++++-- src-tauri/Cargo.toml | 3 + 2 files changed, 126 insertions(+), 5 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 55d2cf12..a8821b5b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -128,6 +128,7 @@ dependencies = [ "objc2-app-kit", "objc2-quartz-core", "objc2-web-kit", + "portable-pty", "redis", "reqwest 0.12.28", "rust_decimal", @@ -146,6 +147,7 @@ dependencies = [ "tauri-plugin-updater", "tiberius", "tokio", + "tokio-tungstenite", "tokio-util", "tower-http 0.5.2", "urlencoding", @@ -568,6 +570,7 @@ dependencies = [ "async-trait", "axum-core", "axum-macros", + "base64 0.22.1", "bytes", "futures-util", "http", @@ -586,8 +589,10 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", + "sha1", "sync_wrapper", "tokio", + "tokio-tungstenite", "tower", "tower-layer", "tower-service", @@ -734,7 +739,7 @@ checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" dependencies = [ "borsh-derive", "bytes", - "cfg_aliases", + "cfg_aliases 0.2.1", ] [[package]] @@ -974,6 +979,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "cfg_aliases" version = "0.2.1" @@ -1354,6 +1365,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "dbus" version = "0.9.11" @@ -1853,6 +1870,17 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "filetime" version = "0.2.29" @@ -3357,6 +3385,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases 0.1.1", + "libc", +] + [[package]] name = "nix" version = "0.29.0" @@ -3365,7 +3405,7 @@ checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ "bitflags 2.11.1", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", "memoffset", ] @@ -4096,6 +4136,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix 0.28.0", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg 0.10.1", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -4252,7 +4313,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ "bytes", - "cfg_aliases", + "cfg_aliases 0.2.1", "pin-project-lite", "quinn-proto", "quinn-udp", @@ -4293,7 +4354,7 @@ version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", "once_cell", "socket2", @@ -5198,6 +5259,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serial2" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730" +dependencies = [ + "cfg-if", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -5257,6 +5329,22 @@ dependencies = [ "digest", ] +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -6441,6 +6529,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -6708,6 +6808,24 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.6", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + [[package]] name = "typeid" version = "1.0.3" @@ -8157,7 +8275,7 @@ dependencies = [ "futures-sink", "futures-util", "hex", - "nix", + "nix 0.29.0", "ordered-stream", "rand 0.8.6", "serde", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5ed9d7a5..65996e67 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -61,6 +61,9 @@ keyring = { version = "3", features = ["apple-native", "windows-native", "sync-s duckdb = { version = "1.3.2", features = ["bundled"] } tiberius = { version = "0.12.3", default-features = false, features = ["rustls", "tds73", "chrono"] } tokio-util = { version = "0.7.18", features = ["compat"] } +# The SQL terminal: the engine's own CLI (psql, mysql, sqlite3...) in a +# pseudo-terminal. ConPTY on Windows, openpty everywhere else. +portable-pty = "0.9" redis = { version = "1", features = ["tokio-comp", "tls-rustls", "tokio-rustls-comp", "tls-rustls-webpki-roots"] } [target.'cfg(target_os = "macos")'.dependencies] From ac96fc3d4fb969efb1beaee6af7d445b264c1c05 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:08:59 +0545 Subject: [PATCH 10/77] Find each engine's command-line client and build its launch psql, mysql or mariadb, sqlite3, sqlcmd and redis-cli: where the binary is (the user's PATH and the usual install folders), its version, and the arguments and environment that point it at a saved connection. The password goes in the environment variable the client reads, never argv. --- src-tauri/src/db/mod.rs | 1 + src-tauri/src/db/terminal.rs | 573 +++++++++++++++++++++++++++++++++++ 2 files changed, 574 insertions(+) create mode 100644 src-tauri/src/db/terminal.rs diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs index 9eb05ba3..7fe16786 100644 --- a/src-tauri/src/db/mod.rs +++ b/src-tauri/src/db/mod.rs @@ -27,6 +27,7 @@ pub mod local_scan; pub mod pg_ext_types; pub mod geo; pub mod import; +pub mod terminal; pub mod tx; pub use connection::{ diff --git a/src-tauri/src/db/terminal.rs b/src-tauri/src/db/terminal.rs new file mode 100644 index 00000000..6300a0e0 --- /dev/null +++ b/src-tauri/src/db/terminal.rs @@ -0,0 +1,573 @@ +/*! +The terminal tab: the connected engine's own command-line client (`psql`, +`mysql`, `sqlite3`, `sqlcmd`, `redis-cli`) running in a pseudo-terminal, its +output streamed to xterm.js in the window byte for byte. + +Nothing of the client is reimplemented. `\d+`, `\x`, tab completion, the pager +and the user's own `.psqlrc` all work because the real program is running. What +lives here is the part a desktop app has to get right around it: + +- finding the binary, since a windowed app is not handed the user's shell PATH; +- passing the saved password through the variable the client reads, never argv + (any user on the machine can read another process's argv with `ps`); +- opening the same SSH tunnel the connection uses, for as long as the client runs; +- killing the client when its tab closes or the app quits, so no `psql` is left + holding a server connection (or an open transaction) behind the window. +*/ + +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::Duration; + +use super::connection::{ + resolve_mysql_ssh, resolve_pg_ssh, AnyConnectionConfig, MssqlConfig, MysqlConfig, PgConfig, + RedisConfig, SqliteConfig, +}; +use super::ssh_tunnel::SshTunnel; + +// ── Clients ─────────────────────────────────────────────────────────────────── + +/// An engine's command-line client. +struct Client { + /// What the user calls it. + name: &'static str, + /// Executables that can serve, preferred first. + binaries: &'static [&'static str], + /// Where to get it, for the "not installed" state. + install: &'static str, +} + +const PSQL_INSTALL: &str = if cfg!(target_os = "macos") { + "brew install libpq" +} else if cfg!(windows) { + "Install PostgreSQL from postgresql.org/download/windows. The Command Line Tools component alone is enough." +} else { + "Install the PostgreSQL client: postgresql-client on Debian and Ubuntu, postgresql on Arch and Fedora." +}; + +const MYSQL_INSTALL: &str = if cfg!(target_os = "macos") { + "brew install mysql-client" +} else if cfg!(windows) { + "Install the MySQL client from dev.mysql.com/downloads/installer, or MariaDB from mariadb.org/download. Either client works." +} else { + "Install a MySQL client: mysql-client or mariadb-client on Debian and Ubuntu, mariadb-clients on Arch." +}; + +const SQLITE_INSTALL: &str = if cfg!(target_os = "macos") { + "brew install sqlite" +} else if cfg!(windows) { + "winget install SQLite.SQLite" +} else { + "Install sqlite3 on Debian and Ubuntu, sqlite on Arch and Fedora." +}; + +const SQLCMD_INSTALL: &str = if cfg!(target_os = "macos") { + "brew install sqlcmd" +} else if cfg!(windows) { + "winget install sqlcmd" +} else { + "Install go-sqlcmd from github.com/microsoft/go-sqlcmd/releases." +}; + +const REDIS_INSTALL: &str = if cfg!(target_os = "macos") { + "brew install redis" +} else if cfg!(windows) { + "redis-cli has no native Windows build. Memurai ships one, or run it under WSL." +} else { + "Install redis-tools on Debian and Ubuntu, redis or valkey on Arch and Fedora." +}; + +/// The client for an engine, or why it has none. +fn client_for(config: &AnyConnectionConfig) -> Result { + let client = |name, binaries, install| Ok(Client { name, binaries, install }); + match config { + AnyConnectionConfig::Postgres(_) => client("psql", &["psql"], PSQL_INSTALL), + // MariaDB's client first: where it is installed, `mysql` is only its + // deprecated alias and prints a warning on every start. Oracle's MySQL + // never ships a `mariadb`, so on those systems `mysql` is the one found. + AnyConnectionConfig::Mysql(_) => client("mysql", &["mariadb", "mysql"], MYSQL_INSTALL), + AnyConnectionConfig::Sqlite(c) if c.file_path.trim().is_empty() => { + Err(super::connection::NO_SQLITE_FILE.into()) + } + AnyConnectionConfig::Sqlite(c) if c.file_path.trim() == ":memory:" => Err( + "This is an in-memory database that lives inside Stroke, so no other process can open it." + .into(), + ), + AnyConnectionConfig::Sqlite(_) => client("sqlite3", &["sqlite3"], SQLITE_INSTALL), + AnyConnectionConfig::Mssql(_) => client("sqlcmd", &["sqlcmd"], SQLCMD_INSTALL), + AnyConnectionConfig::Redis(_) => client("redis-cli", &["redis-cli", "valkey-cli"], REDIS_INSTALL), + AnyConnectionConfig::Duckdb(_) => Err( + "DuckDB lets one process at a time open a database file, and Stroke already has this one open." + .into(), + ), + AnyConnectionConfig::D1(_) => Err( + "D1 has no interactive shell: wrangler d1 execute runs one command and exits.".into(), + ), + AnyConnectionConfig::Libsql(_) => Err( + "Turso's shell signs in through the Turso CLI, not through this connection's token.".into(), + ), + AnyConnectionConfig::Clickhouse(_) => Err( + "clickhouse-client speaks the native protocol, on a different port from the HTTP interface this connection uses." + .into(), + ), + AnyConnectionConfig::Posthog(_) => { + Err("PostHog is queried over its HTTP API and has no shell.".into()) + } + } +} + +/// How to install `client` here: the one-line command for this Linux's package +/// manager when it is one we know (so the page can offer it to copy), the +/// general advice otherwise. +fn install_hint(client: &Client) -> String { + #[cfg(target_os = "linux")] + if let Some(cmd) = linux_install(client.name) { + return cmd.to_string(); + } + client.install.to_string() +} + +/// The package that carries each client, by distribution family, read from +/// `/etc/os-release` (`ID`, or `ID_LIKE` for derivatives such as Omarchy). +#[cfg(target_os = "linux")] +fn linux_install(client: &str) -> Option<&'static str> { + let release = std::fs::read_to_string("/etc/os-release").ok()?; + let ids: Vec = release + .lines() + .filter_map(|l| l.strip_prefix("ID=").or_else(|| l.strip_prefix("ID_LIKE="))) + .flat_map(|v| v.trim_matches('"').split_whitespace().map(str::to_string).collect::>()) + .collect(); + let is = |id: &str| ids.iter().any(|i| i == id); + let family = if is("arch") { + "arch" + } else if is("debian") || is("ubuntu") { + "debian" + } else if is("fedora") || is("rhel") { + "fedora" + } else { + return None; + }; + Some(match (family, client) { + ("arch", "psql") => "sudo pacman -S postgresql-libs", + ("arch", "mysql") => "sudo pacman -S mariadb-clients", + ("arch", "sqlite3") => "sudo pacman -S sqlite", + ("arch", "redis-cli") => "sudo pacman -S valkey", + ("debian", "psql") => "sudo apt install postgresql-client", + ("debian", "mysql") => "sudo apt install mariadb-client", + ("debian", "sqlite3") => "sudo apt install sqlite3", + ("debian", "redis-cli") => "sudo apt install redis-tools", + ("fedora", "psql") => "sudo dnf install postgresql", + ("fedora", "mysql") => "sudo dnf install mariadb", + ("fedora", "sqlite3") => "sudo dnf install sqlite", + ("fedora", "redis-cli") => "sudo dnf install valkey", + _ => return None, + }) +} + +// ── Finding the binary ──────────────────────────────────────────────────────── + +/// The first of `names` found on the user's PATH, then in the places these +/// clients install to without touching PATH. +async fn find_binary(names: &[&str]) -> Option { + let path = crate::omniroute::user_path().await; + let mut dirs: Vec = std::env::split_paths(&path).collect(); + dirs.extend(install_dirs()); + names.iter().find_map(|name| { + let file = if cfg!(windows) { format!("{name}.exe") } else { (*name).to_string() }; + dirs.iter().map(|d| d.join(&file)).find(|p| is_executable(p)) + }) +} + +/// Client installs that stay off PATH: keg-only Homebrew formulas (libpq, +/// mysql-client), Postgres.app, Debian's per-version PostgreSQL directories, +/// and the Windows installers, which give every version a folder of its own. +fn install_dirs() -> Vec { + let mut out = Vec::new(); + #[cfg(not(windows))] + { + for d in [ + "/opt/homebrew/opt/libpq/bin", + "/usr/local/opt/libpq/bin", + "/opt/homebrew/opt/mysql-client/bin", + "/usr/local/opt/mysql-client/bin", + "/Applications/Postgres.app/Contents/Versions/latest/bin", + "/usr/local/mysql/bin", + "/opt/mssql-tools18/bin", + "/opt/mssql-tools/bin", + ] { + out.push(PathBuf::from(d)); + } + out.extend(versioned(Path::new("/usr/lib/postgresql"), "", "bin")); + } + #[cfg(windows)] + for var in ["ProgramFiles", "ProgramFiles(x86)"] { + let Some(root) = std::env::var_os(var).map(PathBuf::from) else { + continue; + }; + out.extend(versioned(&root.join("PostgreSQL"), "", "bin")); + out.extend(versioned(&root.join("MySQL"), "MySQL Server", "bin")); + out.extend(versioned(&root, "MariaDB", "bin")); + out.push(root.join("SqlCmd")); + out.extend(versioned( + &root.join("Microsoft SQL Server").join("Client SDK").join("ODBC"), + "", + "Tools\\Binn", + )); + } + out +} + +/// `root`'s subdirectories whose names start with `prefix`, each joined with `suffix`, newest +/// version first. Versions compare as numbers, so `17` sorts above `9.6`. +fn versioned(root: &Path, prefix: &str, suffix: &str) -> Vec { + let Ok(entries) = std::fs::read_dir(root) else { + return Vec::new(); + }; + let mut dirs: Vec<(Vec, PathBuf)> = entries + .flatten() + .filter(|e| e.file_type().is_ok_and(|t| t.is_dir())) + .filter_map(|e| { + let name = e.file_name().into_string().ok()?; + if !name.starts_with(prefix) { + return None; + } + let version = name + .split(|c: char| !c.is_ascii_digit()) + .filter_map(|part| part.parse().ok()) + .collect(); + Some((version, e.path().join(suffix))) + }) + .collect(); + dirs.sort_by(|a, b| b.0.cmp(&a.0)); + dirs.into_iter().map(|(_, path)| path).collect() +} + +fn is_executable(path: &Path) -> bool { + let Ok(meta) = std::fs::metadata(path) else { + return false; + }; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + meta.is_file() && meta.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + meta.is_file() + } +} + +/// The first line of ` --version`, e.g. `psql (PostgreSQL) 17.2`. +/// `None` when the client has no such flag (the ODBC build of sqlcmd). +async fn client_version(binary: &Path) -> Option { + let mut cmd = tokio::process::Command::new(binary); + cmd.arg("--version").stdin(Stdio::null()).kill_on_drop(true); + crate::proc::quiet(&mut cmd); + let out = tokio::time::timeout(Duration::from_secs(3), cmd.output()) + .await + .ok()? + .ok()?; + if !out.status.success() { + return None; + } + let text = String::from_utf8_lossy(if out.stdout.is_empty() { &out.stderr } else { &out.stdout }) + .into_owned(); + let line = text.lines().map(str::trim).find(|l| !l.is_empty())?; + // sqlite3 prints only `3.46.1 2024-08-13 09:16:08 `. + let line = match binary.file_stem().and_then(|s| s.to_str()) { + Some("sqlite3") => format!("SQLite {}", line.split_whitespace().next()?), + _ => line.to_string(), + }; + Some(line.chars().take(120).collect()) +} + +// ── Launching ───────────────────────────────────────────────────────────────── + +/// How to start one client against one connection. +#[derive(Default)] +struct Launch { + args: Vec, + env: Vec<(&'static str, String)>, + /// Inherited variables that would point the client somewhere else, or hand + /// another server's password to this one. + unset: &'static [&'static str], + /// The tunnel the client connects through. It closes when this drops, so it + /// moves into the session and lives exactly as long as the client. + tunnel: Option, +} + +/// psql's default prompts (`db=# `, `db-# `) with the database name in the +/// theme's blue and the transaction marker (`*`, `!`) in its yellow. `%[ %]` +/// tell readline the escapes take no width, or line editing goes off by their +/// length. A `.psqlrc` still wins: psql reads it after these are set. +/// +/// Each prompt is also wrapped in OSC 133 marks (the shell-integration codes +/// terminals use): `A` where the prompt starts, `A;k=s` for a continuation +/// line, `B` where the input starts. The page uses them to find what is being +/// typed, for its suggestions, and to know a statement is still open. +const PSQL_PROMPT1: &str = + "%[%033]133;A%007%]%[%033[1;34m%]%/%[%033[0m%]%R%[%033[33m%]%x%[%033[0m%]%# %[%033]133;B%007%]"; +const PSQL_PROMPT2: &str = "%[%033]133;A;k=s%007%]%[%033[2m%]%/%R%x%#%[%033[0m%] %[%033]133;B%007%]"; + +async fn pg_launch(config: PgConfig) -> Result { + let (c, tunnel) = resolve_pg_ssh(config).await?; + let mut env = vec![ + ("PGHOST", c.host.clone()), + ("PGPORT", c.port.to_string()), + ("PGUSER", c.user.clone()), + ("PGDATABASE", c.database.clone()), + ("PGAPPNAME", "Stroke".to_string()), + ]; + if !c.password.is_empty() { + env.push(("PGPASSWORD", c.password.clone())); + } + // The rule `PgConfig::connection_url` follows: `sslMode` wins, the old `ssl` + // flag means `require`, and neither leaves libpq on its default, `prefer`. + let mode = non_empty(&c.ssl_mode).unwrap_or_else(|| if c.ssl { "require".into() } else { String::new() }); + if !mode.is_empty() { + env.push(("PGSSLMODE", mode)); + if let Some(ca) = non_empty(&c.ssl_root_cert) { + env.push(("PGSSLROOTCERT", ca)); + } + } + if let Some(tz) = session_timezone(&c.timezone) { + env.push(("PGTZ", tz)); + } + Ok(Launch { + args: vec![ + format!("--set=PROMPT1={PSQL_PROMPT1}"), + format!("--set=PROMPT2={PSQL_PROMPT2}"), + // The tab keeps its own scrollback, so results scroll like any other + // output. With the pager on, even a one-row result could open `less` + // and leave the user at `(END)` with no idea it wants `q`. + "--pset=pager=off".to_string(), + // A terminal cannot scroll sideways, so a result wider than the tab + // wrapped every row into a mess (`\l` is ~130 columns). With + // `expanded=auto` such a result prints one record per block instead, + // and narrow results stay plain tables. (`format=wrapped` was tried + // and dropped: it squeezes columns and breaks names mid-word.) + "--pset=expanded=auto".to_string(), + "--pset=linestyle=unicode".to_string(), + // NULL apart from the empty string. + "--pset=null=\u{2205}".to_string(), + ], + env, + unset: &["PGSERVICE", "PGPASSWORD", "PGSSLMODE", "PGSSLROOTCERT", "PGTZ", "PGOPTIONS"], + tunnel, + }) +} + +async fn mysql_launch(config: MysqlConfig, mariadb: bool) -> Result { + let (c, tunnel) = resolve_mysql_ssh(config).await?; + let mut args = vec![ + format!("--host={}", c.host), + format!("--port={}", c.port), + format!("--user={}", c.user), + "--default-character-set=utf8mb4".to_string(), + // The terminal cannot scroll sideways: a result wider than it is shown + // one column per line (as `\G` does) instead of wrapping every row. + "--auto-vertical-output".to_string(), + ]; + if !c.database.is_empty() { + args.push(format!("--database={}", c.database)); + } + let mode = non_empty(&c.ssl_mode) + .map(|m| m.to_ascii_uppercase()) + .unwrap_or_else(|| if c.ssl { "REQUIRED" } else { "DISABLED" }.into()); + if mariadb { + // MariaDB's client has no --ssl-mode; it spells the same choices as switches. + match mode.as_str() { + "DISABLED" => args.push("--skip-ssl".into()), + "PREFERRED" => {} + "VERIFY_CA" | "VERIFY_IDENTITY" => { + args.extend(["--ssl".into(), "--ssl-verify-server-cert".into()]); + } + _ => args.extend(["--ssl".into(), "--skip-ssl-verify-server-cert".into()]), + } + } else { + args.push(format!("--ssl-mode={mode}")); + } + if mode != "DISABLED" { + if let Some(ca) = non_empty(&c.ssl_root_cert) { + args.push(format!("--ssl-ca={ca}")); + } + } + if let Some(tz) = session_timezone(&c.timezone) { + args.push(format!("--init-command=SET time_zone = '{}'", tz.replace('\'', "''"))); + } + let mut env = Vec::new(); + // Deprecated in MySQL 8 but still read by it and by every MariaDB client, + // and the only way to hand over a password that is neither argv nor a file. + if !c.password.is_empty() { + env.push(("MYSQL_PWD", c.password.clone())); + } + Ok(Launch { args, env, unset: &["MYSQL_PWD", "MYSQL_HOST", "MYSQL_TCP_PORT"], tunnel }) +} + +/// sqlite3's default `list` mode prints rows as `a|b|c` with no header. Box mode +/// draws a table, and from 3.38 wraps long values inside their column; older +/// builds get the box alone, builds before 3.33 neither. A `~/.sqliterc` means +/// the user has chosen, so it is left to decide. +fn sqlite_launch(c: &SqliteConfig, version: Option<&str>) -> Launch { + let has_rc = home_dir().is_some_and(|h| h.join(".sqliterc").exists()); + let v: Vec = version + .and_then(|v| v.split_whitespace().nth(1)) + .map(|v| v.split('.').filter_map(|p| p.parse().ok()).collect()) + .unwrap_or_default(); + let at_least = |major: u32, minor: u32| v.len() >= 2 && (v[0], v[1]) >= (major, minor); + let mut args = Vec::new(); + if !has_rc && at_least(3, 38) { + args.extend(["-cmd", ".mode box --wrap 60", "-cmd", ".nullvalue \u{2205}"].map(String::from)); + } else if !has_rc && at_least(3, 33) { + args.push("-box".into()); + } + args.push(c.file_path.trim().to_string()); + Launch { args, ..Default::default() } +} + +fn mssql_launch(c: &MssqlConfig) -> Launch { + let mut args = vec!["-S".to_string(), format!("{},{}", c.host, c.port)]; + if !c.database.is_empty() { + args.extend(["-d".into(), c.database.clone()]); + } + let mut env = Vec::new(); + if c.user.is_empty() { + // No login saved: Windows authentication, as the connection itself does. + args.push("-E".into()); + } else { + args.extend(["-U".into(), c.user.clone()]); + // Read by both sqlcmd builds, the ODBC one and go-sqlcmd. + env.push(("SQLCMDPASSWORD", c.password.clone())); + } + if c.encrypt { + args.push("-N".into()); + } + if c.trust_cert { + args.push("-C".into()); + } + Launch { + args, + env, + unset: &["SQLCMDPASSWORD", "SQLCMDSERVER", "SQLCMDUSER", "SQLCMDDBNAME"], + tunnel: None, + } +} + +fn redis_launch(c: &RedisConfig) -> Launch { + let mut args = vec!["-h".to_string(), c.host.clone(), "-p".to_string(), c.port.to_string()]; + if c.db != 0 { + args.extend(["-n".into(), c.db.to_string()]); + } + if c.tls { + args.push("--tls".into()); + } + let mut env = Vec::new(); + if let Some(password) = c.password.as_deref().filter(|p| !p.is_empty()) { + env.push(("REDISCLI_AUTH", password.to_string())); + env.push(("VALKEYCLI_AUTH", password.to_string())); + } + Launch { args, env, unset: &["REDISCLI_AUTH", "VALKEYCLI_AUTH"], tunnel: None } +} + +fn non_empty(value: &Option) -> Option { + value.as_deref().map(str::trim).filter(|v| !v.is_empty()).map(String::from) +} + +/// The connection's session time zone, unless it is the server's own. +fn session_timezone(value: &Option) -> Option { + non_empty(value).filter(|tz| !tz.eq_ignore_ascii_case("SYSTEM")) +} + +fn home_dir() -> Option { + let var = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; + std::env::var_os(var).map(PathBuf::from).filter(|p| p.is_dir()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + #[cfg(target_os = "linux")] + #[test] + fn install_hint_is_one_command_on_known_distros() { + let Some(cmd) = linux_install("mysql") else { return }; + assert!(cmd.starts_with("sudo "), "{cmd}"); + assert_eq!(linux_install("sqlcmd"), None); + } + + #[test] + fn versions_sort_numerically() { + let root = std::env::temp_dir().join(format!("stroke-term-{}", std::process::id())); + for v in ["9.6", "17", "16"] { + std::fs::create_dir_all(root.join(v)).unwrap(); + } + let found: Vec = versioned(&root, "", "bin") + .iter() + .map(|p| p.parent().unwrap().file_name().unwrap().to_string_lossy().into_owned()) + .collect(); + std::fs::remove_dir_all(&root).unwrap(); + assert_eq!(found, ["17", "16", "9.6"]); + } + + #[test] + fn pg_password_and_ssl_travel_in_env_not_argv() { + let config = PgConfig { + name: "t".into(), + host: "db.example.com".into(), + port: 5433, + database: "app".into(), + user: "me".into(), + password: "s3cret".into(), + ssl: true, + ssl_mode: None, + ssl_root_cert: Some("/ca.pem".into()), + ssh: None, + timezone: Some("SYSTEM".into()), + }; + let launch = tauri::async_runtime::block_on(pg_launch(config)).unwrap(); + assert!(launch.args.iter().all(|a| !a.contains("s3cret"))); + let env: HashMap<_, _> = launch.env.into_iter().collect(); + assert_eq!(env["PGPASSWORD"], "s3cret"); + assert_eq!(env["PGSSLMODE"], "require"); + assert_eq!(env["PGSSLROOTCERT"], "/ca.pem"); + assert_eq!(env["PGPORT"], "5433"); + assert!(!env.contains_key("PGTZ")); + } + + #[test] + fn sqlite_gets_box_mode_by_version() { + let c = SqliteConfig { name: "t".into(), file_path: "/tmp/x.db".into() }; + if home_dir().is_some_and(|h| h.join(".sqliterc").exists()) { + return; + } + assert_eq!(sqlite_launch(&c, Some("SQLite 3.53.4")).args[..2], ["-cmd", ".mode box --wrap 60"]); + assert_eq!(sqlite_launch(&c, Some("SQLite 3.35.0")).args, ["-box", "/tmp/x.db"]); + assert_eq!(sqlite_launch(&c, Some("SQLite 3.31.1")).args, ["/tmp/x.db"]); + assert_eq!(sqlite_launch(&c, None).args, ["/tmp/x.db"]); + } + + #[test] + fn mariadb_gets_switches_instead_of_ssl_mode() { + let config = |ssl_mode: &str| MysqlConfig { + name: "t".into(), + host: "h".into(), + port: 3306, + database: String::new(), + user: "u".into(), + password: "p".into(), + ssl: false, + ssl_mode: Some(ssl_mode.into()), + ssl_root_cert: None, + ssh: None, + timezone: None, + }; + let run = |c, mariadb| tauri::async_runtime::block_on(mysql_launch(c, mariadb)).unwrap().args; + assert!(run(config("required"), false).contains(&"--ssl-mode=REQUIRED".to_string())); + let maria = run(config("VERIFY_IDENTITY"), true); + assert!(maria.contains(&"--ssl-verify-server-cert".to_string())); + assert!(maria.iter().all(|a| !a.starts_with("--ssl-mode"))); + assert!(run(config("DISABLED"), true).contains(&"--skip-ssl".to_string())); + } + +} From f4ed2053d5f5ffd61761f7e0056f03f3f152b598 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:08:59 +0545 Subject: [PATCH 11/77] Run terminal sessions in a PTY behind a local WebSocket Bytes travel over a per-session WebSocket on 127.0.0.1 with a random token, not Tauri IPC: on Linux every invoke and Channel message waits on the GTK main loop, and typing lagged behind the echo. --- src-tauri/Cargo.toml | 4 +- src-tauri/src/db/terminal.rs | 548 ++++++++++++++++++++++++++++++++++- src-tauri/src/lib.rs | 4 + 3 files changed, 553 insertions(+), 3 deletions(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 65996e67..630067ed 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -29,7 +29,7 @@ tauri-plugin-dialog = "2" tauri-plugin-autostart = "2" tauri-plugin-clipboard-manager = "2" tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "net", "sync", "process"] } -axum = { version = "0.7", features = ["json", "macros"] } +axum = { version = "0.7", features = ["json", "macros", "ws"] } tower-http = { version = "0.5", features = ["cors"] } sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "sqlite", "mysql", "json", "chrono", "uuid", "rust_decimal"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots", "stream"] } @@ -91,6 +91,8 @@ gdk = "0.18" # `start_paused` in #[tokio::test] - lets the connect-deadline test fire the # timeout on a virtual clock instead of waiting CONNECT_DEADLINE for real. tokio = { version = "1", features = ["test-util"] } +# The terminal socket test connects the way the page does. +tokio-tungstenite = "0.24" [profile.release] strip = true diff --git a/src-tauri/src/db/terminal.rs b/src-tauri/src/db/terminal.rs index 6300a0e0..388713f2 100644 --- a/src-tauri/src/db/terminal.rs +++ b/src-tauri/src/db/terminal.rs @@ -15,9 +15,17 @@ lives here is the part a desktop app has to get right around it: holding a server connection (or an open transaction) behind the window. */ +use std::collections::HashMap; +use std::io::{ErrorKind, Read, Write}; use std::path::{Path, PathBuf}; use std::process::Stdio; -use std::time::Duration; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{mpsc, Arc, Mutex}; +use std::time::{Duration, Instant}; + +use portable_pty::{native_pty_system, Child, ChildKiller, CommandBuilder, MasterPty, PtySize}; +use serde::{Deserialize, Serialize}; +use tauri::State; use super::connection::{ resolve_mysql_ssh, resolve_pg_ssh, AnyConnectionConfig, MssqlConfig, MysqlConfig, PgConfig, @@ -483,10 +491,452 @@ fn home_dir() -> Option { std::env::var_os(var).map(PathBuf::from).filter(|p| p.is_dir()) } +// ── Sessions ────────────────────────────────────────────────────────────────── +// +// A session's bytes travel over a WebSocket on 127.0.0.1, not over Tauri IPC. +// On Linux every invoke and every Channel message goes through the GTK main +// loop of the UI process (a custom-scheme request one way, a `webview.eval` the +// other), so each keystroke queued behind that loop twice before its echo +// showed, and typing lagged. WebKit runs a WebSocket in its network process, +// beside the page, with no main-thread hop on either side. Each session has its +// own random token in the URL, so no other local process can attach to it. + +/// What the reader and the waiter hand the socket. +enum Out { + Data(Vec), + /// The client exited. All of its output was queued before this. + Exit(Option), +} + +struct Session { + master: Box, + killer: Box, + _tunnel: Option, +} + +/// The socket's half of a session, waiting for the page to connect. Output the +/// client prints before then (psql's banner, or a refused login) queues here. +struct Attach { + token: String, + output: tokio::sync::mpsc::UnboundedReceiver, + /// Keystrokes for the writer thread. A queue rather than a write in place: a + /// client busy with a query stops reading its input, and a write into a full + /// PTY would block the socket and with it the client's output. + input: mpsc::Sender>, + created: Instant, +} + +type Sessions = Arc>>; +type Pending = Arc>>; + +#[derive(Clone)] +struct Shared { + sessions: Sessions, + pending: Pending, +} + +#[derive(Default)] +pub struct TerminalState { + sessions: Sessions, + pending: Pending, + /// The socket server's port. It starts with the first session. + port: tokio::sync::OnceCell, +} + +impl TerminalState { + /// Kill every client. Run on app exit: a `psql` left behind keeps its server + /// connection, and any transaction it has open, until the server notices. + pub fn kill_all(&self) { + let sessions: Vec = match self.sessions.lock() { + Ok(mut map) => map.drain().map(|(_, s)| s).collect(), + Err(_) => return, + }; + for mut session in sessions { + let _ = session.killer.kill(); + } + } + + fn shared(&self) -> Shared { + Shared { sessions: self.sessions.clone(), pending: self.pending.clone() } + } +} + +static NEXT_ID: AtomicU64 = AtomicU64::new(1); + +/// A page that never connects (it was closed while the client started) leaves +/// its session waiting; the next open reaps it after this long. +const ATTACH_TIMEOUT: Duration = Duration::from_secs(60); + +/// Output is coalesced into frames of at most this many bytes. +const MAX_FRAME: usize = 256 * 1024; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalClient { + /// `psql`, `mysql`, `mariadb` and so on. Empty when the engine has no shell. + pub name: String, + /// The binary that runs. `None` when the client is not installed. + pub path: Option, + /// Its `--version` line, e.g. `psql (PostgreSQL) 17.2`. + pub version: Option, + /// How to install the client, or why this engine has none. + pub hint: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalSession { + pub id: String, + /// `ws://127.0.0.1:/terminal/?token=`. Output arrives as + /// binary frames and keystrokes go back as binary frames; text frames carry + /// control messages, `{"resize":[cols,rows]}` in and `{"exit":code}` out. + pub url: String, +} + +// ── Commands ────────────────────────────────────────────────────────────────── + +/// Which client a connection gets, and whether it is installed. +#[tauri::command] +pub async fn terminal_client(config: AnyConnectionConfig) -> Result { + let client = match client_for(&config) { + Ok(client) => client, + Err(why) => { + return Ok(TerminalClient { name: String::new(), path: None, version: None, hint: why }) + } + }; + let Some(binary) = find_binary(client.binaries).await else { + return Ok(TerminalClient { + name: client.name.into(), + path: None, + version: None, + hint: install_hint(&client), + }); + }; + Ok(TerminalClient { + name: binary.file_stem().and_then(|s| s.to_str()).unwrap_or(client.name).to_string(), + version: client_version(&binary).await, + path: Some(binary.display().to_string()), + hint: install_hint(&client), + }) +} + +/// Start the connection's client in a `cols` x `rows` terminal, and return the +/// socket the page talks to it over. +#[tauri::command] +pub async fn terminal_open( + state: State<'_, TerminalState>, + config: AnyConnectionConfig, + cols: u16, + rows: u16, +) -> Result { + open_session(&state, config, cols, rows).await +} + +/// Kill the client. A connected socket gets its exit message first. +#[tauri::command] +pub async fn terminal_close(state: State<'_, TerminalState>, id: String) -> Result<(), String> { + if let Ok(mut pending) = state.pending.lock() { + pending.remove(&id); + } + let session = state.sessions.lock().map_err(|e| e.to_string())?.remove(&id); + if let Some(mut session) = session { + let _ = session.killer.kill(); + } + Ok(()) +} + +async fn open_session( + state: &TerminalState, + config: AnyConnectionConfig, + cols: u16, + rows: u16, +) -> Result { + reap_unattached(state); + let client = client_for(&config)?; + let binary = find_binary(client.binaries) + .await + .ok_or_else(|| format!("{} is not installed. {}", client.name, install_hint(&client)))?; + let launch = match config { + AnyConnectionConfig::Postgres(c) => pg_launch(c).await?, + AnyConnectionConfig::Mysql(c) => { + let stem = binary.file_stem().and_then(|s| s.to_str()).unwrap_or_default(); + let mariadb = stem == "mariadb" + || client_version(&binary) + .await + .is_some_and(|v| v.to_ascii_lowercase().contains("mariadb")); + mysql_launch(c, mariadb).await? + } + AnyConnectionConfig::Sqlite(c) => sqlite_launch(&c, client_version(&binary).await.as_deref()), + AnyConnectionConfig::Mssql(c) => mssql_launch(&c), + AnyConnectionConfig::Redis(c) => redis_launch(&c), + _ => return Err("This connection has no terminal client.".into()), + }; + let shared = state.shared(); + let port = *state.port.get_or_try_init(|| serve(shared)).await?; + + let Spawned { master, mut child, reader, writer } = + spawn_client(&binary, client.name, &launch, cols, rows).await?; + + let id = format!("term-{}", NEXT_ID.fetch_add(1, Ordering::Relaxed)); + let mut token = [0u8; 16]; + getrandom::getrandom(&mut token).map_err(|e| e.to_string())?; + let token = hex::encode(token); + let (input, keystrokes) = mpsc::channel::>(); + let (out_tx, output) = tokio::sync::mpsc::unbounded_channel::(); + std::thread::spawn(move || write_loop(writer, keystrokes)); + let reader_out = out_tx.clone(); + let reader_thread = std::thread::spawn(move || read_loop(reader, reader_out)); + + // Both in their maps before the waiter starts, so a client that exits at + // once (a refused login) is still found and released by it. + let session = Session { master, killer: child.clone_killer(), _tunnel: launch.tunnel }; + state.sessions.lock().map_err(|e| e.to_string())?.insert(id.clone(), session); + let attach = Attach { token: token.clone(), output, input, created: Instant::now() }; + state.pending.lock().map_err(|e| e.to_string())?.insert(id.clone(), attach); + + let sessions = state.sessions.clone(); + let session_id = id.clone(); + std::thread::spawn(move || { + let status = child.wait(); + // ConPTY keeps its output pipe open after the client exits, until the + // pseudo console itself closes. Dropping the session closes it, which + // lets the reader drain the last bytes and finish before the exit. + let session = sessions.lock().ok().and_then(|mut map| map.remove(&session_id)); + drop(session); + let _ = reader_thread.join(); + let _ = out_tx.send(Out::Exit(status.ok().map(|s| s.exit_code()))); + }); + + Ok(TerminalSession { url: format!("ws://127.0.0.1:{port}/terminal/{id}?token={token}"), id }) +} + +/// Kill the sessions whose page never connected. +fn reap_unattached(state: &TerminalState) { + let stale: Vec = match state.pending.lock() { + Ok(mut pending) => { + let ids: Vec = pending + .iter() + .filter(|(_, a)| a.created.elapsed() > ATTACH_TIMEOUT) + .map(|(id, _)| id.clone()) + .collect(); + for id in &ids { + pending.remove(id); + } + ids + } + Err(_) => return, + }; + if let Ok(mut sessions) = state.sessions.lock() { + for id in stale { + if let Some(mut session) = sessions.remove(&id) { + let _ = session.killer.kill(); + } + } + } +} + +// ── Socket ──────────────────────────────────────────────────────────────────── + +async fn serve(shared: Shared) -> Result { + use axum::routing::get; + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .map_err(|e| format!("Could not open the terminal socket: {e}"))?; + let port = listener.local_addr().map_err(|e| e.to_string())?.port(); + let app = axum::Router::new().route("/terminal/:id", get(socket_route)).with_state(shared); + tauri::async_runtime::spawn(async move { + if let Err(e) = axum::serve(listener, app).await { + log::error!("terminal socket server stopped: {e}"); + } + }); + Ok(port) +} + +#[derive(Deserialize)] +struct TokenQuery { + token: String, +} + +async fn socket_route( + axum::extract::Path(id): axum::extract::Path, + axum::extract::Query(query): axum::extract::Query, + axum::extract::State(shared): axum::extract::State, + ws: axum::extract::ws::WebSocketUpgrade, +) -> axum::response::Response { + use axum::response::IntoResponse; + let attach = shared.pending.lock().ok().and_then(|mut pending| { + let matches = pending.get(&id).is_some_and(|a| a.token == query.token); + if matches { pending.remove(&id) } else { None } + }); + let Some(attach) = attach else { + return axum::http::StatusCode::NOT_FOUND.into_response(); + }; + ws.on_upgrade(move |socket| pump(socket, id, attach, shared.sessions)) +} + +#[derive(Deserialize)] +struct Control { + resize: Option<(u16, u16)>, + /// A line for the log: the page reports its keystroke-to-echo latency. + trace: Option, +} + +/// Move bytes both ways until the client exits or the page goes away. +async fn pump( + mut socket: axum::extract::ws::WebSocket, + id: String, + attach: Attach, + sessions: Sessions, +) { + use axum::extract::ws::Message; + let Attach { mut output, input, .. } = attach; + loop { + tokio::select! { + out = output.recv() => { + let Some(first) = out else { break }; + // Whatever is already queued goes in the same frame: a large + // result is a handful of messages, not one per PTY read. + let mut data = Vec::new(); + let mut exit = None; + let mut next = Some(first); + while let Some(item) = next.take() { + match item { + Out::Data(bytes) => data.extend_from_slice(&bytes), + Out::Exit(code) => { + exit = Some(code); + break; + } + } + if data.len() >= MAX_FRAME { + break; + } + next = output.try_recv().ok(); + } + if !data.is_empty() && socket.send(Message::Binary(data)).await.is_err() { + break; + } + if let Some(code) = exit { + let _ = socket.send(Message::Text(serde_json::json!({ "exit": code }).to_string())).await; + break; + } + } + message = socket.recv() => match message { + Some(Ok(Message::Binary(bytes))) => { + let _ = input.send(bytes); + } + Some(Ok(Message::Text(text))) => { + let Ok(control) = serde_json::from_str::(&text) else { continue }; + if let Some((cols, rows)) = control.resize { + let size = PtySize { rows: rows.max(2), cols: cols.max(2), pixel_width: 0, pixel_height: 0 }; + if let Some(session) = sessions.lock().ok().as_ref().and_then(|m| m.get(&id)) { + let _ = session.master.resize(size); + } + } + if let Some(trace) = control.trace { + log::info!("terminal {id}: {}", trace.chars().take(300).collect::()); + } + } + Some(Ok(Message::Close(_))) | Some(Err(_)) | None => break, + Some(Ok(_)) => {} + } + } + } + let _ = socket.send(Message::Close(None)).await; + // The socket is the session's lifeline: a closed tab, a reloaded page and a + // closed window all end up here, and the client goes with them. + if let Some(mut session) = sessions.lock().ok().and_then(|mut map| map.remove(&id)) { + let _ = session.killer.kill(); + } +} + +/// A client started in a pseudo-terminal of its own. +struct Spawned { + master: Box, + child: Box, + reader: Box, + writer: Box, +} + +async fn spawn_client( + binary: &Path, + name: &str, + launch: &Launch, + cols: u16, + rows: u16, +) -> Result { + let size = PtySize { rows: rows.max(2), cols: cols.max(2), pixel_width: 0, pixel_height: 0 }; + let pair = native_pty_system() + .openpty(size) + .map_err(|e| format!("Could not open a terminal: {e}"))?; + let mut cmd = CommandBuilder::new(binary); + cmd.args(&launch.args); + for key in launch.unset { + cmd.env_remove(key); + } + for (key, value) in &launch.env { + cmd.env(key, value); + } + cmd.env("TERM", "xterm-256color"); + cmd.env("COLORTERM", "truecolor"); + // What the client starts itself (psql's pager, `\!`) needs the same lookup. + cmd.env("PATH", crate::omniroute::user_path().await); + // A .app launched from Finder gets no locale at all, and psql then prints + // every non-ASCII character as `?`. + #[cfg(unix)] + if ["LC_ALL", "LC_CTYPE", "LANG"].iter().all(|k| std::env::var_os(k).is_none()) { + cmd.env("LANG", if cfg!(target_os = "macos") { "en_US.UTF-8" } else { "C.UTF-8" }); + } + if let Some(home) = home_dir() { + cmd.cwd(home); + } + + let mut child = pair + .slave + .spawn_command(cmd) + .map_err(|e| format!("Could not start {name}: {e}"))?; + // The client now holds the only slave handle, so its exit is what ends the reader. + drop(pair.slave); + let streams = pair + .master + .try_clone_reader() + .and_then(|reader| Ok((reader, pair.master.take_writer()?))); + match streams { + Ok((reader, writer)) => Ok(Spawned { master: pair.master, child, reader, writer }), + Err(e) => { + let _ = child.kill(); + Err(format!("Could not attach to {name}: {e}")) + } + } +} + +fn read_loop(mut reader: Box, out: tokio::sync::mpsc::UnboundedSender) { + let mut buf = vec![0u8; 64 * 1024]; + loop { + match reader.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + if out.send(Out::Data(buf[..n].to_vec())).is_err() { + break; + } + } + Err(e) if e.kind() == ErrorKind::Interrupted => continue, + // EIO is how Linux reports that the client closed its end. + Err(_) => break, + } + } +} + +fn write_loop(mut writer: Box, keystrokes: mpsc::Receiver>) { + for bytes in keystrokes { + if writer.write_all(&bytes).and_then(|_| writer.flush()).is_err() { + break; + } + } +} + #[cfg(test)] mod tests { use super::*; - use std::collections::HashMap; #[cfg(target_os = "linux")] #[test] @@ -570,4 +1020,98 @@ mod tests { assert!(run(config("DISABLED"), true).contains(&"--skip-ssl".to_string())); } + /// The whole path a session takes: find the client, start it in a PTY, + /// type into it, read its output, see it exit. Skipped without sqlite3. + #[test] + fn sqlite3_round_trips_through_a_pty() { + let Some(binary) = tauri::async_runtime::block_on(find_binary(&["sqlite3"])) else { + return; + }; + let db = std::env::temp_dir().join(format!("stroke-term-{}.db", std::process::id())); + let launch = sqlite_launch(&SqliteConfig { name: "t".into(), file_path: db.display().to_string() }, None); + let Spawned { mut child, mut reader, mut writer, master: _master } = + tauri::async_runtime::block_on(spawn_client(&binary, "sqlite3", &launch, 80, 24)).unwrap(); + writer.write_all(b"select 6*7;\n.quit\n").unwrap(); + writer.flush().unwrap(); + let mut out = Vec::new(); + let mut buf = [0u8; 4096]; + while let Ok(n @ 1..) = reader.read(&mut buf) { + out.extend_from_slice(&buf[..n]); + } + let status = child.wait().unwrap(); + let _ = std::fs::remove_file(&db); + assert!(String::from_utf8_lossy(&out).contains("42"), "{}", String::from_utf8_lossy(&out)); + assert!(status.success()); + } + + /// The page's side of a session: open, connect to the socket, type, read + /// the echo, run a query, quit, get the exit. Skipped without sqlite3. + #[test] + fn sqlite3_round_trips_over_the_socket() { + use futures::{SinkExt, StreamExt}; + use tokio_tungstenite::tungstenite::Message; + tauri::async_runtime::block_on(async { + if find_binary(&["sqlite3"]).await.is_none() { + return; + } + let state = TerminalState::default(); + let db = std::env::temp_dir().join(format!("stroke-term-ws-{}.db", std::process::id())); + let config = AnyConnectionConfig::Sqlite(SqliteConfig { + name: "t".into(), + file_path: db.display().to_string(), + }); + let session = open_session(&state, config, 80, 24).await.unwrap(); + + let wrong = session.url.replace("token=", "token=x"); + assert!(tokio_tungstenite::connect_async(&wrong).await.is_err(), "a wrong token must not attach"); + + let (mut ws, _) = tokio_tungstenite::connect_async(&session.url).await.unwrap(); + let mut seen = Vec::::new(); + // Read frames until `want` shows up in the output. + async fn until( + ws: &mut (impl StreamExt> + Unpin), + seen: &mut Vec, + want: &str, + ) -> Option { + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + loop { + if String::from_utf8_lossy(seen).contains(want) { + return None; + } + match tokio::time::timeout_at(deadline, ws.next()).await.ok()?? .ok()? { + Message::Binary(b) => seen.extend_from_slice(&b), + Message::Text(t) => return Some(t), + _ => {} + } + } + } + until(&mut ws, &mut seen, "sqlite>").await; + + let mut latencies = Vec::new(); + for ch in "select 6*7;".chars() { + seen.clear(); + let start = Instant::now(); + ws.send(Message::Binary(ch.to_string().into_bytes())).await.unwrap(); + until(&mut ws, &mut seen, &ch.to_string()).await; + latencies.push(start.elapsed()); + } + ws.send(Message::Binary(b"\r".to_vec())).await.unwrap(); + until(&mut ws, &mut seen, "42").await; + assert!(String::from_utf8_lossy(&seen).contains("42")); + + ws.send(Message::Text(r#"{"resize":[120,40]}"#.into())).await.unwrap(); + ws.send(Message::Binary(b".quit\r".to_vec())).await.unwrap(); + let exit = until(&mut ws, &mut seen, "\u{0}never").await; + let _ = std::fs::remove_file(&db); + assert_eq!(exit.as_deref(), Some(r#"{"exit":0}"#)); + + latencies.sort(); + println!( + "keystroke to echo over the socket: p50 {:?}, max {:?}", + latencies[latencies.len() / 2], + latencies.last().unwrap() + ); + assert!(*latencies.last().unwrap() < Duration::from_millis(100)); + }); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e1b648a2..e1c01248 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -323,6 +323,7 @@ pub fn run() { .manage(omniroute::OmniRouteState::new()) .manage(db::live::LiveState::default()) .manage(db::tx::TxState::default()) + .manage(db::terminal::TerminalState::default()) .manage(db::result_store::ResultStore::default()) .setup(move |app| { // Load or generate a stable MCP token from the app data directory. @@ -621,6 +622,9 @@ pub fn run() { db::tx::tx_commit, db::tx::tx_rollback, db::tx::tx_status, + db::terminal::terminal_client, + db::terminal::terminal_open, + db::terminal::terminal_close, commands::check_license_status, commands::activate_license, commands::deactivate_license, From b9da8b3edcb0fbfdd0403cb307ff8df5d2f2fc39 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:08:59 +0545 Subject: [PATCH 12/77] Kill terminal clients when the app quits --- src-tauri/src/lib.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e1c01248..695c0f53 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -651,6 +651,9 @@ pub fn run() { // OmniRoute proxy we spawned, or it survives every app quit. if let tauri::RunEvent::Exit = event { app.state::().kill_now(); + // And every terminal tab's client, which would otherwise keep its + // server connection (and any open transaction) after the app. + app.state::().kill_all(); } }); } From f2468bd6df31d7f684a4dc7894654648cc2933b4 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:08:59 +0545 Subject: [PATCH 13/77] Add xterm.js for the terminal tab --- package-lock.json | 54 ++++++++++++++++++++++++++++++++++++++++++++--- package.json | 4 ++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index ed359b27..c61efe34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "stroke", - "version": "2.2.3", + "version": "2.2.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "stroke", - "version": "2.2.3", + "version": "2.2.4", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@codemirror/autocomplete": "^6.20.3", @@ -41,6 +41,10 @@ "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-opener": "^2.5.4", "@tauri-apps/plugin-updater": "^2.10.1", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/addon-webgl": "^0.19.0", + "@xterm/xterm": "^6.0.0", "@xyflow/svelte": "^1.5.2", "beautiful-mermaid": "^1.1.3", "bits-ui": "^2.18.1", @@ -125,6 +129,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz", "integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.7.0", @@ -206,6 +211,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", @@ -231,6 +237,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.2.tgz", "integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", @@ -242,6 +249,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz", "integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==", "license": "MIT", + "peer": true, "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } @@ -251,6 +259,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.13.tgz", "integrity": "sha512-sihaFrUzAsYBQsL9J2t69y8nfMQGwcYmggAZsk+kjPbjYZMyuf2hU8tUNTZ+P+isb6XRr8JE22TZlJxBoVdH1A==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", @@ -279,6 +288,7 @@ "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" @@ -290,6 +300,7 @@ "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -464,6 +475,7 @@ "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.1.tgz", "integrity": "sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@swc/helpers": "^0.5.0" } @@ -2101,6 +2113,33 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/addon-web-links": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", + "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==", + "license": "MIT" + }, + "node_modules/@xterm/addon-webgl": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz", + "integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/@xyflow/svelte": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/@xyflow/svelte/-/svelte-1.5.2.tgz", @@ -2136,6 +2175,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2374,6 +2414,7 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.4.tgz", "integrity": "sha512-HIN5Pmd9MrX9BkV7tDwnOcEJCSFvCpc8X97h3f508J6I5FsqAY65wKOCvgH2CuP42CaahWaz4tuh32SOOIH7ww==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10" } @@ -2783,6 +2824,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -3914,6 +3956,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4293,6 +4336,7 @@ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.9.tgz", "integrity": "sha512-fTjjT8cHLDwigcu2j3pv7Jq04LklXevPB8uBgyHNiTXv+RMNvVnrjS4UEYrLMkhuq1vpCodHjiW+z/95SDs/fg==", "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -4377,6 +4421,7 @@ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/dcastil" @@ -4405,7 +4450,8 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.3", @@ -4502,6 +4548,7 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4624,6 +4671,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", diff --git a/package.json b/package.json index 197c502d..65e96f0f 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,10 @@ "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-opener": "^2.5.4", "@tauri-apps/plugin-updater": "^2.10.1", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/addon-webgl": "^0.19.0", + "@xterm/xterm": "^6.0.0", "@xyflow/svelte": "^1.5.2", "beautiful-mermaid": "^1.1.3", "bits-ui": "^2.18.1", From 38856d0c4c8f3ca8f8678e25a6ffafdf688a58ff Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:08:59 +0545 Subject: [PATCH 14/77] Add the terminal API wrappers --- src/lib/api.js | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/lib/api.js b/src/lib/api.js index 58b75fa5..42ac66de 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -1326,6 +1326,44 @@ export async function txStatus(sessionId) { return inv('tx_status', { sessionId }) } +// ── Terminal ────────────────────────────────────────────────────────────────── +// The connection's own CLI (psql, mysql, sqlite3, sqlcmd, redis-cli) in a +// pseudo-terminal. See src-tauri/src/db/terminal.rs. + +/** + * @typedef {object} TerminalClient + * @property {string} name `psql`, `mysql`, `mariadb`... '' when the engine has no shell + * @property {string | null} path the binary that runs; null when not installed + * @property {string | null} version its `--version` line + * @property {string} hint how to install it, or why the engine has none + */ + +/** + * @param {Record} config the saved connection, `type` set to its engine + * @returns {Promise} + */ +export async function terminalClient(config) { + return inv('terminal_client', { config }) +} + +/** + * Start the client. Its bytes travel over the returned WebSocket URL, not IPC: + * binary frames both ways, text frames for control (`{"resize":[c,r]}` in, + * `{"exit":code}` out). + * @param {Record} config + * @param {number} cols + * @param {number} rows + * @returns {Promise<{ id: string, url: string }>} + */ +export async function terminalOpen(config, cols, rows) { + return inv('terminal_open', { config, cols, rows }) +} + +/** @param {string} id */ +export async function terminalClose(id) { + return inv('terminal_close', { id }) +} + // ── Data import ─────────────────────────────────────────────────────────────── /** From 23d07af2209330b9545248f712e8b4dbd32900cf Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:08:59 +0545 Subject: [PATCH 15/77] Colour the error lines database clients print --- src/lib/terminal-highlight.js | 173 ++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 src/lib/terminal-highlight.js diff --git a/src/lib/terminal-highlight.js b/src/lib/terminal-highlight.js new file mode 100644 index 00000000..7a2a3582 --- /dev/null +++ b/src/lib/terminal-highlight.js @@ -0,0 +1,173 @@ +/** + * Colour the error, warning and notice lines a database client prints, as its + * bytes stream to the terminal. + * + * The clients print these plain: psql's `ERROR: relation "x" does not exist` + * looks like any other line of output, and in a long session the one line that + * matters is easy to scroll past. This watches the start of every line for the + * prefixes the clients use (psql, mysql/mariadb, sqlite3, sqlcmd, redis-cli) and + * wraps a matching line in an ANSI colour, which the terminal then draws in the + * theme's own red, yellow or blue. + * + * It works on bytes and passes everything else through untouched, so UTF-8, + * escape sequences and the client's own colours are never re-encoded. A chunk + * can end halfway through a prefix (`ERR`), so the bytes after a line break are + * held until they either match or cannot; `flushAfterMs` lets them go anyway if + * nothing more arrives, so a prompt is never left waiting on its first letters. + */ + +const RED = '\x1b[31m' +const YELLOW = '\x1b[33m' +const BLUE = '\x1b[34m' +const DIM = '\x1b[2m' +const RESET = '\x1b[0m' + +/** + * Line prefixes, by client: literal, case-sensitive, matched at column 0. Kept + * per client so one client's rule cannot fire on another's output (a psql + * column called `Msg` in `\x` mode starts its line with sqlcmd's `Msg `). + * @type {Record>} + */ +const RULES_BY_CLIENT = { + psql: [ + ['ERROR:', RED], ['FATAL:', RED], ['PANIC:', RED], ['psql: error:', RED], + ['WARNING:', YELLOW], + ['NOTICE:', BLUE], ['INFO:', BLUE], ['HINT:', BLUE], + ['DETAIL:', DIM], ['CONTEXT:', DIM], ['QUERY:', DIM], + ], + // `ERROR 1146 (42S02): Table 'x.y' doesn't exist` + mysql: [['ERROR ', RED], ['Warning (Code', YELLOW], ['Note (Code', BLUE]], + // `Parse error: no such table: x`; builds before 3.44 say `Error: ...` + sqlite3: [['Parse error', RED], ['Runtime error', RED], ['Error:', RED]], + // `Msg 208, Level 16, State 1, Server x, Line 1` + sqlcmd: [['Msg ', RED], ['Sqlcmd: Error:', RED], ['Sqlcmd: Warning:', YELLOW]], + 'redis-cli': [['(error)', RED]], +} +RULES_BY_CLIENT.mariadb = RULES_BY_CLIENT.mysql +RULES_BY_CLIENT['valkey-cli'] = RULES_BY_CLIENT['redis-cli'] + +const encoder = new TextEncoder() + +/** @param {string} client */ +function rulesFor(client) { + const rules = RULES_BY_CLIENT[client] ?? Object.values(RULES_BY_CLIENT).flat() + return rules.map(([prefix, sgr]) => ({ prefix: encoder.encode(prefix), sgr: encoder.encode(sgr) })) +} + +const RESET_BYTES = encoder.encode(RESET) +const CR = 0x0d +const LF = 0x0a + +/** Index of the next CR or LF at or after `from`, or the length. */ +function nextBreak(/** @type {Uint8Array} */ bytes, /** @type {number} */ from) { + for (let i = from; i < bytes.length; i++) { + if (bytes[i] === CR || bytes[i] === LF) return i + } + return bytes.length +} + +/** @param {Uint8Array[]} parts */ +function concat(parts) { + if (parts.length === 1) return parts[0] + const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)) + let at = 0 + for (const p of parts) { out.set(p, at); at += p.length } + return out +} + +/** + * @param {(bytes: Uint8Array) => void} write where the (coloured) output goes + * @param {{ client?: string, flushAfterMs?: number }} [opts] `client` picks the + * rules (`psql`, `mysql`, ...); unknown or absent uses every client's. + */ +export function createHighlighter(write, { client = '', flushAfterMs = 40 } = {}) { + const RULES = rulesFor(client) + let atLineStart = true + /** Bytes after a line break that might still become a prefix. */ + let held = /** @type {number[]} */ ([]) + /** Inside a coloured line: reset at its end. */ + let colouring = false + /** @type {ReturnType | null} */ + let timer = null + + /** @param {number[]} bytes */ + function couldMatch(bytes) { + return RULES.some(({ prefix }) => bytes.length <= prefix.length && bytes.every((b, i) => prefix[i] === b)) + } + + /** @param {number[]} bytes */ + function matched(bytes) { + return RULES.find(({ prefix }) => prefix.length === bytes.length && bytes.every((b, i) => prefix[i] === b)) + } + + function releaseHeld() { + timer = null + if (!held.length) return + const out = Uint8Array.from(held) + held = [] + atLineStart = false + write(out) + } + + /** @param {Uint8Array} chunk */ + function push(chunk) { + if (timer) { clearTimeout(timer); timer = null } + /** @type {Uint8Array[]} */ + const parts = [] + const n = chunk.length + let i = 0 + while (i < n) { + if (colouring) { + // The rest of a coloured line goes through as is; its break resets. + const end = nextBreak(chunk, i) + parts.push(chunk.subarray(i, end)) + i = end + if (end < n) { + parts.push(RESET_BYTES) + colouring = false + atLineStart = true + } + continue + } + if (atLineStart || held.length) { + const b = chunk[i++] + if (b === CR || b === LF) { + parts.push(Uint8Array.from([...held, b])) + held = [] + atLineStart = true + continue + } + held.push(b) + const rule = matched(held) + if (rule) { + parts.push(rule.sgr, Uint8Array.from(held)) + held = [] + colouring = true + atLineStart = false + } else if (!couldMatch(held)) { + parts.push(Uint8Array.from(held)) + held = [] + atLineStart = false + } + continue + } + // Plain text mid-line: copy through the next line break in one piece. + const end = nextBreak(chunk, i) + const stop = end < n ? end + 1 : n + parts.push(chunk.subarray(i, stop)) + if (end < n) atLineStart = true + i = stop + } + if (parts.length) write(concat(parts)) + if (held.length) timer = setTimeout(releaseHeld, flushAfterMs) + } + + /** Write out anything held, and close an open colour (the client exited). */ + function flush() { + if (timer) { clearTimeout(timer); timer = null } + releaseHeld() + if (colouring) { colouring = false; write(RESET_BYTES) } + } + + return { push, flush } +} From e557b20f8d9cb86211fa0ef4b6f46e1aac1f97c3 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:08:59 +0545 Subject: [PATCH 16/77] Test the terminal error highlighter --- src/lib/terminal-highlight.test.js | 65 ++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 src/lib/terminal-highlight.test.js diff --git a/src/lib/terminal-highlight.test.js b/src/lib/terminal-highlight.test.js new file mode 100644 index 00000000..694993a8 --- /dev/null +++ b/src/lib/terminal-highlight.test.js @@ -0,0 +1,65 @@ +import { describe, it, expect, vi } from 'vitest' +import { createHighlighter } from './terminal-highlight.js' + +const enc = new TextEncoder() +const dec = new TextDecoder() + +/** Push `chunks` through a highlighter and return everything it wrote, as text. */ +function run(chunks, client = 'psql') { + let out = '' + const h = createHighlighter((b) => { out += dec.decode(b, { stream: true }) }, { client }) + for (const c of chunks) h.push(enc.encode(c)) + h.flush() + return out +} + +describe('createHighlighter', () => { + it('colours a psql error line and resets at its end', () => { + expect(run(['ERROR: relation "x" does not exist\r\nLINE 1: select\r\n'])) + .toBe('\x1b[31mERROR: relation "x" does not exist\x1b[0m\r\nLINE 1: select\r\n') + }) + + it('matches a prefix split across chunks', () => { + expect(run(['db=# select 1/0;\r\nER', 'ROR: division by zero\r\n'])) + .toBe('db=# select 1/0;\r\n\x1b[31mERROR: division by zero\x1b[0m\r\n') + }) + + it('leaves ordinary output and prompts alone', () => { + const text = ' id | name\r\n----+------\r\n 1 | ERROR: not at column 0\r\n(1 row)\r\n\r\npsql_test=# ' + expect(run([text])).toBe(text) + }) + + it('passes UTF-8 and escape sequences through byte for byte', () => { + const text = '\x1b[1;34mcafé\x1b[0m=# naïve ✓\r\n' + expect(run([text])).toBe(text) + }) + + it('uses only the running client\'s rules', () => { + expect(run(['Msg 208, Level 16\r\n'], 'psql')).toBe('Msg 208, Level 16\r\n') + expect(run(['Msg 208, Level 16\r\n'], 'sqlcmd')).toBe('\x1b[31mMsg 208, Level 16\x1b[0m\r\n') + expect(run(["ERROR 1146 (42S02): Table 'a.b' doesn't exist\n"], 'mariadb')) + .toBe("\x1b[31mERROR 1146 (42S02): Table 'a.b' doesn't exist\x1b[0m\n") + }) + + it('colours warnings and notices in their own colours', () => { + expect(run(['WARNING: there is no transaction in progress\r\n'])) + .toBe('\x1b[33mWARNING: there is no transaction in progress\x1b[0m\r\n') + expect(run(['NOTICE: table "t" does not exist, skipping\r\n'])) + .toBe('\x1b[34mNOTICE: table "t" does not exist, skipping\x1b[0m\r\n') + }) + + it('releases a held partial prefix when no more output comes', () => { + vi.useFakeTimers() + let out = '' + const h = createHighlighter((b) => { out += dec.decode(b) }, { client: 'psql' }) + h.push(enc.encode('done\r\nE')) + expect(out).toBe('done\r\n') + vi.advanceTimersByTime(50) + expect(out).toBe('done\r\nE') + vi.useRealTimers() + }) + + it('closes an open colour on flush', () => { + expect(run(['FATAL: password authentication failed'])).toBe('\x1b[31mFATAL: password authentication failed\x1b[0m') + }) +}) From 7d1107b7cf8d77c6763a565577eb2adc504ed72e Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:08:59 +0545 Subject: [PATCH 17/77] Build the terminal's colours from the app theme --- src/lib/terminal-theme.js | 120 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 src/lib/terminal-theme.js diff --git a/src/lib/terminal-theme.js b/src/lib/terminal-theme.js new file mode 100644 index 00000000..65921b90 --- /dev/null +++ b/src/lib/terminal-theme.js @@ -0,0 +1,120 @@ +/** + * xterm.js colours from the app theme, so the terminal sits on the same surface + * as the SQL editor and its ANSI palette is the theme's own: red is + * `--destructive`, and green, yellow, blue and magenta are the colours the + * editor gives strings, numbers, names and keywords. psql's prompt, the error + * highlighter (terminal-highlight.js) and every client's own colours all draw + * from these, so they change with the theme like the rest of the app. + * + * xterm needs concrete colours and the tokens are oklch, so each one is + * resolved on a probe element and rasterized to sRGB. + */ + +/** @type {CanvasRenderingContext2D | null} */ +let ctx = null + +/** + * A CSS colour expression (a token, `var()`, `color-mix()`) as [r, g, b], or + * null when it does not resolve here. + * @param {string} expr + * @param {HTMLElement} host where the probe goes, so scoped tokens apply + * @returns {[number, number, number] | null} + */ +function rgbOf(expr, host) { + const probe = document.createElement('span') + probe.style.cssText = 'position:absolute;visibility:hidden;pointer-events:none' + host.appendChild(probe) + probe.style.color = 'var(--stroke-unset-token)' + const unset = getComputedStyle(probe).color + probe.style.color = expr + const color = getComputedStyle(probe).color + probe.remove() + // An unknown token makes the declaration invalid at computed-value time, and + // the probe then inherits: the same colour as the deliberately unset one. + if (!color || color === unset) return null + if (!ctx) { + const canvas = document.createElement('canvas') + canvas.width = canvas.height = 1 + ctx = canvas.getContext('2d', { willReadFrequently: true }) + } + if (!ctx) return null + ctx.clearRect(0, 0, 1, 1) + ctx.fillStyle = color + ctx.fillRect(0, 0, 1, 1) + const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data + return [r, g, b] +} + +/** @param {[number, number, number]} rgb @param {number} [alpha] 0..1 */ +function hex([r, g, b], alpha) { + const h = (/** @type {number} */ n) => n.toString(16).padStart(2, '0') + return `#${h(r)}${h(g)}${h(b)}${alpha === undefined ? '' : h(Math.round(alpha * 255))}` +} + +/** Relative luminance, 0 (black) to 1 (white). */ +function luminance(/** @type {[number, number, number]} */ [r, g, b]) { + const lin = (/** @type {number} */ c) => { + const s = c / 255 + return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4 + } + return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b) +} + +/** + * @param {HTMLElement} host the terminal's container + * @returns {import('@xterm/xterm').ITheme} + */ +export function terminalTheme(host) { + // The SQL editor's surface, so the two read as one family. + const bg = rgbOf('var(--panel, var(--background))', host) ?? [10, 10, 10] + const dark = luminance(bg) < 0.25 + /** @param {string} expr @param {[number, number, number]} fallback */ + const rgb = (expr, fallback) => rgbOf(expr, host) ?? fallback + const fg = rgb('var(--foreground)', dark ? [229, 229, 229] : [26, 26, 26]) + const muted = rgb('var(--muted-foreground)', dark ? [160, 160, 160] : [100, 100, 100]) + const surface = rgb('var(--muted)', dark ? [38, 38, 38] : [235, 235, 235]) + const accent = rgb('var(--primary)', fg) + + const red = hex(rgb('var(--destructive)', [229, 72, 77])) + const green = hex(rgb('var(--json-string)', [74, 180, 110])) + const yellow = hex(rgb('var(--json-number)', [214, 160, 60])) + const blue = hex(rgb('var(--json-key)', [90, 150, 230])) + const magenta = hex(rgb('var(--json-boolean)', [180, 110, 220])) + // No token is cyan; halfway between the success green and the info blue is. + const cyan = hex(rgb('color-mix(in oklch, var(--success), var(--info))', [70, 180, 190])) + + return { + background: hex(bg), + foreground: hex(fg), + cursor: hex(fg), + cursorAccent: hex(bg), + selectionBackground: hex(accent, dark ? 0.32 : 0.22), + selectionInactiveBackground: hex(muted, 0.2), + scrollbarSliderBackground: hex(muted, 0.18), + scrollbarSliderHoverBackground: hex(muted, 0.32), + scrollbarSliderActiveBackground: hex(muted, 0.45), + // ANSI black and white are the ends of the theme's own range: on a dark + // theme black is a raised surface and white the text; on a light one the + // other way round, so "white" text stays readable on the light canvas. + black: dark ? hex(surface) : hex(fg), + white: dark ? hex(fg) : hex(muted), + brightBlack: hex(muted), + brightWhite: hex(fg), + red, green, yellow, blue, magenta, cyan, + brightRed: red, + brightGreen: green, + brightYellow: yellow, + brightBlue: blue, + brightMagenta: magenta, + brightCyan: cyan, + } +} + +/** + * The editor's font as xterm wants it: the family stack and a pixel size. + * @param {HTMLElement} host styled with the editor's font-family and font-size + */ +export function terminalFont(host) { + const style = getComputedStyle(host) + return { fontFamily: style.fontFamily, fontSize: Math.round(parseFloat(style.fontSize)) || 13 } +} From e557664e8cfb03bce2dff38c5d2e90feab80cbd4 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:08:59 +0545 Subject: [PATCH 18/77] Suggest commands, schema names and dialect translations in the terminal --- src/lib/terminal-complete.js | 387 +++++++++++++++++++++++++++++++++++ 1 file changed, 387 insertions(+) create mode 100644 src/lib/terminal-complete.js diff --git a/src/lib/terminal-complete.js b/src/lib/terminal-complete.js new file mode 100644 index 00000000..295955e9 --- /dev/null +++ b/src/lib/terminal-complete.js @@ -0,0 +1,387 @@ +/** + * Suggestions for the terminal tab: what to offer for the line being typed at + * the client's prompt, drawn on top of the real client. + * + * The client keeps its own line editing (and psql its own Tab completion); this + * only reads what has been typed and proposes the rest, and the page types the + * accepted text into the client like a user would. Three sources: + * + * - the client's own commands, with what each does (`\dt` List tables, + * `.schema`, redis commands); + * - SQL: keywords, functions, and the connection's tables and columns, by + * context (tables after FROM, a table's columns after `t.`), the same + * analysis the SQL editor uses; + * - translations, for a command typed in another client's dialect: `show + * databases` in psql offers `\l`, `\dt` in mysql offers `SHOW TABLES;`. + * psql has no SHOW DATABASES, and mysql reads `\d` as "change delimiter", so + * these save a confusing error (or worse). + * + * Pure: no DOM, no client. The page passes the line and the schema hints. + */ +import { PG_KEYWORDS, PG_FUNCTIONS, DIALECT_KEYWORDS, analyzeQuery } from '$lib/sql-complete-data.js' + +/** + * @typedef {object} Suggestion + * @property {string} label what the list shows + * @property {string} insert what accepting types + * @property {string} [detail] the muted text on the right + * @property {'meta' | 'keyword' | 'table' | 'column' | 'function' | 'command' | 'translate'} kind + * @property {boolean} [replaceLine] accepting replaces the whole line + * @property {boolean} [run] accepting also presses Enter + * @property {string} [keys] exact keystrokes to send instead (`clear` sends Ctrl+L) + * @property {boolean} [exact] it is what was typed already: Enter on it runs the line + * @property {number} [rank] position in a curated list (most used first) + */ + +/** + * @typedef {object} SchemaHints + * @property {string[]} [tables] + * @property {Record>} [columnsByTable] + */ + +/** @type {Array<[string, string]>} */ +export const PSQL_META = [ + ['\\l', 'List databases'], ['\\l+', 'List databases with sizes'], + ['\\c', 'Connect to another database'], ['\\conninfo', 'Show the current connection'], + ['\\dt', 'List tables'], ['\\dt+', 'List tables with sizes'], + ['\\d', 'Describe a table, view or index'], ['\\d+', 'Describe, with storage and comments'], + ['\\dn', 'List schemas'], ['\\dv', 'List views'], ['\\dm', 'List materialized views'], + ['\\di', 'List indexes'], ['\\ds', 'List sequences'], ['\\df', 'List functions'], + ['\\dT', 'List data types'], ['\\du', 'List roles'], ['\\dp', 'List privileges'], + ['\\dx', 'List extensions'], ['\\dE', 'List foreign tables'], + ['\\sf', 'Show a function definition'], ['\\sv', 'Show a view definition'], + ['\\x', 'Toggle expanded output'], ['\\timing', 'Toggle query timing'], + ['\\e', 'Edit the query in $EDITOR'], ['\\i', 'Run commands from a file'], + ['\\o', 'Send results to a file'], ['\\copy', 'Copy between a table and a local file'], + ['\\g', 'Run the query buffer'], ['\\gx', 'Run the query buffer, expanded'], + ['\\watch', 'Run the query again every few seconds'], ['\\s', 'Show command history'], + ['\\h', 'Help on an SQL command'], ['\\?', 'Help on psql commands'], + ['\\set', 'Set a psql variable'], ['\\pset', 'Set an output option'], + ['\\a', 'Toggle aligned output'], ['\\t', 'Toggle rows only'], + ['\\echo', 'Print text'], ['\\!', 'Run a shell command'], ['\\q', 'Quit psql'], +] + +/** psql commands whose argument is a table (or view) name. */ +const PSQL_TABLE_ARG = new Set(['\\d', '\\d+', '\\dt', '\\dt+', '\\di', '\\dv', '\\dm', '\\sv', '\\dp', '\\z']) + +/** @type {Array<[string, string]>} */ +export const SQLITE_DOT = [ + ['.tables', 'List tables'], ['.schema', 'Show CREATE statements'], + ['.indexes', 'List indexes'], ['.databases', 'List attached databases'], + ['.mode', 'Output mode: box, table, csv, json, line'], ['.headers', 'Column headers on or off'], + ['.timer', 'Time each statement, on or off'], ['.dump', 'Dump the database as SQL'], + ['.import', 'Import a file into a table'], ['.output', 'Send output to a file'], + ['.read', 'Run SQL from a file'], ['.show', 'Show the current settings'], + ['.help', 'Help'], ['.quit', 'Quit sqlite3'], +] + +const SQLITE_TABLE_ARG = new Set(['.schema', '.indexes', '.dump']) + +/** @type {Array<[string, string]>} */ +const MYSQL_STATEMENTS = [ + ['SHOW DATABASES;', 'List databases'], ['SHOW TABLES;', 'List tables'], + ['SHOW COLUMNS FROM', 'List a table\'s columns'], ['SHOW CREATE TABLE', 'Show a table\'s DDL'], + ['SHOW PROCESSLIST;', 'List running sessions'], ['SHOW VARIABLES LIKE', 'Find a server setting'], + ['SHOW INDEX FROM', 'List a table\'s indexes'], ['SHOW GRANTS;', 'Your privileges'], + ['DESCRIBE', 'Describe a table'], ['USE', 'Switch database'], ['STATUS', 'Connection status'], +] + +/** @type {Array<[string, string]>} */ +const REDIS_COMMANDS = [ + ['GET', 'Value of a key'], ['SET', 'Set a key'], ['DEL', 'Delete keys'], + ['EXISTS', 'Whether keys exist'], ['TYPE', 'Type of a key'], ['TTL', 'Seconds until a key expires'], + ['EXPIRE', 'Expire a key after seconds'], ['KEYS', 'Keys matching a pattern (blocks; prefer SCAN)'], + ['SCAN', 'Iterate keys'], ['HGETALL', 'Every field of a hash'], ['HGET', 'One field of a hash'], + ['HSET', 'Set hash fields'], ['LRANGE', 'A range of a list'], ['LPUSH', 'Push onto a list'], + ['SMEMBERS', 'Every member of a set'], ['SADD', 'Add to a set'], + ['ZRANGE', 'A range of a sorted set'], ['ZADD', 'Add to a sorted set'], + ['INCR', 'Increment a counter'], ['INFO', 'Server information'], ['DBSIZE', 'Number of keys'], + ['SELECT', 'Switch logical database'], ['PING', 'Check the connection'], + ['CLIENT LIST', 'Connected clients'], ['CONFIG GET', 'Read a setting'], + ['MEMORY USAGE', 'Bytes a key uses'], ['MONITOR', 'Stream every command (debug)'], +] + +/** Ctrl+E, Ctrl+U, Ctrl+L: clear the typed line, then the screen (readline's clear-screen). */ +const CLEAR_KEYS = '\x05\x15\x0c' + +/** + * Commands typed in another client's dialect (or a shell's), and what this + * client calls them. `$1` is the captured name; a fourth entry sends those + * keystrokes instead of a command. + * @type {Record>} + */ +const TRANSLATIONS = { + psql: [ + [/^ls(?:\s+-\w+)?$/, '\\dt', 'Tables, the psql way'], + [/^cd\s+([\w"-]+)$/, '\\c $1', 'psql switches database with \\c'], + [/^(?:clear|cls)$/, 'clear', 'Clear the screen', CLEAR_KEYS], + [/^show\s+databases?\s*;?$/i, '\\l', 'psql lists databases with \\l'], + [/^show\s+tables\s*;?$/i, '\\dt', 'psql lists tables with \\dt'], + [/^show\s+(?:schemas|schemata)\s*;?$/i, '\\dn', 'psql lists schemas with \\dn'], + [/^(?:describe|desc)\s+([\w."]+)\s*;?$/i, '\\d $1', 'psql describes a table with \\d'], + [/^show\s+(?:full\s+)?(?:columns|fields)\s+from\s+([\w."]+)\s*;?$/i, '\\d $1', 'psql describes a table with \\d'], + [/^show\s+create\s+table\s+([\w."]+)\s*;?$/i, '\\d+ $1', 'psql shows a table\'s definition with \\d+'], + [/^show\s+index(?:es)?\s+from\s+([\w."]+)\s*;?$/i, '\\d $1', 'psql lists indexes in \\d'], + [/^use\s+([\w"-]+)\s*;?$/i, '\\c $1', 'psql switches database with \\c'], + [/^show\s+(?:users|grants)\s*;?$/i, '\\du', 'psql lists roles with \\du'], + [/^show\s+(?:full\s+)?processlist\s*;?$/i, 'SELECT pid, usename, state, query FROM pg_stat_activity;', 'Sessions on this server'], + [/^\.tables\s*$/, '\\dt', 'psql lists tables with \\dt'], + [/^\.schema\s+(\S+)$/, '\\d $1', 'psql describes a table with \\d'], + ], + mysql: [ + [/^ls$/, 'SHOW TABLES;', 'Tables, the MySQL way'], + [/^(?:clear|cls)$/, 'clear', 'Clear the screen', CLEAR_KEYS], + [/^\\l\+?$/, 'SHOW DATABASES;', 'MySQL lists databases with SHOW DATABASES'], + [/^\\dt\+?$/, 'SHOW TABLES;', 'MySQL lists tables with SHOW TABLES'], + [/^\\dn$/, 'SHOW DATABASES;', 'Schemas are databases in MySQL'], + [/^\\d\+?\s+(\S+)$/, 'DESCRIBE $1;', 'MySQL describes a table with DESCRIBE'], + [/^\\c\s+(\S+)$/, 'USE $1;', 'MySQL switches database with USE'], + [/^\\du$/, 'SELECT user, host FROM mysql.user;', 'Users on this server'], + [/^\.tables$/, 'SHOW TABLES;', 'MySQL lists tables with SHOW TABLES'], + ], + sqlite3: [ + [/^ls$/, '.tables', 'Tables, the sqlite3 way'], + [/^(?:clear|cls)$/, 'clear', 'Clear the screen', CLEAR_KEYS], + [/^(?:\\dt\+?|show\s+tables\s*;?)$/i, '.tables', 'sqlite3 lists tables with .tables'], + [/^(?:\\l|show\s+databases?\s*;?)$/i, '.databases', 'sqlite3 lists databases with .databases'], + [/^(?:\\d\+?|describe|desc)\s+(\S+?)\s*;?$/i, '.schema $1', 'sqlite3 shows a table with .schema'], + [/^\\di$/, '.indexes', 'sqlite3 lists indexes with .indexes'], + [/^(?:\\q|exit|quit)$/i, '.quit', 'sqlite3 quits with .quit'], + ], +} + +/** @param {string} client */ +function familyOf(client) { + if (client === 'mariadb') return 'mysql' + if (client === 'valkey-cli') return 'redis-cli' + return client +} + +const KIND_ORDER = { translate: 0, meta: 1, command: 1, column: 2, table: 3, function: 4, keyword: 5 } +const MAX_ITEMS = 8 + +/** First words of a statement, by client family: all that is offered for the first word. */ +const START_KEYWORDS = [ + 'SELECT', 'WITH', 'INSERT INTO', 'UPDATE', 'DELETE FROM', 'CREATE TABLE', 'CREATE INDEX', 'CREATE VIEW', + 'ALTER TABLE', 'DROP TABLE', 'TRUNCATE', 'EXPLAIN', 'EXPLAIN ANALYZE', 'BEGIN', 'COMMIT', 'ROLLBACK', + 'GRANT', 'REVOKE', 'VACUUM', 'ANALYZE', 'COPY', 'SHOW', 'SET', 'TABLE', 'VALUES', +] +const START_EXTRA = /** @type {Record} */ ({ + mysql: ['USE', 'DESCRIBE', 'REPLACE INTO'], + sqlite3: ['PRAGMA', 'ATTACH DATABASE'], +}) + +/** First words that make the line SQL. Anything else (`ls`, a typo) gets no SQL suggestions. */ +const SQL_STARTERS = new Set([ + 'select', 'with', 'values', 'table', 'insert', 'update', 'delete', 'merge', 'replace', 'create', 'alter', + 'drop', 'truncate', 'comment', 'grant', 'revoke', 'explain', 'analyze', 'analyse', 'vacuum', 'reindex', + 'cluster', 'refresh', 'show', 'describe', 'desc', 'pragma', 'call', 'exec', 'execute', 'set', 'reset', + 'use', 'begin', 'start', 'commit', 'rollback', 'savepoint', 'release', 'lock', 'copy', 'attach', + 'detach', 'do', 'prepare', 'deallocate', 'discard', 'listen', 'notify', 'declare', 'fetch', 'move', 'close', +]) + +/** Keywords and functions wait for this many letters; one letter matches too much to help. */ +const MIN_WORD = 2 + +/** + * @param {Array<[string, string]>} list + * @param {string} typed + * @param {Suggestion['kind']} kind + * @returns {Suggestion[]} + */ +function fromList(list, typed, kind) { + const lower = typed.toLowerCase() + return list + .map(([cmd, detail], rank) => ({ label: cmd, insert: cmd, detail, kind, rank, exact: cmd.toLowerCase() === lower })) + .filter((item) => item.label.toLowerCase().startsWith(lower)) +} + +/** + * The list to show: exact matches first (Enter on one runs the line), then by + * kind and length; nothing at all when only exact matches are left, since a + * list that offers what was typed is noise. + * @param {Suggestion[]} items + * @param {string} token + */ +function finish(items, token) { + if (!items.some((i) => !i.exact)) return { items: [], token: '' } + const seen = new Set() + const unique = items.filter((i) => { + const key = i.insert.toLowerCase() + if (seen.has(key)) return false + seen.add(key) + return true + }) + // Curated lists keep their order (most used first); names go shorter first, + // `user` before `user_sessions`. + unique.sort((a, b) => (Number(!!b.exact) - Number(!!a.exact)) + || (KIND_ORDER[a.kind] - KIND_ORDER[b.kind]) + || ((a.rank ?? 0) - (b.rank ?? 0)) + || (a.label.length - b.label.length) + || a.label.localeCompare(b.label)) + return { items: unique.slice(0, MAX_ITEMS), token } +} + +/** A name as SQL needs it: bare when plain, quoted when not. @param {string} name @param {string} family */ +function sqlName(name, family) { + if (/^[a-z_][a-z0-9_$]*$/.test(name)) return name + const q = family === 'mysql' ? '`' : '"' + return `${q}${name.replaceAll(q, q + q)}${q}` +} + +/** @param {SchemaHints} hints @param {string} table */ +function columnsOf(hints, table) { + const all = hints.columnsByTable ?? {} + const lower = table.toLowerCase() + const key = Object.keys(all).find((k) => k.toLowerCase() === lower || k.toLowerCase().endsWith(`.${lower}`)) + return (key ? all[key] : []).map((c) => (typeof c === 'string' ? { name: c, type: '' } : c)) +} + +/** + * What to suggest for `line`, the text typed so far on the prompt's line. + * @param {{ client: string, line: string, statement?: string, hints?: SchemaHints }} input + * `statement` is the earlier lines of a statement that is still open. + * @returns {{ items: Suggestion[], token: string }} `token` is the typed text + * the accepted item replaces (the end of `line`). + */ +export function suggest({ client, line, statement = '', hints = {} }) { + const family = familyOf(client) + const trimmed = line.trimStart() + const none = { items: [], token: '' } + if (!trimmed) return none + + // A whole command in another dialect: offer this client's spelling. + if (!statement) { + for (const [re, to, why, keys] of TRANSLATIONS[family] ?? []) { + const m = trimmed.match(re) + if (!m) continue + const insert = to.replace('$1', m[1] ?? '') + return { items: [{ label: insert, insert, detail: why, kind: 'translate', replaceLine: true, run: true, keys }], token: '' } + } + } + + const tables = hints.tables ?? [] + const tableItems = (/** @type {string} */ typed) => + tables + .filter((t) => t.toLowerCase().startsWith(typed.toLowerCase())) + .map((t) => /** @type {Suggestion} */ ({ + label: t, insert: sqlName(t, family), detail: 'table', kind: 'table', exact: t.toLowerCase() === typed.toLowerCase(), + })) + + // The client's own commands: psql's \x, sqlite3's .x, the first word in redis-cli. + const metaList = family === 'psql' ? PSQL_META : family === 'sqlite3' ? SQLITE_DOT : null + const metaLead = family === 'psql' ? '\\' : '.' + if (metaList && !statement && trimmed.startsWith(metaLead)) { + const space = trimmed.search(/\s/) + if (space === -1) return finish(fromList(metaList, trimmed, 'meta'), trimmed) + const cmd = trimmed.slice(0, space) + const argCmds = family === 'psql' ? PSQL_TABLE_ARG : SQLITE_TABLE_ARG + if (!argCmds.has(cmd)) return none + const arg = trimmed.slice(space).trimStart() + if (/\s/.test(arg) || !arg) return none + return finish(tableItems(arg), arg) + } + if (family === 'redis-cli') { + if (/\s/.test(trimmed)) return none + return finish(fromList(REDIS_COMMANDS, trimmed, 'command'), trimmed) + } + if (family === 'sqlcmd' && !statement && /^go$/i.test(trimmed)) return none + + // SQL. Nothing inside a string literal. + const before = `${statement}\n${line}` + if ((before.match(/'/g)?.length ?? 0) % 2 === 1) return none + const token = line.match(/[\w$.]*$/)?.[0] ?? '' + if (!token || /^\d/.test(token)) return none + const lower = token.toLowerCase() + const upperCase = token !== lower + + // The statement's first word: only what can start a statement. + if (!statement && trimmed === token) { + if (token.length < MIN_WORD) return none + const words = [...START_KEYWORDS, ...(START_EXTRA[family] ?? [])] + /** @type {Suggestion[]} */ + const items = words + .filter((w) => w.toLowerCase().startsWith(lower)) + .map((w) => { + const text = upperCase ? w : w.toLowerCase() + return { label: text, insert: text, kind: /** @type {const} */ ('keyword'), exact: w.toLowerCase() === lower } + }) + if (family === 'mysql') { + items.push(...fromList(MYSQL_STATEMENTS, token, 'command').map((i) => (upperCase ? i : { ...i, label: i.label.toLowerCase(), insert: i.insert.toLowerCase() }))) + } + return finish(items, token) + } + // Past the first word, only a line that is SQL gets SQL suggestions. + const firstWord = before.trimStart().match(/^[A-Za-z_]+/)?.[0]?.toLowerCase() ?? '' + if (!SQL_STARTERS.has(firstWord)) return none + + // `alias.` or `table.`: that table's columns. + const dot = token.lastIndexOf('.') + if (dot !== -1) { + const qualifier = token.slice(0, dot).split('.').pop()?.toLowerCase() ?? '' + const part = token.slice(dot + 1) + const { aliasMap } = analyzeQuery(before, tables) + const table = aliasMap[qualifier] ?? qualifier + const items = columnsOf(hints, table) + .filter((c) => c.name.toLowerCase().startsWith(part.toLowerCase())) + .map((c) => /** @type {Suggestion} */ ({ + label: c.name, insert: sqlName(c.name, family), detail: c.type || 'column', kind: 'column', + exact: c.name.toLowerCase() === part.toLowerCase(), + })) + return finish(items, part) + } + + // Context from the text before the word, or a half-typed `Or` reads as OR. + const { kind, referencedTables } = analyzeQuery(before.slice(0, before.length - token.length), tables) + /** @type {Suggestion[]} */ + const items = [] + if (kind === 'table') { + items.push(...tableItems(token)) + } else { + if (kind === 'column') { + for (const t of referencedTables) { + for (const c of columnsOf(hints, t)) { + if (c.name.toLowerCase().startsWith(lower)) { + items.push({ label: c.name, insert: sqlName(c.name, family), detail: c.type || t, kind: 'column', exact: c.name.toLowerCase() === lower }) + } + } + } + } else { + items.push(...tableItems(token)) + } + if (token.length >= MIN_WORD) { + const dialect = family === 'psql' ? 'postgres' : family === 'sqlite3' ? 'sqlite' : family === 'sqlcmd' ? 'mssql' : family + for (const kw of new Set([...PG_KEYWORDS, ...(DIALECT_KEYWORDS[dialect] ?? [])])) { + if (kw.toLowerCase().startsWith(lower)) { + const text = upperCase ? kw : kw.toLowerCase() + items.push({ label: text, insert: text, kind: 'keyword', exact: kw.toLowerCase() === lower }) + } + } + // Functions belong in expressions: the select list, WHERE, and so on. + if (family === 'psql' && kind === 'column') { + for (const fn of PG_FUNCTIONS) { + if (fn.label.startsWith(lower)) { + items.push({ label: `${fn.label}()`, insert: `${fn.label}(`, detail: 'function', kind: 'function', exact: fn.label === lower }) + } + } + } + } + } + return finish(items, token) +} + +/** + * The keystrokes that turn the typed `token` into `item`: the rest of the word + * when it only extends what was typed, otherwise erase the word and type it + * whole. A whole-line replacement goes to the end of the line, clears it + * (Ctrl+E, Ctrl+U: readline, editline and linenoise all know both) and types + * the new line. + * @param {Suggestion} item + * @param {string} token + */ +export function keystrokesFor(item, token) { + if (item.keys) return item.keys + if (item.replaceLine) return `\x05\x15${item.insert}${item.run ? '\r' : ''}` + if (item.insert.startsWith(token)) return item.insert.slice(token.length) + return '\x7f'.repeat(token.length) + item.insert +} From 0b3ce38dc0674338866502ff7ba6353f17eb31c4 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:08:59 +0545 Subject: [PATCH 19/77] Test terminal suggestions --- src/lib/terminal-complete.test.js | 99 +++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 src/lib/terminal-complete.test.js diff --git a/src/lib/terminal-complete.test.js b/src/lib/terminal-complete.test.js new file mode 100644 index 00000000..53342efe --- /dev/null +++ b/src/lib/terminal-complete.test.js @@ -0,0 +1,99 @@ +import { describe, it, expect } from 'vitest' +import { suggest, keystrokesFor } from './terminal-complete.js' + +const hints = { + tables: ['users', 'user_sessions', 'messages', 'Orders'], + columnsByTable: { + users: [{ name: 'id', type: 'uuid' }, { name: 'email', type: 'text' }, { name: 'created_at', type: 'timestamptz' }], + 'public.messages': ['id', 'body', 'user_id'], + }, +} +const labels = (r) => r.items.map((i) => i.label) + +describe('suggest', () => { + it('translates a MySQL command typed into psql', () => { + const r = suggest({ client: 'psql', line: 'show databases' }) + expect(r.items[0]).toMatchObject({ insert: '\\l', kind: 'translate', replaceLine: true, run: true }) + expect(suggest({ client: 'psql', line: 'describe users;' }).items[0].insert).toBe('\\d users') + expect(suggest({ client: 'psql', line: 'use chatbot-dev' }).items[0].insert).toBe('\\c chatbot-dev') + }) + + it('translates psql commands typed into mysql, where \\d means delimiter', () => { + expect(suggest({ client: 'mariadb', line: '\\dt' }).items[0].insert).toBe('SHOW TABLES;') + expect(suggest({ client: 'mysql', line: '\\d users' }).items[0].insert).toBe('DESCRIBE users;') + expect(suggest({ client: 'sqlite3', line: 'show tables' }).items[0].insert).toBe('.tables') + }) + + it('does not translate inside a statement that is still open', () => { + expect(suggest({ client: 'psql', line: 'show tables', statement: 'select 1' }).items.every((i) => i.kind !== 'translate')).toBe(true) + }) + + it('lists psql meta-commands with what they do, and tables after \\d', () => { + const r = suggest({ client: 'psql', line: '\\d' }) + expect(r.token).toBe('\\d') + expect(r.items.find((i) => i.label === '\\dt')?.detail).toBe('List tables') + expect(labels(suggest({ client: 'psql', line: '\\d us', hints }))).toEqual(['users', 'user_sessions']) + }) + + it('offers tables after FROM and a table\'s columns after its alias', () => { + expect(labels(suggest({ client: 'psql', line: 'select * from me', hints }))).toEqual(['messages']) + const r = suggest({ client: 'psql', line: 'select u.e', statement: 'select * from users u where', hints }) + expect(r).toMatchObject({ token: 'e' }) + expect(labels(r)).toEqual(['email']) + }) + + it('offers the referenced tables\' columns, then keywords in the typed case', () => { + const r = suggest({ client: 'psql', line: 'select * from users where cr', hints }) + expect(r.items[0]).toMatchObject({ label: 'created_at', kind: 'column' }) + expect(labels(suggest({ client: 'psql', line: 'sel' }))).toContain('select') + expect(labels(suggest({ client: 'psql', line: 'SEL' }))).toContain('SELECT') + }) + + it('offers only statement openers for the first word, and nothing for one letter', () => { + expect(labels(suggest({ client: 'psql', line: 'ins' }))).toEqual(['insert into']) + expect(labels(suggest({ client: 'psql', line: 'de' }))).toEqual(['delete from']) + expect(suggest({ client: 'psql', line: 'l' }).items).toEqual([]) + expect(suggest({ client: 'psql', line: 'select l' }).items).toEqual([]) + }) + + it('gives a line that is not SQL no SQL suggestions', () => { + expect(suggest({ client: 'psql', line: 'le', statement: 'ls' }).items).toEqual([]) + }) + + it('puts an exact match first so Enter runs it, and hides a list of only exact matches', () => { + const r = suggest({ client: 'psql', line: '\\dt' }) + expect(r.items[0]).toMatchObject({ label: '\\dt', exact: true }) + expect(r.items[1].label).toBe('\\dt+') + expect(suggest({ client: 'psql', line: '\\conninfo' }).items).toEqual([]) + }) + + it('maps shell habits', () => { + expect(suggest({ client: 'psql', line: 'ls' }).items[0].insert).toBe('\\dt') + expect(suggest({ client: 'psql', line: 'cd chatbot-dev' }).items[0].insert).toBe('\\c chatbot-dev') + expect(keystrokesFor(suggest({ client: 'psql', line: 'clear' }).items[0], '')).toBe('\x05\x15\x0c') + }) + + it('quotes names that need it', () => { + expect(suggest({ client: 'psql', line: 'select * from Or', hints }).items[0].insert).toBe('"Orders"') + expect(suggest({ client: 'mysql', line: 'select * from Or', hints }).items[0].insert).toBe('`Orders`') + }) + + it('stays quiet inside strings, on numbers and once the word is complete', () => { + expect(suggest({ client: 'psql', line: "select 'us", hints }).items).toEqual([]) + expect(suggest({ client: 'psql', line: 'select 12', hints }).items).toEqual([]) + expect(suggest({ client: 'psql', line: 'select * from users', hints }).items.map((i) => i.label)).not.toContain('users') + }) + + it('completes redis-cli commands on the first word only', () => { + expect(labels(suggest({ client: 'redis-cli', line: 'hg' }))).toEqual(['HGETALL', 'HGET']) + expect(suggest({ client: 'redis-cli', line: 'GET us' }).items).toEqual([]) + }) +}) + +describe('keystrokesFor', () => { + it('types the rest of a word, retypes a word whose case changed, replaces a line', () => { + expect(keystrokesFor({ label: 'users', insert: 'users', kind: 'table' }, 'us')).toBe('ers') + expect(keystrokesFor({ label: 'SELECT', insert: 'SELECT', kind: 'keyword' }, 'sel')).toBe('\x7f\x7f\x7fSELECT') + expect(keystrokesFor({ label: '\\l', insert: '\\l', kind: 'translate', replaceLine: true, run: true }, '')).toBe('\x05\x15\\l\r') + }) +}) From 340c9071016bd979da064810ad8e540348e167da Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:08:59 +0545 Subject: [PATCH 20/77] Add the terminal tab kind --- src/lib/studio-tabs.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/lib/studio-tabs.js b/src/lib/studio-tabs.js index 7ed10436..c7c5c8d4 100644 --- a/src/lib/studio-tabs.js +++ b/src/lib/studio-tabs.js @@ -1,4 +1,4 @@ -/** @typedef {'table' | 'sql' | 'ddl' | 'welcome' | 'ai' | 'schema' | 'orm' | 'security' | 'logs' | 'extensions' | 'extension-detail' | 'backup' | 'json' | 'charts' | 'dashboard' | 'erd' | 'reltree' | 'diagrams' | 'search' | 'notebook' | 'schema-timeline' | 'data-diff' | 'insights' | 'objects' | 'redis' | 'license' | 'orm-schema' | 'map' | 'advisor' | 'golf'} StudioTabKind */ +/** @typedef {'table' | 'sql' | 'ddl' | 'welcome' | 'ai' | 'schema' | 'orm' | 'security' | 'logs' | 'extensions' | 'extension-detail' | 'backup' | 'json' | 'charts' | 'dashboard' | 'erd' | 'reltree' | 'diagrams' | 'search' | 'notebook' | 'schema-timeline' | 'data-diff' | 'insights' | 'objects' | 'redis' | 'license' | 'orm-schema' | 'map' | 'advisor' | 'golf' | 'terminal'} StudioTabKind */ import { loadDefaultPageSize } from '$lib/table-query.js' @@ -231,6 +231,7 @@ const SINGLETON_TAB_TITLES = { 'schema-timeline': 'Schema Timeline', 'data-diff': 'Data Diff', golf: 'VACUUM', + terminal: 'Terminal', } /** @param {StudioTabKind} kind */ @@ -273,6 +274,8 @@ export const createLicenseTab = () => createKindTab('license') export const createSearchTab = () => createKindTab('search') export const createSchemaTimelineTab = () => createKindTab('schema-timeline') export const createDataDiffTab = () => createKindTab('data-diff') +/** The connection's own CLI (psql, mysql, sqlite3...) in a real terminal. */ +export const createTerminalTab = () => createKindTab('terminal') /** @type {(tabs: StudioTab[]) => StudioTab | null} */ export const findAiTab = (tabs) => findTabByKind(tabs, 'ai') @@ -287,6 +290,8 @@ export const findSecurityTab = (tabs) => findTabByKind(tabs, 'security') /** @type {(tabs: StudioTab[]) => StudioTab | null} */ export const findLogsTab = (tabs) => findTabByKind(tabs, 'logs') /** @type {(tabs: StudioTab[]) => StudioTab | null} */ +export const findTerminalTab = (tabs) => findTabByKind(tabs, 'terminal') +/** @type {(tabs: StudioTab[]) => StudioTab | null} */ export const findInsightsTab = (tabs) => findTabByKind(tabs, 'insights') /** @type {(tabs: StudioTab[]) => StudioTab | null} */ export const findAdvisorTab = (tabs) => findTabByKind(tabs, 'advisor') From 7e19a49c658d7ab1db445e13d12440b5e51751b3 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:08:59 +0545 Subject: [PATCH 21/77] Give the terminal tab its icon --- src/lib/components/TabBar.svelte | 1 + src/lib/icon-registry.js | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/lib/components/TabBar.svelte b/src/lib/components/TabBar.svelte index 8ac256f3..038f361d 100644 --- a/src/lib/components/TabBar.svelte +++ b/src/lib/components/TabBar.svelte @@ -98,6 +98,7 @@ /** @param {StudioTab} tab */ function tabIcon(tab) { if (tab.kind === 'sql') return 'terminal' + if (tab.kind === 'terminal') return 'square-terminal' if (tab.kind === 'table') { const entityKind = /** @type {any} */ (tab.state)?.tableKind if (entityKind === 'view' || entityKind === 'materialized_view') return 'eye' diff --git a/src/lib/icon-registry.js b/src/lib/icon-registry.js index a0eec36a..23dcc163 100644 --- a/src/lib/icon-registry.js +++ b/src/lib/icon-registry.js @@ -36,6 +36,7 @@ import ExternalLink from '@lucide/svelte/icons/external-link' import Database from '@lucide/svelte/icons/database' import Code2 from '@lucide/svelte/icons/code-2' import Terminal from '@lucide/svelte/icons/terminal' +import SquareTerminal from '@lucide/svelte/icons/square-terminal' import BarChart2 from '@lucide/svelte/icons/bar-chart-2' import Sparkles from '@lucide/svelte/icons/sparkles' import GitBranch from '@lucide/svelte/icons/git-branch' @@ -198,6 +199,7 @@ export const ICON_MAP = { 'database': { lucide: Database, huge: Database01Icon }, 'code-2': { lucide: Code2, huge: SourceCodeIcon }, 'terminal': { lucide: Terminal, huge: TerminalIcon }, + 'square-terminal': { lucide: SquareTerminal }, 'bar-chart-2': { lucide: BarChart2, huge: ChartLineData02Icon }, 'sparkles': { lucide: Sparkles, huge: SparklesIcon }, 'git-branch': { lucide: GitBranch, huge: GitBranchIcon }, From 4747529008cdef0c193df2380316b69ba3da7fbb Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:08:59 +0545 Subject: [PATCH 22/77] Add the terminal page xterm.js in the SQL editor's font on the theme's colours, with suggestions, Enter that runs a finished statement, editor keys sent as readline keystrokes, and the app's Ctrl shortcuts handed back to it. --- src/lib/components/TerminalPage.svelte | 1115 ++++++++++++++++++++++++ 1 file changed, 1115 insertions(+) create mode 100644 src/lib/components/TerminalPage.svelte diff --git a/src/lib/components/TerminalPage.svelte b/src/lib/components/TerminalPage.svelte new file mode 100644 index 00000000..1cae1155 --- /dev/null +++ b/src/lib/components/TerminalPage.svelte @@ -0,0 +1,1115 @@ + + +
+ +
+
+ + {client?.name || 'Terminal'} + {#if shortVersion} + {shortVersion} + {/if} +
+ {#if target} + + + {targetParts.lead}{targetParts.db} + + {/if} +
+ {#if statusLabel} + + + {statusLabel} + + {/if} +
+ + +
+
+ +
+ +
+
+
+ + {#if suggestItems.length} +
+ {#each suggestItems as item, i (item.kind + item.label)} + {@const KindIcon = KIND_ICON[item.kind]} + {@const selected = i === suggestIndex} +
{ e.preventDefault(); acceptSuggestion(i) }} + onmousemove={() => (suggestIndex = i)} + > + + {item.label} + {#if item.detail} + {item.detail} + {/if} +
+ {/each} +
+ {/if} + + {#if scrolledUp && (phase === 'running' || phase === 'exited')} + + {/if} + + {#if phase === 'unsupported' || phase === 'missing' || phase === 'failed'} +
+
+ {#if phase === 'missing'} +
+ +
+

{client?.name} is not installed

+

+ This tab runs the real {client?.name}, so every command works the way it does in a terminal. Install it, then check again. +

+ {#if client?.hint} +
+ {client.hint} + {#if installLooksLikeCommand} + + {/if} +
+ {/if} + + {:else if phase === 'failed'} +
+ +
+

Could not start {client?.name || 'the terminal'}

+

{failure}

+ + {:else} +
+ +
+

No shell for this connection

+

{client?.hint}

+ {/if} +
+
+ {/if} +
+ + + {#if quickCommands.length && (phase === 'starting' || phase === 'running' || phase === 'exited')} +
+ {#if continuing && phase === 'running'} + + + + Statement open + run + new line + discard + + {:else} + {#each quickCommands as q (q.cmd)} + + {/each} + {/if} +
+ +
+ {/if} +
From be794daf405d4c1a35375cf682c0591480a2c130 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:08:59 +0545 Subject: [PATCH 23/77] Open the terminal tab from the shell --- src/lib/components/StudioShell.svelte | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index 03b84793..18ae9b3d 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -736,6 +736,8 @@ let ormEverOpened = $state(false) let securityEverOpened = $state(false) let logsEverOpened = $state(false) + /** The terminal page lives exactly as long as its tab, and its client with it. */ + const hasTerminalTab = $derived(tabs.some((t) => t.kind === 'terminal')) let insightsEverOpened = $state(false) let advisorEverOpened = $state(false) let golfEverOpened = $state(false) From 3c437a80a2859e204a1d2182d8e01bfa4f3c69df Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:08:59 +0545 Subject: [PATCH 24/77] Toggle the terminal with Ctrl+` --- src/lib/components/StudioShell.svelte | 21 +++++++++++++++++++++ src/lib/shortcuts.js | 1 + 2 files changed, 22 insertions(+) diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index 18ae9b3d..f0c91bba 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -2619,6 +2619,27 @@ let rowSearch = $state('') reopenLastClosedTab() }) + // The connection's own CLI in a terminal tab. Ctrl+` on every platform, as in + // VS Code (on macOS Cmd+` belongs to the window switcher), and like VS Code it + // toggles: pressed in the terminal, it goes back to the tab it came from. + /** The last tab shown before the terminal, however the terminal was reached. */ + let tabBeforeTerminal = /** @type {string | null} */ (null) + $effect(() => { + const tab = activeTab + if (tab && tab.kind !== 'terminal') untrack(() => { tabBeforeTerminal = tab.id }) + }) + createHotkey('Control+`', (e) => { + if (!connection) return + e.preventDefault() + if (activeTab?.kind === 'terminal') { + const back = tabs.find((t) => t.id === tabBeforeTerminal) ?? tabs.find((t) => t.kind !== 'terminal') + if (back) void activateTab(back.id) + return + } + if (aiMode) exitAiMode() + openTerminalTab() + }) + // Tab-bar visibility toggle moved here so Mod+Shift+T can reopen closed tabs. createHotkey('Alt+Shift+T', (e) => { e.preventDefault() diff --git a/src/lib/shortcuts.js b/src/lib/shortcuts.js index 5eab5297..37fa006a 100644 --- a/src/lib/shortcuts.js +++ b/src/lib/shortcuts.js @@ -123,6 +123,7 @@ export const SHORTCUT_GROUPS = [ { combo: 'Mod+Shift+D', desc: 'Disconnect' }, { combo: 'Mod+Shift+S', desc: 'SQL editor' }, { combo: 'Mod+Shift+O', desc: 'ORM Runner' }, + { combo: 'Control+`', desc: 'Terminal (psql, mysql, sqlite3...)' }, { combo: 'Mod+Shift+X', desc: 'Extensions' }, { combo: 'Mod+Shift+E', desc: 'Toggle AI panel' }, { combo: 'Mod+I', desc: 'Toggle AI sidebar' }, From 0cbecef1d484e6ef1555211cc767fbfeb514440a Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:09:00 +0545 Subject: [PATCH 25/77] List the terminal in the command palette --- src/lib/components/CommandPalette.svelte | 2 ++ src/lib/components/StudioShell.svelte | 1 + 2 files changed, 3 insertions(+) diff --git a/src/lib/components/CommandPalette.svelte b/src/lib/components/CommandPalette.svelte index 787654e1..33338eb5 100644 --- a/src/lib/components/CommandPalette.svelte +++ b/src/lib/components/CommandPalette.svelte @@ -49,6 +49,7 @@ geoAvailable = false, onopenredis = () => {}, onopenlogs = () => {}, + onopenterminal = () => {}, onopeninsights = () => {}, onopenadvisor = () => {}, onopenobjects = () => {}, @@ -105,6 +106,7 @@ { icon: 'search', label: 'Find in database', keys: '⌘⇧G', action: onglobalsearch, show: connected && !isRedis, value: 'find search rows data across all tables global database' }, { icon: 'terminal', label: 'SQL editor', keys: '⌘⇧S', action: onopensql, show: connected && !isRedis, value: 'open sql editor query console' }, { icon: 'plus', label: 'New SQL Editor', action: onnewsql, show: connected && !isRedis, value: 'new sql editor tab additional multiple query console open another' }, + { icon: 'square-terminal', label: 'Terminal', keys: '⌃`', action: onopenterminal, show: connected, value: 'terminal shell cli command line psql mysql mariadb sqlite3 sqlcmd redis-cli console repl meta commands' }, { icon: 'code-2', label: 'ORM Runner', keys: '⌘⇧O', action: onopenorm, show: connected && !isRedis, value: 'open orm runner drizzle prisma query builder' }, { icon: 'network', label: 'Schema Diagram', action: onopenerd, show: connected && !isRedis, value: 'open schema diagram data model er diagram erd entity relationship foreign key pk fk graph hierarchy roadmap mermaid tree dictionary ddl schema map' }, { icon: 'layout-template', label: 'Schema Explorer', action: onopenSchema, show: connected && hasSchemaExplorer, value: 'open schema explorer indexes enums views materialized' }, diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index f0c91bba..7970e95f 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -8025,6 +8025,7 @@ let rowSearch = $state('') onopenSchema={() => { if (aiMode) exitAiMode(); openSchemaTab() }} onopensecurity={() => { if (aiMode) exitAiMode(); openSecurityTab() }} onopenlogs={() => { if (aiMode) exitAiMode(); openLogsTab() }} + onopenterminal={() => { if (aiMode) exitAiMode(); openTerminalTab() }} onopeninsights={() => { if (aiMode) exitAiMode(); openInsightsTab() }} onopenadvisor={() => { if (aiMode) exitAiMode(); openAdvisorTab() }} onopenobjects={() => { if (aiMode) exitAiMode(); openObjectsTab() }} From 41ea45a525dec3b49b215c6646aa17cd51964169 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:09:00 +0545 Subject: [PATCH 26/77] Leave xterm's keys to the terminal The input layer treated xterm's hidden textarea as a text field and took Ctrl+Backspace and Ctrl+Z before the terminal saw them. --- src/lib/input-shortcuts.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/lib/input-shortcuts.js b/src/lib/input-shortcuts.js index 1345ba3e..3d5baa19 100644 --- a/src/lib/input-shortcuts.js +++ b/src/lib/input-shortcuts.js @@ -55,7 +55,11 @@ function inManagedEditor(/** @type {Element} */ el) { // CodeMirror manages its text surface (.cm-content), not the inputs in its // panels: matching all of .cm-editor left the find/replace fields with no // word delete, no undo and no shield from the global hotkeys. - return !!el.closest?.('.cm-content') + // xterm.js's hidden textarea is a terminal's keyboard, not a text field: its + // keys belong to the client in the terminal (psql does its own line editing). + // Managing it here swallowed Ctrl+Backspace, Ctrl+Z and the rest before + // xterm ever saw them. + return !!el.closest?.('.cm-content, .xterm') } /** @param {HTMLInputElement | HTMLTextAreaElement} el */ From 1c467e175ae9c9dda3efbc401407db9fcb11e122 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:09:00 +0545 Subject: [PATCH 27/77] Add a changeset for the terminal tab --- .changeset/terminal.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/terminal.md diff --git a/.changeset/terminal.md b/.changeset/terminal.md new file mode 100644 index 00000000..9476e5e0 --- /dev/null +++ b/.changeset/terminal.md @@ -0,0 +1,11 @@ +### New Features + +#### Terminal +- A Terminal tab runs the connection's own command-line client in the app: psql, mysql or mariadb, sqlite3, sqlcmd and redis-cli. Every meta-command, completion and your own .psqlrc work as they do in a terminal. Ctrl+` opens it and goes back +- Signs in with the saved connection: the password goes through the environment, never the command line, and the SSH tunnel is reused +- Suggestions as you type: the client's commands with what each does, your tables and columns, and SQL keywords. A command from another database is translated, so `show databases` in psql offers `\l` +- Enter runs a statement that looks finished and adds the `;`, Shift+Enter starts a new line, and the footer says when a statement is still open +- Wide results print one record per block instead of wrapping, tables draw with box lines, and NULL shows as ∅ +- Error, warning and notice lines are coloured; quick commands sit in the footer +- Editor keys work: Ctrl+Backspace and Alt+Backspace delete a word, Ctrl+A selects the line, Ctrl+Z undoes, Home and End move to the ends +- When the client is not installed, the tab shows the install command for your system From f3273424ccc92eecc9af06ff26f4b0dd4222837f Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:09:00 +0545 Subject: [PATCH 28/77] Use plain-case group labels in the command palette --- src/lib/components/ui/command/command-group.svelte | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/lib/components/ui/command/command-group.svelte b/src/lib/components/ui/command/command-group.svelte index 365a4d25..ec902ab2 100644 --- a/src/lib/components/ui/command/command-group.svelte +++ b/src/lib/components/ui/command/command-group.svelte @@ -24,8 +24,12 @@ {...restProps} > {#if heading} + {heading} From e44c7c8fbd6c338d4bf149ec2fa0ade9d1d590f3 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:09:00 +0545 Subject: [PATCH 29/77] Size the palette's search icon from the icon scale --- src/lib/components/ui/command/command-input.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/components/ui/command/command-input.svelte b/src/lib/components/ui/command/command-input.svelte index 21ecc26d..b4000f93 100644 --- a/src/lib/components/ui/command/command-input.svelte +++ b/src/lib/components/ui/command/command-input.svelte @@ -13,7 +13,7 @@
- + Date: Tue, 6 Oct 2026 14:09:00 +0545 Subject: [PATCH 30/77] Print palette shortcuts in each platform's keys --- .../components/ui/command/command-shortcut.svelte | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/lib/components/ui/command/command-shortcut.svelte b/src/lib/components/ui/command/command-shortcut.svelte index 81551f73..083c383d 100644 --- a/src/lib/components/ui/command/command-shortcut.svelte +++ b/src/lib/components/ui/command/command-shortcut.svelte @@ -1,6 +1,7 @@ From 6ee034697278c8b0263d05b4255ebae033aab30a Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:09:00 +0545 Subject: [PATCH 31/77] Give punctuation keycaps a readable glyph --- src/app.css | 6 ++++++ src/lib/components/Kbd.svelte | 4 +++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/app.css b/src/app.css index d38254d6..9ea92065 100644 --- a/src/app.css +++ b/src/app.css @@ -785,6 +785,12 @@ html[data-os="linux"] { --window-radius: 8px; } background: color-mix(in oklch, var(--muted) 65%, transparent); } + /* A punctuation key (` , . ;) drawn at the cap's text size is a speck; the + mark gets the body size, inside the same cap. */ + kbd[data-mark] { + font-size: var(--fs-xs); + } + /* kbd inside a primary-coloured surface (e.g. Save button) */ kbd.kbd-on-primary { color: color-mix(in oklch, var(--primary-foreground) 80%, transparent); diff --git a/src/lib/components/Kbd.svelte b/src/lib/components/Kbd.svelte index bef823c6..cd7104c4 100644 --- a/src/lib/components/Kbd.svelte +++ b/src/lib/components/Kbd.svelte @@ -55,12 +55,14 @@ : [], ) const total = $derived(groups.reduce((n, g) => n + g.length, 0)) + /** Keys that are a mark, not a letter: at keycap size a backtick is a speck. */ + const MARKS = new Set(['`', '~', "'", '"', ',', '.', ';', ':']) {#if total} {#each groups as group, g (g)}{#each group as cap, i (i)}{cap}{/each}{#each group as cap, i (i)}{cap}{/each}{/each} {/if} From d32bf5bf11992b0b97bdb79d8818595b3d6e6444 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:09:00 +0545 Subject: [PATCH 32/77] Brighten a palette row's icon when it is selected --- src/lib/components/CommandPalette.svelte | 58 ++++++++++++------------ 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/src/lib/components/CommandPalette.svelte b/src/lib/components/CommandPalette.svelte index 33338eb5..c192b6e4 100644 --- a/src/lib/components/CommandPalette.svelte +++ b/src/lib/components/CommandPalette.svelte @@ -631,7 +631,7 @@ {#snippet pageRow(/** @type {typeof pageItems[number]} */ it)} run(it.action)}> - + {it.label} {#if it.keys}{/if} @@ -742,7 +742,7 @@ {#each schemas as schema (schema)} run(() => onschemachange(schema))}> - + {schema} {#if schema === activeSchema} current @@ -756,14 +756,14 @@ {#each regularTables.slice(0, 8) as table (table.name)} run(() => ontableselect(table.name))}> - + {table.name} {formatTableRowCount(table.rowCount)} {/each} {#each viewTables.slice(0, 4) as table (table.name)} run(() => ontableselect(table.name))}> - + {table.name} view @@ -780,12 +780,12 @@ run(onopenai)}> - + Ask AI run(onopenaisidebar)}> - + AI sidebar @@ -793,14 +793,14 @@ value={aiMode ? "close ai panel hide assistant" : "open ai panel show assistant chat"} onSelect={() => run(ontoggleaimode)} > - + {aiMode ? 'Close AI panel' : 'Open AI panel'} run(onopenqueryhistory)}> - + Query history @@ -809,7 +809,7 @@ {#each savedQueries as entry (entry.id)} run(() => onqueryselect(entry.sql))}> - + {entry.name} {/each} @@ -820,7 +820,7 @@ {#each queryHistory.slice(0, 20) as entry (entry.id)} run(() => onqueryselect(entry.sql))}> - + {entry.title} {/each} @@ -829,42 +829,42 @@ run(onrefresh)}> - + Refresh tables run(onreadonlytoggle)}> - + {readonly ? 'Disable read-only mode' : 'Enable read-only mode'} run(onopensettings)}> - + Settings {#if $pinEnabled} run(lockNow)}> - + Lock Stroke {/if} run(onopenshortcuts)}> - + Keyboard shortcuts run(onopenabout)}> - + About Stroke run(onopenreport)}> - + Report an issue run(oncheckupdate)}> - + Check for updates run(ondisconnect)}> - + Disconnect @@ -879,7 +879,7 @@ value="switch database connection {conn.name} {connSubtitle(conn)} {conn.type}" onSelect={() => run(() => onswitchdatabase(conn))} > - +
{conn.name} {connSubtitle(conn)} @@ -898,7 +898,7 @@ value="connections switch database connect postgres mysql sqlite saved {savedConnections.map(c => c.name).join(' ')}" onSelect={() => navigate('connections')} > - + Connections {#if savedConnections.length > 0} {savedConnections.length} @@ -910,7 +910,7 @@ navigate('docker')}> - + Docker @@ -922,7 +922,7 @@ {#each tablesPageRegular.items as table (table.name)} run(() => ontableselect(table.name))}> - + {table.name} {#each tablesPageViews.items as table (table.name)} run(() => ontableselect(table.name))}> - + {table.name} {/each} @@ -957,7 +957,7 @@ {#each tablesPageMatViews.items as table (table.name)} run(() => ontableselect(table.name))}> - + {table.name} run(() => ondockerlaunch('postgres'))} > - + PostgreSQL container :5433 @@ -988,7 +988,7 @@ value="launch mysql container pull run 3307" onSelect={() => run(() => ondockerlaunch('mysql'))} > - + MySQL container :3307 @@ -1006,7 +1006,7 @@ onSelect={() => run(() => onswitchdatabase(conn))} disabled={isActive} > - +
{conn.name} {connSubtitle(conn)} @@ -1020,7 +1020,7 @@ {/if} run(onopenconnection)}> - + New connection… From 5da8916a46fe3f8a902bf9803faf5b5825b63c87 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:09:00 +0545 Subject: [PATCH 33/77] Name the database and its host on the new tab page --- src/lib/components/StudioShell.svelte | 38 +++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index 7970e95f..6eb2ca21 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -277,7 +277,7 @@ engineFamily, } from '$lib/stores/connections.js' import { hasPro, FREE_CONNECTION_LIMIT } from '$lib/stores/license.js' - import { engineSupports } from '$lib/db-capabilities.js' + import { engineSupports, engineLabel } from '$lib/db-capabilities.js' import * as Dialog from '$lib/components/ui/dialog/index.js' import KeyRound from '@lucide/svelte/icons/key-round' import { @@ -738,6 +738,26 @@ let logsEverOpened = $state(false) /** The terminal page lives exactly as long as its tab, and its client with it. */ const hasTerminalTab = $derived(tabs.some((t) => t.kind === 'terminal')) + /** The welcome page's tiles: six when connected (two rows of three), four otherwise and for Redis. */ + const welcomeTileCount = $derived(connection && !isRedis ? 6 : 4) + /** Welcome tile icon colours: theme tokens, written out whole so Tailwind sees them. */ + const TILE_TONE = { + info: 'text-info', + success: 'text-success', + primary: 'text-primary', + warning: 'text-warning', + } + /** The welcome header: which database this tab is on, and where it lives. */ + const welcomeTitle = $derived(connection ? String(connection.database || connection.name || 'Database') : '') + const welcomeWhere = $derived.by(() => { + if (!connection) return '' + const c = /** @type {any} */ (connection) + let where = '' + if (c.filePath) where = c.filePath === ':memory:' ? 'in memory' : String(c.filePath).split(/[\\/]/).pop() ?? '' + else if (c.url) { try { where = new URL(String(c.url).replace(/^libsql:/, 'https:')).host } catch { where = '' } } + else if (c.host) where = `${c.host}${c.port ? `:${c.port}` : ''}` + return [engineLabel(c.type), where].filter(Boolean).join(' · ') + }) let insightsEverOpened = $state(false) let advisorEverOpened = $state(false) let golfEverOpened = $state(false) @@ -9376,11 +9396,19 @@ let rowSearch = $state('') footer now all start where the first tile starts. -->
- -
-
- + +
+
+
+ {#if connection} +
+ {welcomeTitle} + {welcomeWhere} +
+ {/if}
{#snippet row(/** @type {any} */ Icon, /** @type {string} */ label, /** @type {string} */ _desc, /** @type {() => void} */ onclick, /** @type {{ pro?: boolean, keys?: string[] }} */ opts = {})} {@const locked = !!opts.pro && !$hasPro} + {@const chordText = opts.keys ? ` (${opts.keys.join(isMac ? '' : '+')})` : ''} {/snippet} @@ -9410,34 +9402,31 @@ let rowSearch = $state('')
{/if}
- - - - +
-

Quick access

-
+

Quick access

+
{#if isRedis} - {@render row(KeyRound, "Keyspace", "Browse keys and values", openRedisTab, {})} + {@render row(KeyRound, "Keyspace", "Browse keys and values", openRedisTab, { tone: 'info' })} {:else} - {@render row(Terminal, "SQL", "Write and run a query", openSqlTab, { keys: [mod, shiftKey, "S"] })} - {@render row(Sparkles, "AI", "Ask about this database", openAiTab, { pro: true, keys: [mod, shiftKey, "E"] })} + {@render row(Terminal, "SQL", "Write and run a query", openSqlTab, { keys: [mod, shiftKey, "S"], tone: 'info' })} + {/if} + {#if connection} + {@render row(SquareTerminal, "Terminal", isRedis ? "redis-cli, in a tab" : "psql, mysql or sqlite3", () => { if (aiMode) exitAiMode(); openTerminalTab() }, { keys: ["Ctrl", "`"], tone: 'success' })} + {/if} + {#if !isRedis} + {@render row(Sparkles, "AI", "Ask about this database", openAiTab, { pro: true, keys: [mod, shiftKey, "E"], tone: 'primary' })} + {/if} + {#if connection && !isRedis} + {@render row(Search, "Search", "Find in database: search every table at once", openSearchTab, { keys: [mod, shiftKey, "G"], tone: 'warning' })} {/if} - {@render row(Blocks, "Extensions", "Add and manage extensions", openExtensionsTab, { pro: true, keys: [mod, shiftKey, "X"] })} - {@render row(Database, "Connect", "Switch or add a connection", () => (showConnectionModal = true), { keys: [mod, shiftKey, "C"] })} + {@render row(Blocks, "Extensions", "Add and manage extensions", openExtensionsTab, { pro: true, keys: [mod, shiftKey, "X"], tone: 'info' })} + {@render row(Database, "Connect", "Switch or add a connection", () => (showConnectionModal = true), { keys: [mod, shiftKey, "C"], tone: 'success' })}
@@ -9451,13 +9440,13 @@ let rowSearch = $state('') weight. Same max width as the grid above, so both blocks sit on one alignment edge. -->
-

Jump to

+

Jump to

{@render jump(Plus, "New query editor", openNewSqlTab)} - {@render jump(Search, "Find in database", openSearchTab)} + {@render jump(ShieldCheck, "Advisor", openAdvisorTab)} {@render jump(Boxes, "Database objects", openObjectsTab)} {@render jump(GitBranch, "Schema explorer", openSchemaTab)} {@render jump(Gauge, "Instance insights", openInsightsTab)} From 7917d092d8a0b59cf13e8fc93d04343a317f03a4 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:09:00 +0545 Subject: [PATCH 35/77] Use a keyboard icon for Shortcuts off macOS --- src/lib/components/StudioShell.svelte | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index 55338db9..32d45e78 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -15,6 +15,7 @@ import LayoutTemplate from '@lucide/svelte/icons/layout-template' import { cn } from '$lib/utils.js' import Command from '@lucide/svelte/icons/command' + import Keyboard from '@lucide/svelte/icons/keyboard' import Code2 from '@lucide/svelte/icons/code-2' import ShieldCheck from '@lucide/svelte/icons/shield-check' import ScrollText from '@lucide/svelte/icons/scroll-text' @@ -9465,7 +9466,7 @@ let rowSearch = $state('') onclick={() => showShortcutsModal = true} class="flex items-center gap-1.5 rounded-md px-1 py-0.5 transition-colors hover:text-foreground" > - + {#if isMac}{:else}{/if} Shortcuts · From 4fd9fa024f664327a9d3c66a83effb835183b2fd Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:09:00 +0545 Subject: [PATCH 36/77] Add a changeset for the palette and new tab page --- .changeset/palette-welcome.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .changeset/palette-welcome.md diff --git a/.changeset/palette-welcome.md b/.changeset/palette-welcome.md new file mode 100644 index 00000000..62261893 --- /dev/null +++ b/.changeset/palette-welcome.md @@ -0,0 +1,3 @@ +### Changes +- The new tab page names the database it is on and where it lives, and its quick access tiles are smaller, one line each, with a colour per action. Terminal and Search join them +- Command palette shortcuts show your platform's keys (Ctrl on Windows and Linux) instead of macOS symbols, its group labels are plain case, and a row's icon brightens with the row From 3586609c71b9c7fec34c2f588fe99cc92749291c Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:09:00 +0545 Subject: [PATCH 37/77] Reload after an insert when infinite scroll is on --- src/lib/components/StudioShell.svelte | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index 32d45e78..a5140892 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -7527,12 +7527,16 @@ let rowSearch = $state('') // the table holds. // A sort that isn't the default can also place the row elsewhere. In each // of these the page is reloaded instead: one query, always correct. + // - Infinite scroll: the grid's rows are rebuilt from `_infiniteRows` on + // the next load-more, so a row spliced into `rows` alone vanished there, + // and adding it to both would shift the next page's offset by one and + // skip a row. const pkIdx = (primaryKey ?? []).map((k) => columns.findIndex((c) => c.name === k)) const rowComplete = Array.isArray(row) && row.length === columns.length && pkIdx.every((i) => i >= 0 && row[i] !== null && row[i] !== undefined) - const canSplice = !windowed && rowComplete && !rowSort + const canSplice = !windowed && !infiniteScroll && rowComplete && !rowSort if (!hasActiveFilters && page === 1 && canSplice) { rows = [row, ...rows] From 2460dfb5c1b69831c630d2fd3acfd6275368a762 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:09:00 +0545 Subject: [PATCH 38/77] Add a changeset for the infinite scroll fix --- .changeset/infinite-scroll-insert.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .changeset/infinite-scroll-insert.md diff --git a/.changeset/infinite-scroll-insert.md b/.changeset/infinite-scroll-insert.md new file mode 100644 index 00000000..3e628439 --- /dev/null +++ b/.changeset/infinite-scroll-insert.md @@ -0,0 +1,2 @@ +### Bug Fixes +- With infinite scroll on, a row added from the grid no longer disappears when more rows load, and no row is skipped after it: the page reloads instead From 0d24e4ad01729d58ad8c3eda61e7b03a28f58fd0 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:09:00 +0545 Subject: [PATCH 39/77] Add settings for the editor theme, console undo and background updates --- src/lib/stores/settings.js | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/lib/stores/settings.js b/src/lib/stores/settings.js index 728824ed..278591eb 100644 --- a/src/lib/stores/settings.js +++ b/src/lib/stores/settings.js @@ -20,6 +20,7 @@ import { } from '$lib/type-scale.js' import { SQL_FORMAT_DEFAULTS, normalizeSqlFormat, setSqlFormatOptions } from '$lib/sql-format-options.js' import { SQL_EDITOR_DEFAULTS, normalizeSqlEditor } from '$lib/sql-editor-options.js' +import { DEFAULT_EDITOR_THEME, normalizeEditorTheme } from '$lib/themes/editor-themes.js' const STORAGE_KEY = 'stroke:settings' @@ -90,7 +91,7 @@ const markFontGeistDefaultApplied = () => { /** @typedef {'claude' | '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, jsonTheme: JsonThemeId, 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, lazyWideColumns: boolean, nullSortOrder: string, agentChatFontSize: number, agentCodeFontSize: number, agentThinkingStyle: string, agentShowQueryCards: boolean, agentWebAccess: boolean, tableTextAlign: string, telemetry: boolean, jsonWordWrap: boolean, nativeScroll: boolean, rowSpacing: RowSpacingId, motion: MotionId, zebraRows: boolean, showRowNumbers: boolean, showMenuBar: boolean, numberGrouping: boolean, imagePreview: boolean, openUrlsOnClick: boolean, highlightActiveRow: boolean, fkAutoExpandJson: boolean, gridFontSize: number, autoSaveQueries: boolean, streamResults: boolean, sidebarComments: boolean, sidebarRememberGroups: boolean, sqlFormat: import('$lib/sql-format-options.js').SqlFormatOptions, sqlEditor: import('$lib/sql-editor-options.js').SqlEditorOptions }} AppSettings */ +/** @typedef {{ theme: ThemeId, zoom: number, font: FontId, iconStyle: IconStyleId, iconSet: IconSetId, tableStyle: TableStyleId, jsonTheme: JsonThemeId, editorTheme: import('$lib/themes/editor-themes.js').EditorThemeId, mcpAutoStart: boolean, launchAtLogin: boolean, autoUpdate: 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, lazyWideColumns: boolean, nullSortOrder: string, agentChatFontSize: number, agentCodeFontSize: number, agentThinkingStyle: string, agentShowQueryCards: boolean, agentWebAccess: boolean, tableTextAlign: string, telemetry: boolean, jsonWordWrap: boolean, nativeScroll: boolean, rowSpacing: RowSpacingId, motion: MotionId, zebraRows: boolean, showRowNumbers: boolean, showMenuBar: boolean, numberGrouping: boolean, imagePreview: boolean, openUrlsOnClick: boolean, highlightActiveRow: boolean, fkAutoExpandJson: boolean, gridFontSize: number, autoSaveQueries: boolean, streamResults: boolean, sqlUndo: boolean, sidebarComments: boolean, sidebarRememberGroups: boolean, sqlFormat: import('$lib/sql-format-options.js').SqlFormatOptions, sqlEditor: import('$lib/sql-editor-options.js').SqlEditorOptions }} AppSettings */ /** * UI type scale in design pixels: `[step, font-size, line-height?]`, matching @@ -508,8 +509,13 @@ export const DEFAULT_SETTINGS = { iconSet: DEFAULT_ICON_SET, tableStyle: DEFAULT_TABLE_STYLE, jsonTheme: DEFAULT_JSON_THEME, + // Code editor colours: 'app' follows the app theme, as the editor always has. + editorTheme: DEFAULT_EDITOR_THEME, mcpAutoStart: false, launchAtLogin: false, + // Updates download while the app runs and install as it quits, where the + // install can finish on its own (see UpdateDialog.svelte). Off asks first. + autoUpdate: true, autoReconnectOnStartup: true, previewDmlBeforeApply: true, defaultDataView: DEFAULT_DATA_VIEW, @@ -555,6 +561,10 @@ export const DEFAULT_SETTINGS = { // and scrolling flat. Off: every row loads into the window (simpler, and // fine for results that fit comfortably). streamResults: true, + // A single UPDATE / DELETE / INSERT run from the console keeps an in-memory + // copy of what it changed, so the statement's lens can offer Revert + // (sql-undo.js). Costs one catalog read and one SELECT of the touched rows. + sqlUndo: true, // Table, view and routine comments as a second line in the sidebar. Off by // default: most schemas carry few, and the extra query is per schema. sidebarComments: false, @@ -660,6 +670,9 @@ export const appAutoSaveQueries = writable(false) /** Reactive: console results stream into the backend's result store (see DEFAULT_SETTINGS). */ export const appStreamResults = writable(true) +/** Reactive: console writes keep an undo copy (see DEFAULT_SETTINGS). */ +export const appSqlUndo = writable(true) + /** Reactive: comments as a second line under sidebar rows. */ export const appSidebarComments = writable(false) /** Reactive: the Objects tab remembers which groups were open, per connection. */ @@ -689,6 +702,8 @@ export const appPaginationMode = writable(/** @type {string} */ (DEFAULT_PAGINAT * subscribes to repaint when it changes. */ export const appTableStyle = writable(/** @type {TableStyleId} */ (DEFAULT_TABLE_STYLE)) export const appJsonTheme = writable(/** @type {JsonThemeId} */ (DEFAULT_JSON_THEME)) +/** Reactive: the code editor's colour preset (CodeEditor reconfigures on change). */ +export const appEditorTheme = writable(DEFAULT_EDITOR_THEME) const LAST_DARK_KEY = 'stroke:last-dark-theme' const LAST_LIGHT_KEY = 'stroke:last-light-theme' @@ -791,6 +806,7 @@ export function loadSettings() { } const mcpAutoStart = parsed.mcpAutoStart === true const launchAtLogin = parsed.launchAtLogin === true + const autoUpdate = parsed.autoUpdate !== false const autoReconnectOnStartup = parsed.autoReconnectOnStartup !== false const previewDmlBeforeApply = parsed.previewDmlBeforeApply !== false let font = normalizeFont(parsed.font) @@ -821,6 +837,7 @@ export function loadSettings() { const iconSet = normalizeIconSet(parsed.iconSet) const tableStyle = normalizeTableStyle(parsed.tableStyle) const jsonTheme = normalizeJsonTheme(parsed.jsonTheme) + const editorTheme = normalizeEditorTheme(parsed.editorTheme) const defaultDataView = DATA_VIEW_IDS.includes(parsed.defaultDataView) ? parsed.defaultDataView : DEFAULT_DATA_VIEW const paginationMode = PAGINATION_MODE_IDS.includes(parsed.paginationMode) ? parsed.paginationMode : DEFAULT_PAGINATION_MODE const maxQueryHistory = normalizeInt(parsed.maxQueryHistory, DEFAULT_MAX_QUERY_HISTORY, 1, 100000) @@ -865,6 +882,7 @@ export function loadSettings() { const gridFontSize = normalizeGridFontSize(parsed.gridFontSize) const autoSaveQueries = parsed.autoSaveQueries === true const streamResults = parsed.streamResults !== false + const sqlUndo = parsed.sqlUndo !== false const sidebarComments = parsed.sidebarComments === true const sidebarRememberGroups = parsed.sidebarRememberGroups !== false const liveModeEnabled = parsed.liveModeEnabled === true @@ -876,7 +894,7 @@ export function loadSettings() { 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, jsonTheme, mcpAutoStart, launchAtLogin, autoReconnectOnStartup, previewDmlBeforeApply, defaultDataView, paginationMode, maxQueryHistory, connectTimeoutMs, socketTimeoutMs, maxAllowedPacket, sessionTimezone, vimMode, cmdkAiEnabled, liveModeEnabled, lazyWideColumns, nullSortOrder, agentChatFontSize, agentCodeFontSize, agentThinkingStyle, agentShowQueryCards, agentWebAccess, tableTextAlign, telemetry, jsonWordWrap, nativeScroll, rowSpacing, motion, zebraRows, showRowNumbers, showMenuBar, numberGrouping, imagePreview, openUrlsOnClick, highlightActiveRow, fkAutoExpandJson, gridFontSize, autoSaveQueries, streamResults, sidebarComments, sidebarRememberGroups, sqlFormat, sqlEditor } + _settingsCache = { theme, zoom, font, iconStyle, iconSet, tableStyle, jsonTheme, editorTheme, mcpAutoStart, launchAtLogin, autoUpdate, autoReconnectOnStartup, previewDmlBeforeApply, defaultDataView, paginationMode, maxQueryHistory, connectTimeoutMs, socketTimeoutMs, maxAllowedPacket, sessionTimezone, vimMode, cmdkAiEnabled, liveModeEnabled, lazyWideColumns, nullSortOrder, agentChatFontSize, agentCodeFontSize, agentThinkingStyle, agentShowQueryCards, agentWebAccess, tableTextAlign, telemetry, jsonWordWrap, nativeScroll, rowSpacing, motion, zebraRows, showRowNumbers, showMenuBar, numberGrouping, imagePreview, openUrlsOnClick, highlightActiveRow, fkAutoExpandJson, gridFontSize, autoSaveQueries, streamResults, sqlUndo, sidebarComments, sidebarRememberGroups, sqlFormat, sqlEditor } if (fontMigrated) { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(_settingsCache)) } catch {} } @@ -1055,6 +1073,7 @@ export function applySettings(settings) { setStore(appGridFontSize, normalizeGridFontSize(settings.gridFontSize)) setStore(appAutoSaveQueries, settings.autoSaveQueries === true) setStore(appStreamResults, settings.streamResults !== false) + setStore(appSqlUndo, settings.sqlUndo !== false) setStore(appSidebarComments, settings.sidebarComments === true) setStore(appSidebarRememberGroups, settings.sidebarRememberGroups !== false) setStore(appLiveMode, settings.liveModeEnabled === true) @@ -1075,6 +1094,7 @@ export function applySettings(settings) { const jsonTheme = normalizeJsonTheme(settings.jsonTheme) setAttr(root, 'data-json-theme', jsonTheme) setStore(appJsonTheme, jsonTheme) + setStore(appEditorTheme, normalizeEditorTheme(settings.editorTheme)) // Keep the canvas-table zoom in lockstep with the app zoom so Cmd +/-/0 (and // the zoom buttons) scale the grid alongside the rest of the UI. The canvas From 620f006d09f7c2cedd1a8e1702cff776a1fec8a2 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:09:00 +0545 Subject: [PATCH 40/77] Add the code editor colour themes --- src/lib/themes/editor-themes.js | 252 ++++++++++++++++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 src/lib/themes/editor-themes.js diff --git a/src/lib/themes/editor-themes.js b/src/lib/themes/editor-themes.js new file mode 100644 index 00000000..650f8a6c --- /dev/null +++ b/src/lib/themes/editor-themes.js @@ -0,0 +1,252 @@ +/** + * Code editor colour themes: the presets, their labels and validation. Pure + * data, so the settings store can hold and check the choice without importing + * CodeMirror; `cm-editor-theme.js` turns a palette into editor extensions. + * + * 'app' is the editor as it has always looked: transparent over its surface, + * coloured from the app theme's tokens (and the JSON colours setting). Every + * other entry is a fixed palette taken from its canonical source, so a light + * editor can sit in a dark app and the other way round. + */ + +/** + * @typedef {'app' | 'one-dark' | 'github-light' | 'github-dark' | 'dracula' | 'monokai' + * | 'nord' | 'solarized-light' | 'solarized-dark' | 'tokyo-night' | 'catppuccin-mocha' + * | 'catppuccin-latte' | 'rose-pine' | 'gruvbox-dark'} EditorThemeId + */ + +/** + * @typedef {object} EditorPalette + * @property {string} bg editor background + * @property {string} fg default text + * @property {string} gutter line numbers + * @property {string} gutterActive the caret line's number + * @property {string} line current line band + * @property {string} selection drawn selection (focused; unfocused is a lighter mix) + * @property {string} cursor caret + * @property {string} panel tooltips, completion list, find panel fields + * @property {string} border tooltip and gutter edges + * @property {string} muted secondary text: completion details, placeholders, fold markers + * @property {string} keyword + * @property {string} string + * @property {string} number + * @property {string} comment + * @property {string} fn function calls and SQL builtins + * @property {string} type type and class names + * @property {string} operator + * @property {string} variable plain names (SQL identifiers, JS variables) + * @property {string} property object keys, JSON keys, attributes + * @property {string} punctuation + * @property {string} constant true, false, null, atoms + * @property {string} tag markup tag names + */ + +/** @typedef {{ label: string, dark: boolean, palette: EditorPalette | null }} EditorThemeDef */ + +/** @type {Record} */ +export const EDITOR_THEMES = { + app: { label: 'Match app theme', dark: false, palette: null }, + // @codemirror/theme-one-dark's values. Plain names stay ivory rather than + // its coral, which turned every SQL column name red. + 'one-dark': { + label: 'One Dark', + dark: true, + palette: { + bg: '#282c34', fg: '#abb2bf', gutter: '#7d8799', gutterActive: '#abb2bf', + line: '#2c313a', selection: '#3e4451', cursor: '#528bff', + panel: '#21252b', border: '#181a1f', muted: '#9da5b4', + keyword: '#c678dd', string: '#98c379', number: '#d19a66', comment: '#7d8799', + fn: '#61afef', type: '#e5c07b', operator: '#56b6c2', variable: '#abb2bf', + property: '#e06c75', punctuation: '#abb2bf', constant: '#d19a66', tag: '#e06c75', + }, + }, + // Primer's light default (github-vscode-theme). + 'github-light': { + label: 'GitHub Light', + dark: false, + palette: { + bg: '#ffffff', fg: '#1f2328', gutter: '#8c959f', gutterActive: '#1f2328', + line: '#f6f8fa', selection: '#0969da33', cursor: '#0969da', + panel: '#ffffff', border: '#d0d7de', muted: '#59636e', + keyword: '#cf222e', string: '#0a3069', number: '#0550ae', comment: '#6e7781', + fn: '#8250df', type: '#953800', operator: '#cf222e', variable: '#1f2328', + property: '#0550ae', punctuation: '#1f2328', constant: '#0550ae', tag: '#116329', + }, + }, + // Primer's dark default. + 'github-dark': { + label: 'GitHub Dark', + dark: true, + palette: { + bg: '#0d1117', fg: '#e6edf3', gutter: '#6e7681', gutterActive: '#e6edf3', + line: '#6e76811a', selection: '#264f78', cursor: '#2f81f7', + panel: '#161b22', border: '#30363d', muted: '#8d96a0', + keyword: '#ff7b72', string: '#a5d6ff', number: '#79c0ff', comment: '#8b949e', + fn: '#d2a8ff', type: '#ffa657', operator: '#ff7b72', variable: '#e6edf3', + property: '#79c0ff', punctuation: '#e6edf3', constant: '#79c0ff', tag: '#7ee787', + }, + }, + // draculatheme.com/spec. + dracula: { + label: 'Dracula', + dark: true, + palette: { + bg: '#282a36', fg: '#f8f8f2', gutter: '#6272a4', gutterActive: '#f8f8f2', + line: '#44475a59', selection: '#44475a', cursor: '#f8f8f2', + panel: '#21222c', border: '#191a21', muted: '#a4aecf', + keyword: '#ff79c6', string: '#f1fa8c', number: '#bd93f9', comment: '#6272a4', + fn: '#50fa7b', type: '#8be9fd', operator: '#ff79c6', variable: '#f8f8f2', + property: '#8be9fd', punctuation: '#f8f8f2', constant: '#bd93f9', tag: '#ff79c6', + }, + }, + // Sublime Text's classic Monokai. + monokai: { + label: 'Monokai', + dark: true, + palette: { + bg: '#272822', fg: '#f8f8f2', gutter: '#90908a', gutterActive: '#c2c2bf', + line: '#3e3d32', selection: '#49483e', cursor: '#f8f8f0', + panel: '#1e1f1c', border: '#414339', muted: '#b4b09c', + keyword: '#f92672', string: '#e6db74', number: '#ae81ff', comment: '#75715e', + fn: '#a6e22e', type: '#66d9ef', operator: '#f92672', variable: '#f8f8f2', + property: '#a6e22e', punctuation: '#f8f8f2', constant: '#ae81ff', tag: '#f92672', + }, + }, + // nordtheme.com, as its VS Code port colours code. + nord: { + label: 'Nord', + dark: true, + palette: { + bg: '#2e3440', fg: '#d8dee9', gutter: '#4c566a', gutterActive: '#d8dee9', + line: '#3b4252', selection: '#434c5ecc', cursor: '#d8dee9', + panel: '#3b4252', border: '#434c5e', muted: '#aeb6c4', + keyword: '#81a1c1', string: '#a3be8c', number: '#b48ead', comment: '#616e88', + fn: '#88c0d0', type: '#8fbcbb', operator: '#81a1c1', variable: '#d8dee9', + property: '#8fbcbb', punctuation: '#eceff4', constant: '#81a1c1', tag: '#81a1c1', + }, + }, + // ethanschoonover.com/solarized: base3 ground, base00 body, base1 comments. + // Tooltips sit on the ground, not base2: body text on base2 is 3.6:1. + 'solarized-light': { + label: 'Solarized Light', + dark: false, + palette: { + bg: '#fdf6e3', fg: '#657b83', gutter: '#93a1a1', gutterActive: '#586e75', + line: '#eee8d5', selection: '#93a1a14d', cursor: '#657b83', + panel: '#fdf6e3', border: '#ddd6c1', muted: '#586e75', + keyword: '#859900', string: '#2aa198', number: '#d33682', comment: '#93a1a1', + fn: '#268bd2', type: '#b58900', operator: '#859900', variable: '#657b83', + property: '#268bd2', punctuation: '#657b83', constant: '#b58900', tag: '#268bd2', + }, + }, + // Solarized dark: base03 ground, base0 body, base01 comments. Tooltips on + // the ground for the same reason as the light variant. + 'solarized-dark': { + label: 'Solarized Dark', + dark: true, + palette: { + bg: '#002b36', fg: '#839496', gutter: '#586e75', gutterActive: '#93a1a1', + line: '#073642', selection: '#274642', cursor: '#93a1a1', + panel: '#002b36', border: '#0f4d5c', muted: '#93a1a1', + keyword: '#859900', string: '#2aa198', number: '#d33682', comment: '#586e75', + fn: '#268bd2', type: '#b58900', operator: '#859900', variable: '#839496', + property: '#268bd2', punctuation: '#839496', constant: '#b58900', tag: '#268bd2', + }, + }, + // folke/tokyonight, the Night style. + 'tokyo-night': { + label: 'Tokyo Night', + dark: true, + palette: { + bg: '#1a1b26', fg: '#a9b1d6', gutter: '#3b4261', gutterActive: '#737aa2', + line: '#292e42', selection: '#283457', cursor: '#c0caf5', + panel: '#16161e', border: '#292e42', muted: '#9aa5ce', + keyword: '#bb9af7', string: '#9ece6a', number: '#ff9e64', comment: '#565f89', + fn: '#7aa2f7', type: '#2ac3de', operator: '#89ddff', variable: '#c0caf5', + property: '#73daca', punctuation: '#a9b1d6', constant: '#ff9e64', tag: '#f7768e', + }, + }, + // catppuccin.com/palette, Mocha, following its style guide. + 'catppuccin-mocha': { + label: 'Catppuccin Mocha', + dark: true, + palette: { + bg: '#1e1e2e', fg: '#cdd6f4', gutter: '#7f849c', gutterActive: '#b4befe', + line: '#2a2b3c', selection: '#9399b240', cursor: '#f5e0dc', + panel: '#181825', border: '#313244', muted: '#a6adc8', + keyword: '#cba6f7', string: '#a6e3a1', number: '#fab387', comment: '#9399b2', + fn: '#89b4fa', type: '#f9e2af', operator: '#89dceb', variable: '#cdd6f4', + property: '#b4befe', punctuation: '#9399b2', constant: '#fab387', tag: '#89b4fa', + }, + }, + // Catppuccin Latte. + 'catppuccin-latte': { + label: 'Catppuccin Latte', + dark: false, + palette: { + bg: '#eff1f5', fg: '#4c4f69', gutter: '#8c8fa1', gutterActive: '#7287fd', + line: '#e6e9ef', selection: '#7c7f9340', cursor: '#dc8a78', + panel: '#e6e9ef', border: '#ccd0da', muted: '#5c5f77', + keyword: '#8839ef', string: '#40a02b', number: '#fe640b', comment: '#7c7f93', + fn: '#1e66f5', type: '#df8e1d', operator: '#04a5e5', variable: '#4c4f69', + property: '#7287fd', punctuation: '#7c7f93', constant: '#fe640b', tag: '#1e66f5', + }, + }, + // rosepinetheme.com, main variant. + 'rose-pine': { + label: 'Rosé Pine', + dark: true, + palette: { + bg: '#191724', fg: '#e0def4', gutter: '#6e6a86', gutterActive: '#e0def4', + line: '#21202e', selection: '#403d52', cursor: '#e0def4', + panel: '#1f1d2e', border: '#26233a', muted: '#908caa', + keyword: '#31748f', string: '#f6c177', number: '#f6c177', comment: '#6e6a86', + fn: '#ebbcba', type: '#9ccfd8', operator: '#908caa', variable: '#e0def4', + property: '#c4a7e7', punctuation: '#908caa', constant: '#ebbcba', tag: '#9ccfd8', + }, + }, + // morhetz/gruvbox, dark medium contrast. + 'gruvbox-dark': { + label: 'Gruvbox Dark', + dark: true, + palette: { + bg: '#282828', fg: '#ebdbb2', gutter: '#7c6f64', gutterActive: '#fabd2f', + line: '#3c3836', selection: '#504945', cursor: '#ebdbb2', + panel: '#32302f', border: '#504945', muted: '#a89984', + keyword: '#fb4934', string: '#b8bb26', number: '#d3869b', comment: '#928374', + fn: '#b8bb26', type: '#fabd2f', operator: '#ebdbb2', variable: '#ebdbb2', + property: '#83a598', punctuation: '#a89984', constant: '#d3869b', tag: '#8ec07c', + }, + }, +} + +/** @type {EditorThemeId} */ +export const DEFAULT_EDITOR_THEME = 'app' +export const EDITOR_THEME_IDS = /** @type {EditorThemeId[]} */ (Object.keys(EDITOR_THEMES)) + +/** @returns {EditorThemeId} */ +export function normalizeEditorTheme(/** @type {unknown} */ id) { + return typeof id === 'string' && Object.hasOwn(EDITOR_THEMES, id) ? /** @type {EditorThemeId} */ (id) : DEFAULT_EDITOR_THEME +} + +/** + * Colours for a settings preview card. 'app' answers with the app's own + * tokens, the same ones the editor reads in that mode. + * @param {EditorThemeId} id + * @returns {Pick} + */ +export function editorPreviewColors(id) { + const p = EDITOR_THEMES[id]?.palette + if (p) return p + return { + bg: 'var(--background)', + fg: 'var(--foreground)', + gutter: 'color-mix(in oklch, var(--muted-foreground) 50%, transparent)', + keyword: 'var(--json-boolean)', + string: 'var(--json-string)', + comment: 'var(--json-null)', + operator: 'var(--muted-foreground)', + punctuation: 'var(--muted-foreground)', + variable: 'var(--foreground)', + } +} From 33a352763378079697c48d03e3afa09eaa839e2d Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:09:00 +0545 Subject: [PATCH 41/77] Turn an editor theme into CodeMirror extensions --- src/lib/cm-editor-theme.js | 109 +++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 src/lib/cm-editor-theme.js diff --git a/src/lib/cm-editor-theme.js b/src/lib/cm-editor-theme.js new file mode 100644 index 00000000..338bd621 --- /dev/null +++ b/src/lib/cm-editor-theme.js @@ -0,0 +1,109 @@ +/** + * Editor colour presets as CodeMirror extensions: a theme for the chrome plus + * a highlight style for the code. The palettes live in themes/editor-themes.js. + * + * The editor's own theme (CodeEditor.svelte) and the themes surfaces add to it + * are written against the app's tokens: --foreground, --muted-foreground, + * --border, --popover, the --json-* colours. A preset redefines those tokens + * on the editor root, so every one of those rules (find panel, completion + * list, fold markers, lint and quick-fix tooltips, statement bands) takes the + * preset's colours without a rule of its own. Tooltips render outside the + * editor but carry its theme classes, so they pick the tokens up too. + * --destructive, --warning, --success and --primary stay the app's: errors, + * search hits and actions read the same in every preset. + */ +import { EditorView } from '@codemirror/view' +import { HighlightStyle, syntaxHighlighting } from '@codemirror/language' +import { tags as t } from '@lezer/highlight' +import { EDITOR_THEMES } from '$lib/themes/editor-themes.js' + +/** Built once per preset, so every open editor shares one set of style rules. */ +const built = new Map() + +/** + * Theme and highlighting for a preset, or null for 'app' (the editor keeps + * its own token-based colours). + * @param {import('$lib/themes/editor-themes.js').EditorThemeId} id + * @returns {import('@codemirror/state').Extension | null} + */ +export function editorThemeExtension(id) { + const def = EDITOR_THEMES[id] + if (!def?.palette) return null + let ext = built.get(id) + if (!ext) { + ext = [chrome(def.palette, def.dark), syntaxHighlighting(highlightStyle(def.palette))] + built.set(id, ext) + } + return ext +} + +/** + * @param {import('$lib/themes/editor-themes.js').EditorPalette} p + * @param {boolean} dark + */ +function chrome(p, dark) { + return EditorView.theme( + { + '&': { + '--background': p.bg, + '--foreground': p.fg, + '--muted': p.line, + '--muted-foreground': p.muted, + '--border': p.border, + '--popover': p.panel, + '--popover-foreground': p.fg, + // Derived tokens resolve where they are declared (the app root), so + // the find field's hairline is derived again from the preset here. + '--field-border': 'color-mix(in oklch, var(--foreground) 30%, var(--background))', + // The completion list's kind dots and any JSON-coloured mark. + '--json-key': p.property, + '--json-string': p.string, + '--json-number': p.number, + '--json-boolean': p.keyword, + '--json-null': p.comment, + }, + // The editor itself only; '&' alone also matches the tooltip host. + '&.cm-editor': { backgroundColor: p.bg, color: p.fg }, + '.cm-content': { caretColor: p.cursor }, + '.cm-cursor, .cm-dropCursor': { borderLeftColor: p.cursor }, + '.cm-selectionBackground': { + backgroundColor: `color-mix(in srgb, ${p.selection} 65%, transparent) !important`, + }, + '&.cm-focused .cm-selectionBackground': { backgroundColor: `${p.selection} !important` }, + '.cm-activeLine': { backgroundColor: p.line }, + '.cm-gutters': { color: p.gutter }, + '.cm-activeLineGutter': { color: p.gutterActive }, + }, + { dark }, + ) +} + +/** + * Token colours. Tags are the ones the languages in use emit: lang-sql gives + * keyword, typeName, standard(name) for builtins, name for identifiers, + * special(string) for quoted identifiers, bool, null, number, string, + * operator, punctuation and the comment kinds; JSON, JavaScript and HTML add + * propertyName, variableName, function(...), definition(...), className, + * tagName, attributeName and attributeValue. + * @param {import('$lib/themes/editor-themes.js').EditorPalette} p + */ +function highlightStyle(p) { + return HighlightStyle.define([ + { tag: t.keyword, color: p.keyword }, + { tag: [t.string, t.attributeValue, t.regexp], color: p.string }, + // A quoted identifier is a name, not a string ("name" = 'ad'). + { tag: t.special(t.string), color: p.variable }, + { tag: t.number, color: p.number }, + { tag: [t.bool, t.null, t.atom, t.escape, t.special(t.name), t.constant(t.name), t.constant(t.variableName)], color: p.constant }, + { tag: t.comment, color: p.comment, fontStyle: 'italic' }, + { tag: [t.standard(t.name), t.function(t.variableName), t.function(t.propertyName), t.macroName], color: p.fn }, + { tag: [t.typeName, t.className, t.namespace, t.standard(t.typeName)], color: p.type }, + { tag: t.operator, color: p.operator }, + { tag: [t.name, t.variableName, t.labelName, t.definition(t.variableName)], color: p.variable }, + { tag: [t.propertyName, t.attributeName, t.definition(t.propertyName)], color: p.property }, + { tag: [t.punctuation, t.bracket, t.angleBracket, t.separator], color: p.punctuation }, + { tag: t.tagName, color: p.tag }, + { tag: [t.meta, t.annotation], color: p.fn }, + { tag: t.invalid, textDecoration: 'underline wavy' }, + ]) +} From 8039b1bda214af6cb6831a28fed16609aea2cd6c Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:09:00 +0545 Subject: [PATCH 42/77] Apply the editor theme in the code editor --- src/lib/components/CodeEditor.svelte | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/lib/components/CodeEditor.svelte b/src/lib/components/CodeEditor.svelte index 0ca1b808..134f1bdf 100644 --- a/src/lib/components/CodeEditor.svelte +++ b/src/lib/components/CodeEditor.svelte @@ -51,6 +51,9 @@ import { ArrowDown01Icon, ArrowRight01Icon } from '@hugeicons/core-free-icons' import { hugeSvg } from '$lib/cm-huge-icon.js' import { tags as t } from '@lezer/highlight' + import { get } from 'svelte/store' + import { appEditorTheme } from '$lib/stores/settings.js' + import { editorThemeExtension } from '$lib/cm-editor-theme.js' let { value = $bindable(''), @@ -86,6 +89,8 @@ const extraC = new Compartment() const measureC = new Compartment() const completeC = new Compartment() + /** Colours: the app-token highlight below, or a preset's theme and highlight. */ + const colorC = new Compartment() /** Two empty themes to flip between: see `remeasure`. */ const MEASURE_FLIP = [EditorView.theme({}), EditorView.theme({})] let measureFlip = 0 @@ -151,6 +156,14 @@ { tag: [t.regexp, t.escape], color: 'var(--json-string)' }, ]) + /** + * The editor theme setting's extensions. Mounted ahead of `theme` so a + * preset's chrome wins over it at the same precedence (CodeMirror gives the + * earlier of two equal themes priority). + */ + const colorsFor = (/** @type {import('$lib/themes/editor-themes.js').EditorThemeId} */ id) => + editorThemeExtension(id) ?? syntaxHighlighting(highlight) + /* * One type system for the whole surface: the gutter uses the code's font, * size and line-height, so number N sits on text row N's baseline. Only the @@ -815,7 +828,7 @@ ...foldKeymap, ...defaultKeymap, ]), - syntaxHighlighting(highlight), + colorC.of(colorsFor(get(appEditorTheme))), theme, sqlEditing(), placeholderExt(placeholder), @@ -911,6 +924,7 @@ }) $effect(() => { const x = extensions; view?.dispatch({ effects: extraC.reconfigure(x) }) }) + $effect(() => { const id = $appEditorTheme; view?.dispatch({ effects: colorC.reconfigure(colorsFor(id)) }) }) $effect(() => { const t = suggestWhileTyping const e = acceptOnEnter From 9dac6a7f87ecabb8a7d3ce41c91eec616db82e47 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:09:00 +0545 Subject: [PATCH 43/77] Add an editor theme picker --- src/lib/components/EditorThemePicker.svelte | 94 +++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 src/lib/components/EditorThemePicker.svelte diff --git a/src/lib/components/EditorThemePicker.svelte b/src/lib/components/EditorThemePicker.svelte new file mode 100644 index 00000000..b78fd969 --- /dev/null +++ b/src/lib/components/EditorThemePicker.svelte @@ -0,0 +1,94 @@ + + +
+ {#each EDITOR_THEME_IDS as id (id)} + {@const c = editorPreviewColors(id)} + {@const selected = value === id} + + {/each} +
From 64ec0810312be26b88f767e0d989cb3ef38371dd Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:09:00 +0545 Subject: [PATCH 44/77] Choose the editor theme in Settings --- src/lib/components/SettingsDialog.svelte | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/lib/components/SettingsDialog.svelte b/src/lib/components/SettingsDialog.svelte index 8f985041..3a227d69 100644 --- a/src/lib/components/SettingsDialog.svelte +++ b/src/lib/components/SettingsDialog.svelte @@ -10,6 +10,7 @@ import ThemeSwatch from "$lib/components/ThemeSwatch.svelte"; import SearchableMenu from "$lib/components/SearchableMenu.svelte"; import SelectMenu from "$lib/components/SelectMenu.svelte"; + import EditorThemePicker from "$lib/components/EditorThemePicker.svelte"; import { getThemeDefinition, themesByGroup } from "$lib/themes/registry.js"; import { sidebarSideStore, setSidebarSide } from "$lib/stores/layout.js"; import { pluginState, isPluginEnabled, setPluginEnabled } from "$lib/stores/plugins.js"; @@ -181,6 +182,11 @@ if (!id || id === settings.jsonTheme) return; settings = updateSettings({ jsonTheme: /** @type {any} */ (id) }); } + /** @param {import('$lib/themes/editor-themes.js').EditorThemeId} id */ + function setEditorTheme(id) { + if (id === settings.editorTheme) return; + settings = updateSettings({ editorTheme: id }); + } // Theme-aware CSS previews (mirror how each preset renders on the canvas grid). const tableStylePreview = { lines: "background-image:linear-gradient(var(--border) 1px,transparent 1px),linear-gradient(90deg,var(--border) 1px,transparent 1px);background-size:7px 7px;", @@ -1232,6 +1238,18 @@
{/if} + {#if show('Editor theme', 'Colours for the SQL editor and every code view: One Dark, GitHub, Dracula, Monokai, Nord, Solarized, Tokyo Night, Catppuccin, Rose Pine, Gruvbox')} + +
+

Editor theme

+

+ Colours for the SQL editor and every other code view. Match app theme uses the app theme and the JSON colours above. +

+
+ +
+
+ {/if} {#if show('Row spacing', 'Vertical space each row of the data grid takes')}
From 0f94e770860b6477beb021fcd98914b3a003979a Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:09:00 +0545 Subject: [PATCH 45/77] Explain SQL errors in plain words --- src/lib/sql-error-explain.js | 168 +++++++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 src/lib/sql-error-explain.js diff --git a/src/lib/sql-error-explain.js b/src/lib/sql-error-explain.js new file mode 100644 index 00000000..60a2f5ee --- /dev/null +++ b/src/lib/sql-error-explain.js @@ -0,0 +1,168 @@ +/** + * Plain words for a failed statement, and what was probably meant when it names + * something that is not there. "relation "x" does not exist" is Postgres talking + * to itself; the console says "No table named x" and offers the closest table. + */ +import { quoteIdent } from './dml-preview.js' + +/** + * @typedef {'table' | 'column' | 'schema' | 'function' | 'syntax' | 'other'} ErrorKind + * @typedef {{ + * kind: ErrorKind, + * title: string, + * name: string, + * detail: string, + * }} ErrorExplanation + * `title` reads before `name` ("No table named" + `orders`); `name` is '' when + * the error does not name anything. `detail` is the database's own wording, + * shown small under the title so it can still be searched for; for a message + * the rules do not know, the title is its first line and `detail` the rest. + */ + +/** Unquoted identifier, `"quoted"`, `'quoted'` or `` `quoted` ``. */ +const IDENT = String.raw`"((?:[^"]|"")+)"|'([^']+)'|` + '`([^`]+)`' + String.raw`|([\w$.]+)` + +/** + * One rule per message shape, across the engines the console runs against. + * Unanchored on purpose: drivers prefix codes (`1146 (42S02): `, `(code: 1) `). + * `bare` rules name nothing, so the title stands alone. + * @type {{ re: RegExp, kind: ErrorKind, title: string, bare?: boolean }[]} + */ +const RULES = [ + // Postgres + { re: new RegExp(String.raw`column (?:${IDENT}) of relation (?:${IDENT}) does not exist`, 'i'), kind: 'column', title: 'No column named' }, + { re: new RegExp(String.raw`relation (?:${IDENT}) does not exist`, 'i'), kind: 'table', title: 'No table named' }, + { re: new RegExp(String.raw`column (?:${IDENT}) does not exist`, 'i'), kind: 'column', title: 'No column named' }, + { re: new RegExp(String.raw`schema (?:${IDENT}) does not exist`, 'i'), kind: 'schema', title: 'No schema named' }, + { re: /function ([\w$."]+)\(.*?\) does not exist/i, kind: 'function', title: 'No function matches' }, + { re: new RegExp(String.raw`syntax error at or near (?:${IDENT})`, 'i'), kind: 'syntax', title: 'Syntax error near' }, + { re: /syntax error at end of input/i, kind: 'syntax', title: 'The statement ends too early', bare: true }, + // MySQL / MariaDB + { re: /Table '(?:[^'.]+\.)?([^']+)' doesn't exist/i, kind: 'table', title: 'No table named' }, + { re: /Unknown column '([^']+)'/i, kind: 'column', title: 'No column named' }, + { re: /error in your SQL syntax;.*?near '((?:[^'\n]|'')*?)'/is, kind: 'syntax', title: 'Syntax error near' }, + // SQLite / D1 / libSQL + { re: /no such table: ([\w$."]+)/i, kind: 'table', title: 'No table named' }, + { re: /no such column: ([\w$."]+)/i, kind: 'column', title: 'No column named' }, + { re: /near "([^"]*)": syntax error/i, kind: 'syntax', title: 'Syntax error near' }, + // SQL Server + { re: /Invalid object name '([^']+)'/i, kind: 'table', title: 'No table named' }, + { re: /Invalid column name '([^']+)'/i, kind: 'column', title: 'No column named' }, + { re: /Incorrect syntax near '((?:[^']|'')*)'/i, kind: 'syntax', title: 'Syntax error near' }, + // DuckDB + { re: /Table with name ([\w$."]+) does not exist/i, kind: 'table', title: 'No table named' }, + { re: /Referenced column "([^"]+)" not found/i, kind: 'column', title: 'No column named' }, + // ClickHouse + { re: /Table ([\w$.]+) does not exist/i, kind: 'table', title: 'No table named' }, + { re: /Missing columns: '([^']+)'/i, kind: 'column', title: 'No column named' }, +] + +/** The first captured group that matched (each IDENT alternative is its own group). */ +function firstGroup(/** @type {RegExpMatchArray} */ m) { + for (let i = 1; i < m.length; i++) if (m[i] != null) return m[i].replace(/""/g, '"') + return '' +} + +/** + * @param {string} message the database's message, already through cleanErrorMessage + * @returns {ErrorExplanation} + */ +export function explainSqlError(message) { + const text = String(message ?? '').trim() + for (const rule of RULES) { + const m = text.match(rule.re) + if (!m) continue + let name = rule.bare ? '' : firstGroup(m) + // MySQL quotes the rest of the statement after the error: keep the first token. + if (rule.kind === 'syntax') name = name.split(/\s+/)[0] ?? '' + // MySQL's "near ''" is its way of saying the statement stopped early. + const title = rule.kind === 'syntax' && !name ? 'The statement ends too early' : rule.title + return { kind: rule.kind, title, name, detail: text } + } + // Unknown shape: the message is the title, and there is nothing to repeat. + const firstLine = text.split('\n')[0] + return { kind: 'other', title: firstLine, name: '', detail: text.slice(firstLine.length).trim() } +} + +/** Edit distance, a swapped pair counting as one edit (`FORM` -> `FROM`). */ +function distance(/** @type {string} */ a, /** @type {string} */ b) { + if (a === b) return 0 + /** @type {number[][]} */ + const d = Array.from({ length: a.length + 1 }, (_, i) => [i]) + for (let j = 1; j <= b.length; j++) d[0][j] = j + for (let i = 1; i <= a.length; i++) { + for (let j = 1; j <= b.length; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1 + d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost) + if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1) + } + } + return d[a.length][b.length] +} + +/** The part after the last dot: `public.orders` -> `orders`, `u.email` -> `email`. */ +export function bareName(/** @type {string} */ name) { + const m = String(name ?? '').trim().match(/(?:"((?:[^"]|"")+)"|`([^`]+)`|'([^']+)'|([^."'`\s]+))$/) + if (!m) return '' + return m[1]?.replace(/""/g, '"') ?? m[2] ?? m[3] ?? m[4] ?? '' +} + +/** + * How to write `name` so the database finds it: as is when it is a plain + * lowercase identifier (any case on MySQL/SQLite), quoted otherwise. + * @param {string} name @param {string} dialect + */ +export function writeIdent(name, dialect) { + const plain = /^[A-Za-z_][\w$]*$/.test(name) + const caseFolds = dialect === 'postgres' || dialect === 'postgresql' || dialect === 'cockroachdb' + if (plain && (!caseFolds || name === name.toLowerCase())) return name + return quoteIdent(name, dialect === 'mysql' || dialect === 'mariadb' ? 'mysql' : 'postgres') +} + +/** + * Names close to `missing`, written the way the statement needs them. A name + * that differs only in case comes first: on Postgres that is the quoting trap + * (`Orders` only matches as `"Orders"`), and it is almost always the intent. + * @param {string} missing as the error reported it (may be qualified) + * @param {string[]} candidates names that exist + * @param {string} dialect + * @param {number} [limit] + * @returns {string[]} + */ +export function suggestNames(missing, candidates, dialect, limit = 3) { + const want = bareName(missing) + if (!want) return [] + const lower = want.toLowerCase() + /** @type {{ name: string, score: number }[]} */ + const scored = [] + const seen = new Set() + for (const c of candidates) { + const name = bareName(c) + if (!name || seen.has(name) || name === want) continue + seen.add(name) + const cl = name.toLowerCase() + if (cl === lower) { scored.push({ name, score: -1 }); continue } + const d = distance(lower, cl) + // Close enough to be a typo: a third of the name, at least one edit. + if (d <= Math.max(1, Math.floor(lower.length / 3)) && d < lower.length) scored.push({ name, score: d }) + // A prefix of something longer (`order` -> `orders_archive`) is worth a mention too. + else if (lower.length >= 4 && cl.startsWith(lower)) scored.push({ name, score: d + 1 }) + } + // On a tie, the name closest in length: a typo usually keeps it (`FORM` -> `FROM`, not `FOR`). + const gap = (/** @type {string} */ n) => Math.abs(n.length - want.length) + scored.sort((a, b) => a.score - b.score || gap(a.name) - gap(b.name) || a.name.localeCompare(b.name)) + return scored.slice(0, limit).map((s) => writeIdent(s.name, dialect)) +} + +/** + * Tables a statement reads or writes, by the names it was written with. + * Rough on purpose: it only narrows "did you mean" for columns. + * @param {string} sql + * @returns {string[]} + */ +export function tablesIn(sql) { + const out = new Set() + const re = /\b(?:from|join|update|into|table)\s+((?:"[^"]+"|`[^`]+`|[\w$]+)(?:\.(?:"[^"]+"|`[^`]+`|[\w$]+))?)/gi + for (const m of String(sql ?? '').matchAll(re)) out.add(bareName(m[1])) + return [...out] +} From 2779106df282a2c5e641ef3432b7df4cd551061b Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:09:00 +0545 Subject: [PATCH 46/77] Test the SQL error explanations --- src/lib/sql-error-explain.test.js | 95 +++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 src/lib/sql-error-explain.test.js diff --git a/src/lib/sql-error-explain.test.js b/src/lib/sql-error-explain.test.js new file mode 100644 index 00000000..6f9223bc --- /dev/null +++ b/src/lib/sql-error-explain.test.js @@ -0,0 +1,95 @@ +import { describe, it, expect } from 'vitest' +import { explainSqlError, suggestNames, bareName, writeIdent, tablesIn } from './sql-error-explain.js' + +describe('explainSqlError', () => { + it('says "table" for a Postgres relation', () => { + expect(explainSqlError('relation "alembic_versio" does not exist')).toMatchObject({ + kind: 'table', title: 'No table named', name: 'alembic_versio', + }) + }) + + it('keeps the qualifier Postgres reported', () => { + expect(explainSqlError('relation "public.orderz" does not exist').name).toBe('public.orderz') + }) + + it('reads Postgres columns, quoted, qualified and of a relation', () => { + expect(explainSqlError('column "emial" does not exist')).toMatchObject({ kind: 'column', name: 'emial' }) + expect(explainSqlError('column u.emial does not exist')).toMatchObject({ kind: 'column', name: 'u.emial' }) + expect(explainSqlError('column "emial" of relation "users" does not exist')).toMatchObject({ kind: 'column', name: 'emial' }) + }) + + it('reads MySQL, SQLite, SQL Server, DuckDB and ClickHouse wording', () => { + expect(explainSqlError("1146 (42S02): Table 'shop.orderz' doesn't exist")).toMatchObject({ kind: 'table', name: 'orderz' }) + expect(explainSqlError("1054 (42S22): Unknown column 'emial' in 'field list'")).toMatchObject({ kind: 'column', name: 'emial' }) + expect(explainSqlError('(code: 1) no such table: orderz')).toMatchObject({ kind: 'table', name: 'orderz' }) + expect(explainSqlError('no such column: emial')).toMatchObject({ kind: 'column', name: 'emial' }) + expect(explainSqlError("Invalid object name 'dbo.orderz'.")).toMatchObject({ kind: 'table', name: 'dbo.orderz' }) + expect(explainSqlError('Catalog Error: Table with name orderz does not exist!')).toMatchObject({ kind: 'table', name: 'orderz' }) + expect(explainSqlError('Code: 60. DB::Exception: Table default.orderz does not exist.')).toMatchObject({ kind: 'table', name: 'default.orderz' }) + }) + + it('names the token a syntax error is at, and the early end', () => { + expect(explainSqlError('syntax error at or near "FORM"')).toMatchObject({ kind: 'syntax', title: 'Syntax error near', name: 'FORM' }) + expect(explainSqlError('syntax error at end of input')).toMatchObject({ kind: 'syntax', title: 'The statement ends too early', name: '' }) + expect(explainSqlError("1064 (42000): You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'FORM users' at line 1")) + .toMatchObject({ kind: 'syntax', name: 'FORM' }) + expect(explainSqlError("You have an error in your SQL syntax; check the manual for the right syntax to use near '' at line 1")) + .toMatchObject({ kind: 'syntax', title: 'The statement ends too early', name: '' }) + }) + + it('falls back to the message itself', () => { + expect(explainSqlError('permission denied for table secrets\nDETAIL: nope')).toEqual({ + kind: 'other', title: 'permission denied for table secrets', name: '', detail: 'DETAIL: nope', + }) + }) +}) + +describe('suggestNames', () => { + const tables = ['alembic_version', 'alembic_versions_old', 'users', 'Orders', 'order_items'] + + it('finds the typo', () => { + expect(suggestNames('alembic_versio', tables, 'postgres')[0]).toBe('alembic_version') + }) + + it('puts a case-only match first, quoted on Postgres', () => { + expect(suggestNames('orders', tables, 'postgres')[0]).toBe('"Orders"') + expect(suggestNames('orders', tables, 'mysql')[0]).toBe('Orders') + }) + + it('compares the bare name of a qualified one', () => { + expect(suggestNames('public.userz', tables, 'postgres')).toContain('users') + }) + + it('counts a swapped pair as one typo', () => { + expect(suggestNames('FORM', ['FROM', 'FOR', 'FORMAT'], '')[0]).toBe('FROM') + }) + + it('stays quiet when nothing is close', () => { + expect(suggestNames('invoices', tables, 'postgres')).toEqual([]) + expect(suggestNames('', tables, 'postgres')).toEqual([]) + }) + + it('caps the list', () => { + expect(suggestNames('a', ['b', 'c', 'd', 'e'], 'postgres', 2).length).toBeLessThanOrEqual(2) + }) +}) + +describe('helpers', () => { + it('bareName strips qualifiers and quotes', () => { + expect(bareName('public.orders')).toBe('orders') + expect(bareName('"public"."My.Table"')).toBe('My.Table') + expect(bareName('`shop`.`orders`')).toBe('orders') + }) + + it('writeIdent quotes only what needs it', () => { + expect(writeIdent('orders', 'postgres')).toBe('orders') + expect(writeIdent('Orders', 'postgres')).toBe('"Orders"') + expect(writeIdent('order items', 'mysql')).toBe('`order items`') + expect(writeIdent('Orders', 'sqlite')).toBe('Orders') + }) + + it('tablesIn lists the tables a statement touches', () => { + expect(tablesIn('SELECT u.id FROM public.users u JOIN "Orders" o ON o.user_id = u.id')).toEqual(['users', 'Orders']) + expect(tablesIn('update accounts set x = 1')).toEqual(['accounts']) + }) +}) From 6fb2fb5ba607bd0ae075fd0b5305a1c823d72d81 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:09:00 +0545 Subject: [PATCH 47/77] Show the explanation and a fix in the query error view --- src/lib/components/SqlErrorConsole.svelte | 141 +++++++++++++++++----- 1 file changed, 113 insertions(+), 28 deletions(-) diff --git a/src/lib/components/SqlErrorConsole.svelte b/src/lib/components/SqlErrorConsole.svelte index 9436d717..69951873 100644 --- a/src/lib/components/SqlErrorConsole.svelte +++ b/src/lib/components/SqlErrorConsole.svelte @@ -1,17 +1,20 @@ + + open, (v) => { if (!v) finish(false) }}> + + {#if undo && words} +
+
+
+ {words.title} + ran {ranAgo(undo.at)} +
+ {words.body} +
+ + +
+ Ran +
+ +
+ + + Revert runs{#if undo.statements.length > 1} · {undo.statements.length} statements{/if} + +
+ +
+
+ + {#if undo.warnings.length || words.note} +
    + {#each undo.warnings as w (w)} +
  • + + {w} +
  • + {/each} + {#if words.note} +
  • + + {words.note} +
  • + {/if} +
+ {/if} +
+ +
+ + +
+ {/if} +
+
From ae0cb60093e6325ed6bf07f89e676770f63f6df4 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:09:00 +0545 Subject: [PATCH 51/77] Add API helpers for undo reads --- src/lib/api.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/lib/api.js b/src/lib/api.js index 42ac66de..45073ac5 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -1326,6 +1326,25 @@ export async function txStatus(sessionId) { return inv('tx_status', { sessionId }) } +/** + * A read for a console write's undo copy (sql-undo.js), outside any + * transaction. Not logged as a query of the user's, and stoppable through the + * run's own `queryId`. + * @param {string} sql @param {string} [queryId] + */ +export async function inspectSql(sql, queryId) { + return inv('pg_execute_sql', { sql, queryId: queryId ?? null }) +} + +/** + * txExecute for the undo copy's transaction: the same, without a query-log + * entry for each of its reads. The run itself is recorded in history as usual. + * @param {string} sessionId @param {string} sql + */ +export async function txExecuteQuiet(sessionId, sql) { + return inv('tx_execute', { sessionId, sql }) +} + // ── Terminal ────────────────────────────────────────────────────────────────── // The connection's own CLI (psql, mysql, sqlite3, sqlcmd, redis-cli) in a // pseudo-terminal. See src-tauri/src/db/terminal.rs. From da4a3f1e9eda5a829b88c599ac2f02a605189c82 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:09:00 +0545 Subject: [PATCH 52/77] Revert from run marks, apply fixes, and narrow the SQL editor gutter --- src/lib/components/SqlEditor.svelte | 306 +++++++++++++++++++++------- 1 file changed, 237 insertions(+), 69 deletions(-) diff --git a/src/lib/components/SqlEditor.svelte b/src/lib/components/SqlEditor.svelte index cdf1d36e..ad575a4b 100644 --- a/src/lib/components/SqlEditor.svelte +++ b/src/lib/components/SqlEditor.svelte @@ -16,7 +16,7 @@ import { onMount } from 'svelte' import { StateEffect, StateField, RangeSetBuilder, Prec, EditorState } from '@codemirror/state' import { EditorView, Decoration, ViewPlugin, GutterMarker, WidgetType, gutter, hoverTooltip, keymap, drawSelection, highlightWhitespace } from '@codemirror/view' - import { indentUnit, foldGutter } from '@codemirror/language' + import { indentUnit, foldable, foldEffect, unfoldEffect, foldedRanges, syntaxTree } from '@codemirror/language' import { insertNewlineKeepIndent } from '@codemirror/commands' import { snippet, completionStatus, hasNextSnippetField, hasPrevSnippetField } from '@codemirror/autocomplete' import { wantsTerminator } from '$lib/sql-terminator.js' @@ -76,6 +76,12 @@ * @type {((action: 'run' | 'newtab' | 'json' | 'variables' | 'ai', sql: string) => void) | undefined} */ onlens = undefined, + /** + * Revert a run from its statement's lens: the id of the undo copy the run + * kept (sql-undo.js). Shown only on a statement whose last run kept one. + * @type {((undoId: string) => void) | undefined} + */ + onrevert = undefined, /** @param {string} content */ onchange = undefined, /** @type {(actions: { format: () => Promise }) => void} */ @@ -169,16 +175,30 @@ * and goes when that statement itself is edited: it is about the text that * ran. A running mark follows its statement until the run ends. `info` is * the note written after the statement (`478ms · 12 rows`). - * @typedef {{ from: number, to: number, kind: 'running' | 'ok' | 'failed', title: string, info?: string }} RunMark + * `undo` is the run's undo copy, while it can still be reverted. + * @typedef {{ from: number, to: number, kind: 'running' | 'ok' | 'failed', title: string, info?: string, undo?: { id: string, kind: string } | null }} RunMark * @typedef {{ from: number, to: number, stmtFrom: number, stmtTo: number, message: string }} RunError * @typedef {{ marks: RunMark[], errors: RunError[], at: number }} RunMarks */ const NO_RUN_MARKS = /** @type {RunMarks} */ ({ marks: [], errors: [], at: 0 }) const setRunMarks = StateEffect.define() + /** A run's revert went through: its mark loses the button and says so. */ + const markRevertedEffect = StateEffect.define() const runMarksField = StateField.define({ create: () => NO_RUN_MARKS, update(run, tr) { - for (const e of tr.effects) if (e.is(setRunMarks)) return /** @type {RunMarks} */ (e.value) + for (const e of tr.effects) { + if (e.is(setRunMarks)) return /** @type {RunMarks} */ (e.value) + if (e.is(markRevertedEffect)) { + const id = /** @type {string} */ (e.value) + run = { + ...run, + marks: run.marks.map((m) => (m.undo?.id === id + ? { ...m, undo: null, title: 'Reverted', info: m.info ? `${m.info} · reverted` : 'reverted' } + : m)), + } + } + } if (!tr.docChanged || (!run.marks.length && !run.errors.length)) return run const ch = tr.changes const marks = run.marks @@ -402,46 +422,105 @@ }, ) - const glyphGutter = gutter({ - class: 'cm-sql-glyphs', - markers(view) { - /** @type {Map} */ - const byLine = new Map() - const run = view.state.field(runMarksField) - const doc = view.state.doc - for (const m of run.marks) { - byLine.set(doc.lineAt(Math.min(m.from, doc.length)).from, new GlyphMarker(m.kind, m.title, run.at)) - } - for (const d of view.state.field(lintField).diags) { - const from = view.state.doc.lineAt(Math.min(d.start, view.state.doc.length)).from - const prev = byLine.get(from) - if (prev?.kind === 'error' || prev?.kind === 'running' || prev?.kind === 'failed') continue - byLine.set(from, new GlyphMarker(d.severity === 'error' ? 'error' : 'warning', d.message)) - } - const builder = new RangeSetBuilder() - for (const from of [...byLine.keys()].sort((a, b) => a - b)) builder.add(from, from, /** @type {GlyphMarker} */ (byLine.get(from))) - return builder.finish() - }, - initialSpacer: () => new GlyphMarker('ok', ''), - }) + /** + * Run and lint marks by line start. Rebuilt only when either changes: the + * gutter asks once per visible line. + */ + let glyphMemo = { run: /** @type {RunMarks | null} */ (null), lint: /** @type {unknown} */ (null), map: new Map() } + /** @param {import('@codemirror/state').EditorState} state @returns {Map} */ + function glyphsOf(state) { + const run = state.field(runMarksField) + const lint = state.field(lintField) + if (glyphMemo.run === run && glyphMemo.lint === lint) return glyphMemo.map + /** @type {Map} */ + const map = new Map() + const doc = state.doc + for (const m of run.marks) map.set(doc.lineAt(Math.min(m.from, doc.length)).from, new GlyphMarker(m.kind, m.title, run.at)) + for (const d of lint.diags) { + const from = doc.lineAt(Math.min(d.start, doc.length)).from + const prev = map.get(from) + if (prev?.kind === 'error' || prev?.kind === 'running' || prev?.kind === 'failed') continue + map.set(from, new GlyphMarker(d.severity === 'error' ? 'error' : 'warning', d.message)) + } + glyphMemo = { run, lint, map } + return map + } + + /** The fold sitting on `line`, if it is folded. @param {import('@codemirror/state').EditorState} state @param {{ from: number, to: number }} line */ + function foldOn(state, line) { + /** @type {{ from: number, to: number } | null} */ + let found = null + foldedRanges(state).between(line.from, line.to, (from, to) => { if (!found || found.from > from) found = { from, to } }) + return found + } /** - * Fold arrows on the mark's side of the numbers, not between the numbers and - * the gutter line where CodeEditor puts them: there they were a strip that - * stays empty until hovered and made the gap before the line two and a half - * times the gap after it. Here the line has the numbers g before it and the - * text g after it, arrows or not. High precedence, after the marks' gutter, - * so the columns run mark, arrow, number. + * One cell per line for the run mark and the fold arrow, instead of a column + * each: they took a third of the gutter between them, mostly blank. The mark + * shows; while the pointer is over the gutter a foldable line shows its arrow + * in its place, and a folded statement keeps its arrow (it is the only sign + * the text is there). */ - const foldColumn = Prec.high(foldGutter({ - markerDOM(open) { + class StatusMarker extends GutterMarker { + /** @param {GlyphMarker | null} glyph @param {'open' | 'closed' | null} fold */ + constructor(glyph, fold) { + super() + this.glyph = glyph + this.fold = fold + } + /** @param {StatusMarker} other */ + eq(other) { + if (other.fold !== this.fold) return false + if (!other.glyph || !this.glyph) return other.glyph === this.glyph + return other.glyph.eq(this.glyph) + } + toDOM() { const el = document.createElement('span') - el.className = 'cm-fold-marker' - el.title = open ? 'Fold' : 'Unfold' - el.append(hugeSvg(open ? ArrowDown01Icon : ArrowRight01Icon)) + el.className = this.fold ? `sql-cell sql-cell-fold sql-cell-${this.fold}` : 'sql-cell' + if (this.glyph) el.append(this.glyph.toDOM()) + if (this.fold) { + const arrow = document.createElement('span') + arrow.className = 'cm-fold-marker' + arrow.append(hugeSvg(this.fold === 'open' ? ArrowDown01Icon : ArrowRight01Icon)) + el.append(arrow) + el.title = [this.glyph?.title, this.fold === 'open' ? 'Fold' : 'Unfold'].filter(Boolean).join('\n') + } return el + } + } + + const statusGutter = gutter({ + class: 'cm-sql-glyphs', + lineMarker(view, line) { + const glyph = glyphsOf(view.state).get(line.from) ?? null + /** @type {'open' | 'closed' | null} */ + let fold = null + if (view.state.field(configField).fold) { + if (foldOn(view.state, line)) fold = 'closed' + else if (foldable(view.state, line.from, line.to)) fold = 'open' + } + return glyph || fold ? new StatusMarker(glyph, fold) : null }, - })) + lineMarkerChange: (u) => u.docChanged || u.viewportChanged + || u.transactions.some((tr) => tr.effects.some((e) => e.is(setRunMarks) || e.is(markRevertedEffect) || e.is(foldEffect) || e.is(unfoldEffect) || e.is(setConfig))) + || u.startState.field(lintField) !== u.state.field(lintField) + || syntaxTree(u.startState) !== syntaxTree(u.state), + initialSpacer: () => new StatusMarker(new GlyphMarker('ok', ''), null), + domEventHandlers: { + click(view, line) { + if (!view.state.field(configField).fold) return false + const folded = foldOn(view.state, line) + if (folded) { + view.dispatch({ effects: unfoldEffect.of(folded) }) + return true + } + const range = foldable(view.state, line.from, line.to) + if (!range) return false + view.dispatch({ effects: foldEffect.of(range) }) + return true + }, + }, + }) // ── Active statement: a faint band behind the one under the caret ────── // Only when the buffer holds more than one, so a single query stays clean. @@ -452,7 +531,7 @@ // Pushed in as an effect when they change, so the fields below recompute // without the editor being rebuilt. - /** @typedef {{ lens: 'off' | 'current' | 'all', highlight: boolean, variables: boolean, endHint: boolean }} EditorConfig */ + /** @typedef {{ lens: 'off' | 'current' | 'all', highlight: boolean, variables: boolean, endHint: boolean, fold: boolean }} EditorConfig */ const setConfig = StateEffect.define() /** @returns {EditorConfig} */ function currentConfig() { @@ -461,6 +540,8 @@ highlight: $appSqlEditor.highlightBlock, variables: $appSqlEditor.variables, endHint: $appSqlEditor.endHint && !readOnly, + // Beside the numbers only: a fold arrow with no numbers is a mark nothing explains. + fold: $appSqlEditor.lineNumbers && $appSqlEditor.folding, } } const configField = StateField.define({ @@ -544,13 +625,15 @@ * @param {boolean} [float] pinned over the right end of the statement's * first line instead of a row of its own (the caret mode) */ - constructor(vars, float = false) { + constructor(vars, float = false, undo = '') { super() this.vars = vars this.float = float + /** The statement's last run kept an undo copy: offer Revert. */ + this.undo = undo } /** @param {LensWidget} other */ - eq(other) { return other.vars === this.vars && other.float === this.float } + eq(other) { return other.vars === this.vars && other.float === this.float && other.undo === this.undo } /** @param {EditorView} view */ toDOM(view) { if (this.float) return this.floatDOM(view) @@ -558,6 +641,17 @@ row.className = 'cm-sql-lens' row.setAttribute('role', 'toolbar') row.setAttribute('aria-label', 'Statement actions') + if (this.undo) { + const b = document.createElement('button') + b.type = 'button' + b.className = 'cm-sql-lens-revert' + b.textContent = 'Revert' + b.title = 'Put back what the last run of this statement changed' + const undo = this.undo + b.addEventListener('mousedown', (e) => e.preventDefault()) + b.addEventListener('click', () => onrevert?.(undo)) + row.append(b) + } for (const a of LENS_ACTIONS) { if (a.id === 'variables' && !this.vars) continue if (row.childElementCount) { @@ -606,6 +700,19 @@ b.addEventListener('click', (e) => { e.stopPropagation(); onclick() }) return b } + if (this.undo) { + const undo = this.undo + chip.append(button( + 'Revert', + 'Put back what the last run of this statement changed', + () => onrevert?.(undo), + 'cm-sql-lens-revert', + )) + const gap = document.createElement('span') + gap.className = 'cm-sql-lens-sep' + gap.setAttribute('aria-hidden', 'true') + chip.append(gap) + } chip.append(button( 'Run', 'Run this statement (Ctrl+R)', @@ -687,9 +794,12 @@ const doc = state.doc const caret = statementAtOffset(stmts, state.selection.main.head) const builder = new RangeSetBuilder() - /** @param {{ from: number }} line @param {{ text: string }} st */ + const runMarks = onrevert ? (state.field(runMarksField, false)?.marks ?? []) : [] + /** The undo copy of the statement's last run, while it has one. @param {{ start: number, end: number }} st */ + const undoOf = (st) => runMarks.find((m) => m.kind === 'ok' && m.undo && m.from < st.end && m.to > st.start)?.undo?.id ?? '' + /** @param {{ from: number }} line @param {{ text: string, start: number, end: number }} st */ const add = (line, st) => builder.add(line.from, line.from, Decoration.widget({ - widget: new LensWidget(variables && hasVariables(st.text)), + widget: new LensWidget(variables && hasVariables(st.text), false, undoOf(st)), block: true, side: -1, })) @@ -701,7 +811,7 @@ // (and placed the completion list) at the chip on the far right. if (caret) { const line = doc.lineAt(caret.start) - builder.add(line.from, line.from, Decoration.widget({ widget: new LensWidget(variables && hasVariables(caret.text), true), block: true, side: -1 })) + builder.add(line.from, line.from, Decoration.widget({ widget: new LensWidget(variables && hasVariables(caret.text), true, undoOf(caret)), block: true, side: -1 })) } return builder.finish() } @@ -720,7 +830,9 @@ const lensField = StateField.define({ create: (state) => lensRanges(state), - update: (v, tr) => (tr.docChanged || tr.selection || configChanged(tr) ? lensRanges(tr.state) : v), + update: (v, tr) => (tr.docChanged || tr.selection || configChanged(tr) || tr.effects.some((e) => e.is(setRunMarks) || e.is(markRevertedEffect)) + ? lensRanges(tr.state) + : v), provide: (f) => EditorView.decorations.from(f), }) @@ -871,22 +983,35 @@ const consoleTheme = EditorView.theme({ '.cm-content': { padding: '12px 0' }, - // One gap, g = 0.5em, sets every step of the gutter: either side of the - // run mark, after the line numbers, and from the gutter's edge to the - // text - so the mark sits centred in its column and the edge line is as - // far from the mark as from the code, numbers or not. It was 0.55em before - // the mark, 0.4em after it and a fixed 10px before the text, and the 1em - // mark overflowed a 1.05em border-box cell. In em throughout: the text - // follows the app zoom (--cm-font-size is a type-scale step), so a px gap - // drifted against it. + // The gutter is two columns: the run mark (which the fold arrow shares) + // and the line numbers, then g = 0.5em to the text. The mark sits 0.45em + // from the edge and 0.3em from the numbers: tight, because a third column + // and a full g either side of the mark made the gutter ~100px at 125% zoom. + // In em throughout: the text follows the app zoom (--cm-font-size is a + // type-scale step), so a px gap drifted against it. '.cm-sql-glyphs .cm-gutterElement': { display: 'flex', alignItems: 'center', justifyContent: 'center', boxSizing: 'content-box', width: '1em', - padding: '0 0.5em', + padding: '0 0.3em 0 0.45em', + }, + // Mark and fold arrow share the cell: stacked, and swapped while the + // pointer is over the gutter (a folded line keeps its arrow). + '.sql-cell': { position: 'relative', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: '1em', height: '1em' }, + '.sql-cell-fold': { cursor: 'pointer' }, + '.sql-cell .cm-fold-marker': { + position: 'absolute', + inset: '0', + alignItems: 'center', + justifyContent: 'center', + opacity: '0', + transition: 'opacity 120ms', }, + '.sql-cell .sql-glyph': { transition: 'opacity 120ms' }, + '.cm-gutters:hover .sql-cell-fold .cm-fold-marker, .sql-cell-closed .cm-fold-marker': { opacity: '1' }, + '.cm-gutters:hover .sql-cell-fold .sql-glyph, .sql-cell-closed .sql-glyph': { opacity: '0' }, '.cm-content .cm-line': { paddingLeft: '0.5em' }, '.sql-glyph': { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', cursor: 'default' }, '.sql-glyph svg': { width: '1em', height: '1em' }, @@ -973,18 +1098,11 @@ }, '.sql-glyph-error': { color: 'var(--destructive)' }, '.sql-glyph-warning': { color: 'var(--warning)' }, - // Three digits reserved, not the cell dock's five: a query is rarely past - // line 999, and the reserve was the gap between the glyphs and the numbers. - // The mark's column already ends in g, so the numbers only need g after. + // Two digits reserved, not the cell dock's five: most consoles stay under + // line 100, and a reserve the numbers never use is just a blank strip. A + // longer script widens the column once, at line 100. '.cm-gutters .cm-lineNumbers .cm-gutterElement': { - minWidth: 'calc(3ch + 0.5em)', - padding: '0 0.5em 0 0', - }, - // The fold arrow: as wide as itself plus g, in the editor's em like the - // mark, so mark, arrow and number step across at the same gap. - '.cm-gutters .cm-foldGutter .cm-gutterElement': { - boxSizing: 'content-box', - width: '0.85em', + minWidth: 'calc(2ch + 0.5em)', padding: '0 0.5em 0 0', }, '.cm-gutters .cm-fold-marker svg': { width: '0.85em', height: '0.85em' }, @@ -1047,6 +1165,8 @@ '.cm-sql-lens-float svg': { width: '0.95em', height: '0.95em', flex: 'none' }, '.cm-sql-lens-run': { color: 'var(--foreground) !important' }, '.cm-sql-lens-run svg': { color: 'var(--success)' }, + '.cm-sql-lens-revert': { color: 'var(--foreground) !important' }, + '.cm-sql-lens-revert svg': { color: 'var(--warning)' }, '.cm-sql-lens-more': { padding: '0 4px !important' }, '.cm-sql-lens-menu': { position: 'absolute', @@ -1111,7 +1231,7 @@ const baseExtensions = [ configField, - Prec.high(glyphGutter), + Prec.high(statusGutter), runMarksField, runErrorTooltip, lintField, @@ -1129,8 +1249,6 @@ let vimExtension = $state(/** @type {import('@codemirror/state').Extension | null} */ (null)) const editing = $derived([ ...editingExtensions($appSqlEditor), - // Beside the numbers only: an arrow column with no numbers is a stripe nothing explains. - ...($appSqlEditor.lineNumbers && $appSqlEditor.folding ? [foldColumn] : []), ...(onlens && !readOnly && $appSqlEditor.codeLens === 'current' ? [lensRoom] : []), ]) const extensions = $derived([...(vimExtension ? [Prec.highest(vimExtension)] : []), ...baseExtensions, ...editing]) @@ -1238,7 +1356,9 @@ * went to the database when it differs from the editor's (variables filled * in, a LIMIT added), for placing the failure; `ms` and `rows` or `affected` * make the note after a statement that ran. - * @param {Array<{ sql: string, sent?: string, error?: string | null, position?: number | null, ms?: number | null, rows?: number | null, affected?: number | null }>} outcomes + * `undo` is the undo copy a write kept, for the lens's Revert; `undoNote` + * says why one that could have kept a copy did not. + * @param {Array<{ sql: string, sent?: string, error?: string | null, position?: number | null, ms?: number | null, rows?: number | null, affected?: number | null, undo?: { id: string, kind: string } | null, undoNote?: string }>} outcomes */ export function markOutcomes(outcomes) { const view = editorRef?.getView() @@ -1259,7 +1379,8 @@ const st = stmts[i] if (!o.error) { const info = formatRunInfo({ ms: o.ms, rows: o.rows, affected: o.affected }) - marks.push({ from: st.start, to: st.end, kind: 'ok', title: info ? `Ran successfully · ${info}` : 'Ran successfully', info }) + const title = (info ? `Ran successfully · ${info}` : 'Ran successfully') + (o.undoNote ? `\nNo revert: ${o.undoNote}` : '') + marks.push({ from: st.start, to: st.end, kind: 'ok', title, info, undo: o.undo ?? null }) continue } const message = o.error.replace(/^Error:\s*/, '').replace(/^(Query|Statement \d+) failed:\s*(error returned from database:\s*)?/i, '') @@ -1309,11 +1430,58 @@ markRun('ok', ranStatement, formatRunInfo(run)) } + /** The run whose undo copy this is was reverted. @param {string} undoId */ + export function markReverted(undoId) { + editorRef?.getView()?.dispatch({ effects: markRevertedEffect.of(undoId) }) + } + /** Drop the run marks (the run failed or was stopped). */ export function clearRunMarks() { editorRef?.getView()?.dispatch({ effects: setRunMarks.of(NO_RUN_MARKS) }) } + /** + * The error console's "Did you mean": rewrite `name` inside the statement + * that failed, preferring the occurrence the database pointed at. Only while + * that statement is unedited since the run (its failure mark is still there), + * so it never rewrites text the error is no longer about. + * @param {string} name the bare name the error reported + * @param {string} replacement already quoted as the dialect needs + * @returns {boolean} whether anything was replaced + */ + export function replaceInFailed(name, replacement) { + const view = editorRef?.getView() + if (!view || !name) return false + const esc = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + // As written: bare in any case (the error reports the folded name), or quoted. + const re = new RegExp(`"${esc}"|\`${esc}\`|\\[${esc}\\]|(?= e.from + if (!hit || atError) hit = { from, to } + if (atError) break + } + if (!hit) continue + // A keyword goes in the case the statement is written in. + const was = view.state.doc.sliceString(hit.from, hit.to) + const insert = /^[A-Z_]+$/.test(replacement) && was === was.toLowerCase() ? replacement.toLowerCase() : replacement + view.dispatch({ + changes: { from: hit.from, to: hit.to, insert }, + selection: { anchor: hit.from, head: hit.from + insert.length }, + scrollIntoView: true, + userEvent: 'input.complete', + }) + view.focus() + return true + } + return false + } + /** Focus the editor (called when the SQL tab becomes active). */ /** Fold every statement to its first line (the editor menu's Fold all). */ export function foldAll() { editorRef?.foldEverything?.() } From d19ec3ae2b406142b8e42bf1b726a596964425ca Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:09:00 +0545 Subject: [PATCH 53/77] Revert a console run from its lens --- src/lib/components/SqlConsole.svelte | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/lib/components/SqlConsole.svelte b/src/lib/components/SqlConsole.svelte index 167666ae..a23e56d8 100644 --- a/src/lib/components/SqlConsole.svelte +++ b/src/lib/components/SqlConsole.svelte @@ -677,6 +677,11 @@ let saveQueryName = $state(''); let savingQuery = $state(false); + /** @param {string} undoId */ + async function revertFromLens(undoId) { + if (await onrevertrun?.(undoId)) sqlEditorRef?.markReverted?.(undoId) + } + function fixWithAi() { const failedSql = activeSet?.error ? activeSet.sql : sql if (!shownError || !failedSql.trim()) return From 1718aa8536fec7bd0fcd222875d3828da4071f9d Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:09:00 +0545 Subject: [PATCH 54/77] Offer did-you-mean fixes in the SQL console --- src/lib/components/SqlConsole.svelte | 40 +++++++++++++++++++--------- 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/src/lib/components/SqlConsole.svelte b/src/lib/components/SqlConsole.svelte index a23e56d8..3ff994c1 100644 --- a/src/lib/components/SqlConsole.svelte +++ b/src/lib/components/SqlConsole.svelte @@ -125,6 +125,12 @@ /** Called when user clicks "Fix with AI" - parent opens sidebar and sends the message */ /** @param {{ error: string, sql: string }} detail */ onfixwithai = /** @type {((detail: { error: string, sql: string }) => void) | undefined} */ (undefined), + /** + * Revert a run from its statement's lens (sql-undo.js). Resolves whether + * the revert ran, so the lens drops its button. + * @type {((undoId: string) => Promise) | undefined} + */ + onrevertrun = undefined, onprorequired = /** @type {() => void} */ (() => {}), /** The result lives in the backend's result store and `rows` is a sparse * view of it (stored-result-view.js): the grid runs windowed, sorting @@ -156,7 +162,7 @@ onaskai = undefined, } = $props(); - /** @type {{ focus: () => void, markRunning: (ranStatement?: string | null) => void, markExecuted: (ranStatement?: string | null, run?: { ms?: number | null, rows?: number | null }) => void, markOutcomes: (outcomes: Array<{ sql: string, sent?: string, error: string | null, position: number | null, ms?: number | null, rows?: number | null, affected?: number | null }>) => void, clearRunMarks: () => void, getStatementAtCursor: () => string, getSelectionText: () => string } | null} */ + /** @type {{ focus: () => void, markRunning: (ranStatement?: string | null) => void, markExecuted: (ranStatement?: string | null, run?: { ms?: number | null, rows?: number | null }) => void, markOutcomes: (outcomes: Array<{ sql: string, sent?: string, error: string | null, position: number | null, ms?: number | null, rows?: number | null, affected?: number | null }>) => void, clearRunMarks: () => void, markReverted: (undoId: string) => void, replaceInFailed: (name: string, replacement: string) => boolean, getStatementAtCursor: () => string, getSelectionText: () => string } | null} */ let sqlEditorRef = $state(null) /** Mod+R from outside the editor: the selection, else the statement at the cursor. */ @@ -682,6 +688,20 @@ if (await onrevertrun?.(undoId)) sqlEditorRef?.markReverted?.(undoId) } + /** + * "Did you mean" in the error console: put the name in the failed statement. + * The editor refuses once that statement has been edited since the run, and + * then the name goes to the clipboard instead of nowhere. + * @param {string} name @param {string} replacement + */ + async function applySuggestion(name, replacement) { + if (sqlEditorRef?.replaceInFailed?.(name, replacement)) return + try { + await navigator.clipboard.writeText(replacement) + toast.info(`Copied ${replacement}`, { description: 'The statement changed since it ran, so it was not edited.' }) + } catch { /* clipboard unavailable - no-op */ } + } + function fixWithAi() { const failedSql = activeSet?.error ? activeSet.sql : sql if (!shownError || !failedSql.trim()) return @@ -1205,6 +1225,7 @@ onmodj={toggleOutput} onmodshiftb={() => { toggleHistory(); onmodshiftb?.() }} onlens={onStatementAction} + onrevert={onrevertrun ? (id) => void revertFromLens(id) : undefined} onactionsready={(actions) => { formatSql = actions.format; }} @@ -1446,22 +1467,17 @@ position={failedStatement.position} queryMs={currentDisplay.queryMs} dialect={engine} + hints={schemaHints} copied={errorCopied} oncopy={copyError} onfixwithai={onfixwithai ? fixWithAi : undefined} + onsuggest={applySuggestion} > {#if /statement timeout|canceling statement due to/i.test(shownError)} -

- The query timed out. If this table has large JSON/text columns, select just the - columns you need instead of *, or add a smaller - LIMIT. -

- {:else if /relation "[^"]*" does not exist|column "[^"]*" does not exist/i.test(shownError)} -

- PostgreSQL folds unquoted names to lowercase, so a table like - Products only matches when quoted - - SELECT * FROM "Products". Pick the table from - autocomplete and it inserts the quoted form for you. +

+ The query ran past the statement timeout. If the table has large JSON or text columns, select only + the columns you need instead of *, or add a smaller + LIMIT.

{/if} From f87d84885fabf04494b05d186f2f163d49c51806 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:09:00 +0545 Subject: [PATCH 55/77] Capture undo copies and revert runs from the shell Also stops a streamed SELECT reporting its row count as rows affected, and a schema change reporting "0 affected". --- src/lib/components/StudioShell.svelte | 129 +++++++++++++++++++++++++- 1 file changed, 125 insertions(+), 4 deletions(-) diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index a5140892..b65cc2b9 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -3,7 +3,8 @@ import { fade } from 'svelte/transition' import { revealApp, isRevealed } from '$lib/app-reveal.js' import { setReadOnly } from '$lib/stores/read-only.js' - import { isWriteSql, sqlRunEffects } from '$lib/sql-write.js' + import { isWriteSql, sqlRunEffects, stripSqlComments } from '$lib/sql-write.js' + import { parseWrite, runWithUndo, undoDialect, keepUndo, getUndo, dropUndo, revertSummary } from '$lib/sql-undo.js' import { bumpObjects } from '$lib/stores/sidebar-objects.svelte.js' import Logo from './Logo.svelte' import ConnectOverlay from './ConnectOverlay.svelte' @@ -32,7 +33,7 @@ import { createHotkey } from '@tanstack/svelte-hotkeys' import { IS_MAC } from '$lib/shortcuts.js' import { findSearchInput, isTypingTarget } from '$lib/focus-search.js' - import { appFkAutoExpandJson, cycleTheme, restorePreviousTheme, isCurrentThemeDark, loadSettings, appPaginationMode, appVimMode, appAutoSaveQueries, appStreamResults, appSqlEditor, increaseZoom, decreaseZoom, resetZoom } from '$lib/stores/settings.js' + import { appFkAutoExpandJson, cycleTheme, restorePreviousTheme, isCurrentThemeDark, loadSettings, appPaginationMode, appVimMode, appAutoSaveQueries, appStreamResults, appSqlUndo, appSqlEditor, increaseZoom, decreaseZoom, resetZoom } from '$lib/stores/settings.js' import { requireUnlock } from '$lib/stores/app-lock.js' import { isTextEntryTarget, setVimSubMode } from '$lib/vim/vim.js' import { normalizeColumn, columnType } from '$lib/column.js' @@ -86,6 +87,7 @@ import AboutDialog from './AboutDialog.svelte' import ReportIssueDialog from './ReportIssueDialog.svelte' import UpdateDialog from './UpdateDialog.svelte' + import SqlRevertDialog from './SqlRevertDialog.svelte' import StatusBar from './StatusBar.svelte' import QueryLogConsole from './QueryLogConsole.svelte' import DisconnectDialog from './DisconnectDialog.svelte' @@ -150,6 +152,8 @@ txStatus, txCommit, txRollback, + inspectSql, + txExecuteQuiet, executeDdl, updateTableCell, fetchCellValue, @@ -6298,6 +6302,101 @@ let rowSearch = $state('') } } + /** + * A single UPDATE / DELETE / INSERT keeps an undo copy for the Revert button + * on its lens (sql-undo.js), on a transaction of its own. Null, or a + * fallback, means it runs the ordinary way; a fallback's note says why it + * has no copy. + * @param {string} sql @param {string} queryId + * @returns {Promise} + */ + async function captureWrite(sql, queryId) { + if (!get(appSqlUndo)) return null + const dialect = undoDialect(dbType) + const plan = dialect ? parseWrite(sql) : null + if (!dialect || !plan) return null + const session = `undo-${queryId}` + const out = await runWithUndo(plan, dialect, { + inspect: (q) => inspectSql(q, queryId), + begin: () => txBegin(session), + exec: (q) => txExecuteQuiet(session, q), + run: (q) => txExecuteQuiet(session, q), + commit: () => txCommit(session), + rollback: () => txRollback(session), + }) + if ('undo' in out && out.undo) keepUndo({ ...out.undo, connection: connectionId }) + return out + } + + /** The revert waiting on its confirm dialog. @type {import('$lib/sql-undo.js').UndoRecord | null} */ + let revertAsk = $state(null) + /** @type {((ran: boolean) => void) | null} */ + let revertDone = null + + /** + * Revert from a statement's lens: confirm, then run the undo copy's + * statements in one transaction. Resolves whether it ran, so the lens can + * drop its button. + * @param {string} id + * @returns {Promise} + */ + async function revertSqlRun(id) { + const u = getUndo(id) + if (!u) { + toast.error('Nothing to revert', { description: 'The copy of this run is gone. Stroke keeps the last 30, until it closes.' }) + return false + } + if (u.connection !== connectionId) { + toast.error('Connect to the database this ran on to revert it.') + return false + } + // A table about to be dropped says what it holds now, read as the dialog opens. + let shown = u + if (u.precheck) { + try { + const n = Number((await inspectSql(u.precheck.sql)).rows?.[0]?.[0] ?? 0) + if (n > 0) shown = { ...u, warnings: [u.precheck.says.replace('{rows}', `${n.toLocaleString()} ${n === 1 ? 'row' : 'rows'}`), ...u.warnings] } + } catch { /* gone already: the drop will say so */ } + } + revertDone?.(false) + revertAsk = shown + return new Promise((resolve) => { revertDone = resolve }) + } + + /** @param {boolean} ran */ + function settleRevert(ran) { + revertAsk = null + revertDone?.(ran) + revertDone = null + } + + async function confirmRevert() { + const u = revertAsk + if (!u) return + const session = `revert-${u.id}` + try { + await txBegin(session) + let affected = 0 + try { + for (const stmt of u.statements) affected += Number((await txExecute(session, stmt))?.rowCount ?? 0) + await txCommit(session) + } catch (e) { + await txRollback(session).catch(() => {}) + throw e + } + dropUndo(u.id) + const sum = revertSummary(u, affected) + if (sum.ok) toast.success(sum.title) + else toast.warning(sum.title, { description: sum.description }) + // Every chunk names the same table: the first says what changed. + void refreshAfterSql(sqlRunEffects(u.statements[0] ?? '')) + settleRevert(true) + } catch (e) { + toast.error('Revert failed', { description: String(e).replace(/^Error:\s*/, '').replace(/^Query failed:\s*/, ''), code: true }) + settleRevert(false) + } + } + async function runSqlOnTab(overrideSql) { track('sql_run') const sqlRan = typeof overrideSql === 'string' && overrideSql.trim() ? overrideSql : sqlText @@ -6332,6 +6431,8 @@ let rowSearch = $state('') let ranError = '' let ranRowCount = 0 let changedSql = '' + /** @type {import('$lib/sql-undo.js').UndoOutcome | null} */ + let captured = null try { // A tab with an open transaction runs on that transaction's connection, // so its statements stay invisible until the user commits. Everything @@ -6344,6 +6445,17 @@ let rowSearch = $state('') _txSqlByTab.set(runTabId, `${_txSqlByTab.get(runTabId) ?? ''}${sqlRan};\n`) setTxStatus(runTabId, await txStatus(txSession)) } + } else if ((captured = await captureWrite(sqlRan, queryId)) && 'result' in captured) { + results = [captured.result] + const undo = captured.undo + if (stillHere()) { + sqlRunOutcomes = [{ + sql: sqlRan, error: null, position: null, + ms: captured.result.queryMs ?? null, rows: null, affected: captured.result.rowCount ?? null, + undo: undo ? { id: undo.id, kind: undo.kind } : null, undoNote: captured.note, + }] + } + changedSql = sqlRan } else { // Rows stream in (executeSqlStream), into the backend's result store // when that setting is on, otherwise into this window. @@ -6389,15 +6501,23 @@ let rowSearch = $state('') // Each statement's outcome, for the editor marks. A single failed // statement is the run failing; in a script the others still ran. if (stillHere()) { + const note = captured && 'fallback' in captured ? captured.note : '' sqlRunOutcomes = results.map((r) => { - const returned = Array.isArray(r.columns) && r.columns.length > 0 + // A streamed statement's columns arrive through the channel, not in + // the reply: a SELECT read as a write said "6 affected". + const returned = (Array.isArray(r.columns) && r.columns.length > 0) + || (results.length === 1 && streamedCols.length > 0) + || !isWriteSql(r.sql || sqlRan) + // A schema change has no rows to count: "0 affected" after CREATE TABLE said nothing. + const schema = /^\s*(create|alter|drop|comment|grant|revoke|truncate|vacuum|analyze|refresh|reindex|cluster)\b/i.test(stripSqlComments(r.sql || sqlRan)) return { sql: r.sql ?? '', error: r.error ?? null, position: r.errorPosition ?? null, // Time and size, for the note the editor writes after the statement. // rowCount is the rows a query returned, or the rows a write changed. ms: r.queryMs ?? r.query_ms ?? null, rows: returned ? (r.rowCount ?? r.rows?.length ?? null) : null, - affected: returned ? null : (r.rowCount ?? null), + affected: returned || schema ? null : (r.rowCount ?? null), + undo: null, undoNote: results.length === 1 ? note : '', } }) } @@ -8906,6 +9026,7 @@ let rowSearch = $state('') onsaveinplace={handleSaveInPlace} savedQueryName={activeSavedQuery?.name ?? ''} onfixwithai={handleFixWithAi} + onrevertrun={revertSqlRun} onprorequired={() => (showProGate = true)} /> {/await} From 92e8f1d9a45735094553bf3f5e8fcbc1eab36878 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Tue, 6 Oct 2026 14:09:00 +0545 Subject: [PATCH 56/77] Offer console undo in Settings --- src/lib/components/SettingsDialog.svelte | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/lib/components/SettingsDialog.svelte b/src/lib/components/SettingsDialog.svelte index 3a227d69..3e112453 100644 --- a/src/lib/components/SettingsDialog.svelte +++ b/src/lib/components/SettingsDialog.svelte @@ -338,6 +338,10 @@ settings = updateSettings({ streamResults: settings.streamResults === false }); } + function toggleSqlUndo() { + settings = updateSettings({ sqlUndo: settings.sqlUndo === false }); + } + // ── SQL editor ──────────────────────────────────────────────────────────── // Read through the normalizer for the same reason as sqlFmt below. const sqlEd = $derived(/** @type {any} */ (normalizeSqlEditor(settings.sqlEditor))); @@ -777,6 +781,15 @@ )} {/if} + {#if show('Revert console writes', 'Keep a copy of the rows an UPDATE, DELETE or INSERT changes so it can be reverted')} + {@render switchRow( + 'Revert console writes', + 'Keep a copy of the rows a single UPDATE, DELETE or INSERT changes, so its Revert button can put them back. Postgres, MySQL, MariaDB and SQLite, up to 10,000 rows, in memory only.', + settings.sqlUndo !== false, + toggleSqlUndo, + )} + {/if} + {#if visible} @@ -318,7 +370,7 @@ tabindex="-1" aria-label="Dismiss" class="absolute inset-0 cursor-default bg-black/65" - onclick={() => { if (status !== 'downloading') dismissed = true }} + onclick={() => (dismissed = true)} > {/if} @@ -328,7 +380,7 @@
- {#if status === 'done' || status === 'up-to-date'} + {#if status === 'ready' || status === 'up-to-date'} {:else if status === 'error'} @@ -343,12 +395,14 @@ {#if status === 'error'} - {failedAction === 'install' ? 'Download failed' : "Couldn't check for updates"} + {failedAction === 'install' ? 'Download failed' + : failedAction === 'restart' ? "Couldn't install the update" + : "Couldn't check for updates"} {:else if status === 'available'} Stroke {updateVersion} available {:else if status === 'downloading'} Downloading update… - {:else if status === 'done'} + {:else if status === 'ready'} Ready to install {:else if status === 'up-to-date'} Up to date @@ -357,16 +411,16 @@ {/if} - {#if status !== 'downloading'} - - {/if} + +
@@ -377,7 +431,7 @@

- Stroke {updateVersion} + Stroke {updateVersion}. + It keeps downloading if you close this.

- {:else if status === 'done'} -

- Version {updateVersion} downloaded. Restart to apply. -

-
- {#if changelog.length > 0} + {:else if status === 'ready'} + {#if applyOnQuit} +

+ Stroke {updateVersion} is ready. It installs when you quit. +

+
- {/if} - -
+ +
+ {:else} +

+ Version {updateVersion} downloaded. Restart to apply. +

+
+ {#if changelog.length > 0} + + {/if} + +
+ {/if} {:else if status === 'error'}

- {failedAction === 'install' - ? 'The download did not finish.' + {failedAction === 'install' ? 'The download did not finish.' + : failedAction === 'restart' ? 'The update could not be installed. You can try again or keep working.' : 'Stroke could not reach the update server.'}

+ {#if hasTerminalTab} +
+ + {#await import('./TerminalPage.svelte')}{:then { default: TerminalPage }} + + {/await} + +
+ {/if} + {#if insightsEverOpened}
{/snippet} - - {#snippet row(/** @type {any} */ Icon, /** @type {string} */ label, /** @type {string} */ _desc, /** @type {() => void} */ onclick, /** @type {{ pro?: boolean, keys?: string[] }} */ opts = {})} + + {#snippet row(/** @type {any} */ Icon, /** @type {string} */ label, /** @type {string} */ desc, /** @type {() => void} */ onclick, /** @type {{ pro?: boolean, keys?: string[], tone?: 'info' | 'success' | 'primary' | 'warning' }} */ opts = {})} {@const locked = !!opts.pro && !$hasPro} {@const chordText = opts.keys ? ` (${opts.keys.join(isMac ? '' : '+')})` : ''}