diff --git a/.changeset/release-2-2-5.md b/.changeset/release-2-2-5.md new file mode 100644 index 00000000..f07e589e --- /dev/null +++ b/.changeset/release-2-2-5.md @@ -0,0 +1,30 @@ +### New Features + +#### Terminal +- A Terminal tab runs the database's own CLI in the app: psql, mysql, sqlite3, sqlcmd and redis-cli, signed in with the saved connection (Ctrl+`) +- Suggestions for commands, tables and columns, and `show databases` in psql offers `\l` +- Enter runs a finished statement and adds the `;`, Shift+Enter starts a new line +- Wide results print one record per block, and error lines show in red +- Shows the install command when the client is missing + +#### SQL editor +- Colour themes for the code editor: One Dark, GitHub, Dracula, Monokai, Nord, Solarized, Tokyo Night, Catppuccin, Rosé Pine and Gruvbox +- Errors in plain words, with a one-click fix for a mistyped table, column or keyword +- Revert a single UPDATE, DELETE or INSERT, and schema changes such as CREATE TABLE or ADD COLUMN +- Completion follows the grammar: DROP TABLE offers IF EXISTS, ORDER offers BY, ON CONFLICT offers DO NOTHING + +#### Updates +- Updates download in the background and install when you quit, and the status bar offers Restart once one is ready +- A What's new dialog after each update. Background downloads can be turned off in Settings, Updates + +### Bug Fixes +- A SQLite or DuckDB connection with no file no longer loses its tables on disconnect. It asks for a file, and New creates one +- Duplicating or adding a row with page size All no longer drops the last row from the grid +- A row added with infinite scroll on no longer vanishes when more rows load +- A SELECT no longer shows its row count as rows affected + +### Changes +- The new tab page shows which database it is on, with smaller tiles that now include Terminal and Search +- Command palette shortcuts show Ctrl on Windows and Linux instead of macOS symbols +- A narrower SQL editor gutter, and an error view laid out like the editor +- .deb, .rpm and Scoop installs still ask before updating 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", 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..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"] } @@ -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] @@ -88,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/nsis/installer.nsi b/src-tauri/nsis/installer.nsi index c32890f3..ff9b5a9a 100644 --- a/src-tauri/nsis/installer.nsi +++ b/src-tauri/nsis/installer.nsi @@ -8,6 +8,10 @@ ; Pass /WIZARD on the command line to get the classic full wizard back. ; 2. .onInstSuccess launches the app after a fresh interactive install ; (the wizard's "Run" checkbox is never shown in passive mode). +; 3. An update the app applies as it quits (src-tauri/src/updates.rs) sets +; STROKE_UPDATE_ON_QUIT=1 in the environment the installer inherits. +; .onInit then runs it silently and .onInstSuccess skips the /R relaunch, +; because the person just closed the app. Unicode true ManifestDPIAware true ; Add in `dpiAwareness` `PerMonitorV2` to manifest for Windows 10 1607+ (note this should not affect lower versions since they should be able to ignore this and pick up `dpiAware` `true` set by `ManifestDPIAware true`) @@ -76,6 +80,7 @@ ${StrLoc} Var PassiveMode Var UpdateMode +Var QuitUpdate Var NoShortcutMode Var WixMode Var OldMainBinaryName @@ -501,6 +506,15 @@ Function .onInit StrCpy $UpdateMode 1 ${EndIf} + ; Stroke: an update applied as the app quits shows nothing and does not + ; reopen the app. Only honoured together with /UPDATE. + ReadEnvStr $R8 STROKE_UPDATE_ON_QUIT + ${If} $R8 == "1" + ${AndIf} $UpdateMode = 1 + StrCpy $QuitUpdate 1 + SetSilent silent + ${EndIf} + !if "${DISPLAYLANGUAGESELECTOR}" == "true" !insertmacro MUI_LANGDLL_DISPLAY !endif @@ -754,6 +768,10 @@ SectionEnd Function .onInstSuccess ; Check for `/R` flag only in silent and passive installers because ; GUI installer has a toggle for the user to (re)start the app + ; Stroke: an update applied on quit never relaunches the app. + ${If} $QuitUpdate = 1 + Return + ${EndIf} ${If} $PassiveMode = 1 ${OrIf} ${Silent} ${GetOptions} $CMDLINE "/R" $R0 diff --git a/src-tauri/src/db/connection.rs b/src-tauri/src/db/connection.rs index 316dbdbb..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; @@ -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,10 +1193,21 @@ 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}"))?; - let opts = opts.log_slow_statements(LevelFilter::Debug, Duration::from_secs(5)); + // A path to a file that does not exist yet is how a new database is made + // (the form's "New file" picks one); create it rather than refuse. + let opts = opts + .create_if_missing(true) + .log_slow_statements(LevelFilter::Debug, Duration::from_secs(5)); SqlitePoolOptions::new() .max_connections(1) @@ -1327,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; @@ -1417,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) 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..388713f2 --- /dev/null +++ b/src-tauri/src/db/terminal.rs @@ -0,0 +1,1117 @@ +/*! +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::collections::HashMap; +use std::io::{ErrorKind, Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +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, + 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()) +} + +// ── 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::*; + + #[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())); + } + + /// 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..37c4e9fe 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -25,6 +25,7 @@ mod metrics; mod proc; mod providers; mod secrets; +mod updates; mod web_search; use db::{ActiveConnection, DbState, TunnelState}; @@ -323,7 +324,9 @@ 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()) + .manage(updates::UpdateState::default()) .setup(move |app| { // Load or generate a stable MCP token from the app data directory. app.state::().init_token(app.handle()); @@ -335,6 +338,7 @@ pub fn run() { db::connection::set_data_dir(dir); } db::connection::register_active_conn(std::sync::Arc::clone(&db_conn_for_setup)); + updates::clear_stale(app.handle()); let mut window_builder = tauri::WebviewWindowBuilder::new( app, @@ -494,6 +498,10 @@ pub fn run() { commands::open_new_window, commands::reveal_window, commands::restart_app, + updates::update_check, + updates::update_download, + updates::update_status, + updates::update_restart, commands::toggle_devtools, commands::test_postgres_connection, commands::connect_postgres, @@ -621,6 +629,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, @@ -642,11 +653,21 @@ pub fn run() { ]) .build(tauri::generate_context!()) .expect("error while running tauri application") - .run(|app, event| { + .run(|app, event| match event { + tauri::RunEvent::ExitRequested { + code: Some(tauri::RESTART_EXIT_CODE), + .. + } => updates::note_restart(app), // The last window closed, or the OS is shutting down. Reap the // OmniRoute proxy we spawned, or it survives every app quit. - if let tauri::RunEvent::Exit = event { + tauri::RunEvent::Exit => { 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(); + // Last: on Windows a successful install ends the process here. + updates::apply_on_quit(app); } + _ => {} }); } 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(); } diff --git a/src-tauri/src/updates.rs b/src-tauri/src/updates.rs new file mode 100644 index 00000000..a1802811 --- /dev/null +++ b/src-tauri/src/updates.rs @@ -0,0 +1,380 @@ +/*! +Background updates. + +The updater plugin's JS flow downloads and installs in one step and then +restarts, so an update always interrupts whatever is open. This module splits +that apart so an update can download while Stroke runs and install as it quits, +the way a browser does it: + + - `update_check` asks the release feed for a newer build. + - `update_download` fetches it (the plugin verifies the signature) and + stages the bytes on disk. The AppImage alone is ~100MB, too much to hold + in memory for a whole session. + - `update_status` returns the staged build, so a reloaded webview can pick + it up again. + - `update_restart` installs the staged build now and relaunches. + - `apply_on_quit` installs the staged build from `RunEvent::Exit`, so the + next launch is the new version. + +Installing on quit only happens where it can finish without asking anyone +anything (see `can_apply_on_quit`). Everywhere else the staged build waits for +`update_restart`, which is the old prompt-and-restart flow. +*/ + +use serde::Serialize; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Mutex, MutexGuard}; +use std::time::{Duration, Instant}; +use tauri::ipc::Channel; +use tauri::{AppHandle, Manager, State}; +use tauri_plugin_updater::{Update, UpdaterExt}; + +/// A check that cannot reach GitHub should give up in seconds. Only the check: +/// the download carries no timeout, a 100MB AppImage on a slow line needs one. +const CHECK_TIMEOUT: Duration = Duration::from_secs(15); + +/// Progress events per download are throttled to this. The plugin reports one +/// per HTTP chunk, thousands of them, and each one is an IPC message. +const PROGRESS_INTERVAL: Duration = Duration::from_millis(100); + +/// Read by the NSIS template (src-tauri/nsis/installer.nsi): install silently +/// and do not reopen the app, because the person just closed it. +#[cfg(target_os = "windows")] +const QUIT_UPDATE_ENV: &str = "STROKE_UPDATE_ON_QUIT"; + +// ── Types ────────────────────────────────────────────────────────────────────── + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct UpdateInfo { + pub version: String, + pub current_version: String, + /// Release notes from the feed (the CHANGELOG section for that version). + pub notes: String, + /// Publish date, unix seconds. + pub date: Option, + /// Downloaded and waiting to install. + pub staged: bool, + /// This install can apply a staged build by itself when Stroke quits. + pub apply_on_quit: bool, +} + +#[derive(Clone, Serialize)] +#[serde( + tag = "event", + content = "data", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum DownloadEvent { + Started { content_length: Option }, + Progress { downloaded: u64 }, + Finished, +} + +struct Staged { + update: Update, + path: PathBuf, +} + +#[derive(Default)] +pub struct UpdateState { + /// The last check's result, waiting for `update_download`. + pending: Mutex>, + staged: Mutex>, + /// Set when the exit under way is a restart. A Windows install then keeps + /// the installer's own relaunch instead of suppressing it. + restarting: AtomicBool, +} + +/// A poisoned lock only means another command panicked mid-update; the Option +/// inside is still whole. +fn lock(m: &Mutex) -> MutexGuard<'_, T> { + m.lock().unwrap_or_else(|e| e.into_inner()) +} + +fn info(update: &Update, staged: bool) -> UpdateInfo { + UpdateInfo { + version: update.version.clone(), + current_version: update.current_version.clone(), + notes: update.body.clone().unwrap_or_default(), + date: update.date.map(|d| d.unix_timestamp()), + staged, + apply_on_quit: can_apply_on_quit(), + } +} + +fn staged_info(state: &UpdateState) -> Option { + lock(&state.staged).as_ref().map(|s| info(&s.update, true)) +} + +fn updates_dir(app: &AppHandle) -> Result { + app.path() + .app_cache_dir() + .map(|dir| dir.join("updates")) + .map_err(|e| e.to_string()) +} + +// ── Commands ─────────────────────────────────────────────────────────────────── + +/// Ask the release feed for a newer build. Returns the staged build instead +/// when one is already waiting, so a second check never downloads twice. +#[tauri::command] +pub async fn update_check( + app: AppHandle, + state: State<'_, UpdateState>, +) -> Result, String> { + if let Some(staged) = staged_info(&state) { + return Ok(Some(staged)); + } + let update = app + .updater_builder() + .timeout(CHECK_TIMEOUT) + .build() + .map_err(|e| e.to_string())? + .check() + .await + .map_err(|e| e.to_string())?; + let found = update.as_ref().map(|u| info(u, false)); + *lock(&state.pending) = update; + Ok(found) +} + +/// Download the update from the last check and stage it on disk. +#[tauri::command] +pub async fn update_download( + app: AppHandle, + state: State<'_, UpdateState>, + on_event: Channel, +) -> Result { + if let Some(staged) = staged_info(&state) { + return Ok(staged); + } + let update = lock(&state.pending) + .clone() + .ok_or("No update to download. Check for updates first.")?; + + let mut downloaded: u64 = 0; + let mut started = false; + let mut last_sent: Option = None; + let bytes = update + .download( + |chunk, content_length| { + if !started { + started = true; + let _ = on_event.send(DownloadEvent::Started { content_length }); + } + downloaded += chunk as u64; + if last_sent.map_or(true, |t| t.elapsed() >= PROGRESS_INTERVAL) { + last_sent = Some(Instant::now()); + let _ = on_event.send(DownloadEvent::Progress { downloaded }); + } + }, + || { + let _ = on_event.send(DownloadEvent::Finished); + }, + ) + .await + .map_err(|e| e.to_string())?; + + let dir = updates_dir(&app)?; + // The version comes from the feed but was parsed as semver by the plugin, + // so it is only digits, letters, dots, dashes and plus signs. + let path = dir.join(format!("{}.update", update.version)); + let write_path = path.clone(); + tauri::async_runtime::spawn_blocking(move || { + std::fs::create_dir_all(&dir)?; + std::fs::write(&write_path, bytes) + }) + .await + .map_err(|e| e.to_string())? + .map_err(|e| format!("Could not save the update: {e}"))?; + + let staged = info(&update, true); + *lock(&state.staged) = Some(Staged { update, path }); + *lock(&state.pending) = None; + Ok(staged) +} + +/// The staged build, if there is one. A webview reload loses the frontend's +/// state but not this, so the status bar can still say an update is ready. +#[tauri::command] +pub fn update_status(state: State<'_, UpdateState>) -> Option { + staged_info(&state) +} + +/// Install the staged build now and relaunch. Async so it runs off the main +/// thread: a macOS install that needs admin rights asks through the main +/// thread, and `restart()` from another thread goes through the normal exit. +#[tauri::command] +pub async fn update_restart(app: AppHandle, state: State<'_, UpdateState>) -> Result<(), String> { + let staged = lock(&state.staged) + .take() + .ok_or("No update is ready to install.")?; + let bytes = match std::fs::read(&staged.path) { + Ok(bytes) => bytes, + Err(e) => return Err(format!("The downloaded update is missing: {e}")), + }; + // On Windows this hands over to the installer and exits; the installer + // relaunches the app when it is done (`/R`). + if let Err(e) = staged.update.install(&bytes) { + let msg = e.to_string(); + *lock(&state.staged) = Some(staged); + return Err(msg); + } + let _ = std::fs::remove_file(&staged.path); + app.restart(); +} + +// ── Lifecycle ────────────────────────────────────────────────────────────────── + +/// Delete builds staged by an earlier session. One that was never installed is +/// fetched again by the next check, which also catches a newer release. +pub fn clear_stale(app: &AppHandle) { + let Ok(dir) = updates_dir(app) else { return }; + std::thread::spawn(move || { + let Ok(entries) = std::fs::read_dir(&dir) else { return }; + for entry in entries.flatten() { + let _ = std::fs::remove_file(entry.path()); + } + }); +} + +/// `RunEvent::ExitRequested` with the restart code: the exit that follows is a +/// restart, not a quit. +pub fn note_restart(app: &AppHandle) { + app.state::() + .restarting + .store(true, Ordering::Relaxed); +} + +/// Install a staged build as Stroke quits. Called from `RunEvent::Exit`, on the +/// main thread, after everything else has shut down: on Windows a successful +/// install ends the process. +pub fn apply_on_quit(app: &AppHandle) { + let state = app.state::(); + let Some(staged) = lock(&state.staged).take() else { return }; + if !can_apply_on_quit() { + return; + } + let bytes = match std::fs::read(&staged.path) { + Ok(bytes) => bytes, + Err(e) => { + log::warn!("[updater] staged update unreadable, skipping install: {e}"); + return; + } + }; + + #[cfg(target_os = "windows")] + { + // Windows is ending the session: an installer started now would be + // killed halfway through and leave a broken install behind. + if session_ending() { + log::info!("[updater] session ending, leaving the update for next time"); + return; + } + if !state.restarting.load(Ordering::Relaxed) { + std::env::set_var(QUIT_UPDATE_ENV, "1"); + } + } + + log::info!("[updater] installing {} on quit", staged.update.version); + match staged.update.install(&bytes) { + Ok(()) => { + let _ = std::fs::remove_file(&staged.path); + } + Err(e) => log::error!("[updater] install on quit failed: {e}"), + } +} + +// ── Can this install update itself on quit? ──────────────────────────────────── + +/// True when a staged build can be installed at quit with nobody watching: no +/// password prompt, no second copy beside a package manager's, nothing that +/// would need the main thread while it is shutting down. Never in dev builds. +pub fn can_apply_on_quit() -> bool { + !cfg!(debug_assertions) && platform_can_apply() +} + +/// The NSIS install, in the folder it installed to. Scoop unpacks the same +/// setup into its own folder (bundle type still reads NSIS); installing there +/// would add a second copy under %LOCALAPPDATA% and repeat on every quit. The +/// uninstaller is written by the installer itself, so it marks a real install. +#[cfg(target_os = "windows")] +fn platform_can_apply() -> bool { + use tauri::utils::config::BundleType; + if !matches!(tauri::utils::platform::bundle_type(), Some(BundleType::Nsis)) { + return false; + } + let Ok(exe) = tauri::utils::platform::current_exe() else { return false }; + if exe.to_string_lossy().to_lowercase().contains("\\scoop\\") { + return false; + } + exe.parent() + .is_some_and(|dir| dir.join("uninstall.exe").is_file()) +} + +/// The .app bundle, where its folder and the bundle itself are writable. If not +/// (another user's /Applications, a translocated or DMG-mounted copy) the +/// plugin asks for an admin password through the main thread, which deadlocks +/// inside the exit handler. The plugin also swaps the bundle by renaming it +/// through the temp dir, which cannot work across volumes. +#[cfg(target_os = "macos")] +fn platform_can_apply() -> bool { + use std::os::unix::fs::MetadataExt; + let Ok(exe) = tauri::utils::platform::current_exe() else { return false }; + // Stroke.app/Contents/MacOS/stroke + let Some(bundle) = exe.ancestors().nth(3) else { return false }; + if bundle.extension().and_then(|e| e.to_str()) != Some("app") { + return false; + } + let same_volume = match (bundle.metadata(), std::env::temp_dir().metadata()) { + (Ok(app), Ok(tmp)) => app.dev() == tmp.dev(), + _ => false, + }; + same_volume && bundle.parent().is_some_and(writable) && writable(bundle) +} + +/// The AppImage only. A .deb or .rpm installs through pkexec, which asks for a +/// password, and latest.json ships only the AppImage for Linux anyway. +#[cfg(target_os = "linux")] +fn platform_can_apply() -> bool { + use tauri::utils::config::BundleType; + if !matches!( + tauri::utils::platform::bundle_type(), + Some(BundleType::AppImage) + ) { + return false; + } + let Some(appimage) = std::env::var_os("APPIMAGE") else { return false }; + std::path::Path::new(&appimage).parent().is_some_and(writable) +} + +#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] +fn platform_can_apply() -> bool { + false +} + +/// Write access as the OS would grant it, checked without writing anything: +/// the macOS probe runs against a signed bundle. +#[cfg(unix)] +fn writable(path: &std::path::Path) -> bool { + use std::os::unix::ffi::OsStrExt; + let Ok(c) = std::ffi::CString::new(path.as_os_str().as_bytes()) else { + return false; + }; + // SAFETY: `c` is a valid NUL-terminated string that outlives the call. + unsafe { libc::access(c.as_ptr(), libc::W_OK) == 0 } +} + +#[cfg(target_os = "windows")] +fn session_ending() -> bool { + #[link(name = "user32")] + extern "system" { + fn GetSystemMetrics(index: i32) -> i32; + } + const SM_SHUTTINGDOWN: i32 = 0x2000; + // SAFETY: GetSystemMetrics takes a plain index and has no preconditions. + unsafe { GetSystemMetrics(SM_SHUTTINGDOWN) != 0 } +} 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/api.js b/src/lib/api.js index 58b75fa5..90ca54ad 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -1326,6 +1326,63 @@ 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. + +/** + * @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 ─────────────────────────────────────────────────────────────── /** @@ -1489,3 +1546,43 @@ async function blobToBase64(blob) { } return btoa(binary) } + +// ── Updates ────────────────────────────────────────────────────────────────── +// Rust stages the download on disk and installs it as the app quits where the +// install can finish on its own (src-tauri/src/updates.rs). + +/** + * @typedef {{ version: string, currentVersion: string, notes: string, date: number | null, staged: boolean, applyOnQuit: boolean }} UpdateInfo + * @typedef {{ event: 'started', data: { contentLength: number | null } } | { event: 'progress', data: { downloaded: number } } | { event: 'finished' }} UpdateDownloadEvent + */ + +/** + * Ask the release feed for a newer build. Returns the downloaded one when an + * update is already waiting, so a second check never downloads twice. + * @returns {Promise} + */ +export async function updateCheck() { + return inv('update_check') +} + +/** + * Download the update from the last check and stage it for install. + * @param {(event: UpdateDownloadEvent) => void} onEvent + * @returns {Promise} + */ +export async function updateDownload(onEvent) { + return inv('update_download', { onEvent: new Channel(onEvent) }) +} + +/** + * The downloaded update waiting to install, if any. + * @returns {Promise} + */ +export async function updateStatus() { + return inv('update_status') +} + +/** Install the downloaded update now and relaunch. Does not return on success. */ +export async function updateRestart() { + return inv('update_restart') +} diff --git a/src/lib/changelog.js b/src/lib/changelog.js new file mode 100644 index 00000000..ff877ac5 --- /dev/null +++ b/src/lib/changelog.js @@ -0,0 +1,159 @@ +/** + * Release notes for the update dialog and the What's new dialog. + * + * CHANGELOG.md and the notes in latest.json share one shape (the release + * workflow writes both from .changeset/*.md): `## [x.y.z] - date` per version, + * then `### New Features` / `### Bug Fixes` / `### Changes`, optional + * `#### Area` sub-sections, and `-` bullets. + */ + +/** @typedef {'feature'|'fix'|'change'|'other'} ChangeType */ +/** + * `group` is the `###` heading a section sits under; for a `###` heading with + * bullets of its own it equals `title`. + * @typedef {{ title: string, group: string, type: ChangeType, items: string[] }} ChangelogSection + */ +/** @typedef {{ version: string, date: string, sections: ChangelogSection[] }} ChangelogRelease */ + +/** + * Parse one version's notes into typed sections. Sub-sections inherit their + * parent's type, so "Canvas Table" under "New Features" is a feature. + * @param {string} markdown + * @returns {ChangelogSection[]} + */ +export function parseChangelog(markdown) { + if (!markdown?.trim()) return [] + /** @type {ChangeType} */ + let currentType = 'other' + let currentGroup = '' + /** @type {ChangelogSection | null} */ + let current = null + /** @type {ChangelogSection[]} */ + const sections = [] + + for (const line of markdown.split('\n')) { + const trimmed = line.trim() + + if (/^#{1,3}\s/.test(trimmed)) { + // Top-level section (## or ###) - determines the type inherited by sub-sections + const title = trimmed.replace(/^#+\s*/, '').trim() + const lower = title.toLowerCase() + currentType = + /feat|feature|add|new|what.?s new|✨|🚀|⭐|🆕/.test(lower) ? 'feature' + : /fix|bug|patch|issue|🐛|🔧|🩹/.test(lower) ? 'fix' + : /change|improve|update|refactor|perf|♻️|💄|🔄|⚡/.test(lower) ? 'change' + : 'other' + currentGroup = title + current = { title, group: title, type: currentType, items: [] } + sections.push(current) + + } else if (/^#{4,}\s/.test(trimmed)) { + // Sub-section (#### Category) - inherits parent type + const title = trimmed.replace(/^#+\s*/, '').trim() + current = { title, group: currentGroup || title, type: currentType, items: [] } + sections.push(current) + + } else if (/^[-*•]\s/.test(trimmed)) { + if (!current) { + current = { title: 'Changes', group: 'Changes', type: 'other', items: [] } + sections.push(current) + } + const item = trimmed + .replace(/^[-*•]\s*/, '') + .replace(/\s*\(#\d+\)\s*$/, '') + .trim() + if (item) current.items.push(item) + + } else if (trimmed && current && current.items.length === 0 && !/^#+/.test(trimmed) && !/^([-*_])\1{2,}$/.test(trimmed)) { + // Plain text before any bullet - treat as a description item + current.items.push(trimmed) + } + } + + return sections.filter((s) => s.items.length > 0) +} + +/** + * `x.y.z` with an optional `-pre` tag, or null. A leading `v` is allowed. + * @param {string} v + */ +function parseVersion(v) { + const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(String(v ?? '').trim()) + return m ? { nums: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] ?? '' } : null +} + +/** + * Semver order: negative when a < b, 0 when equal, positive when a > b, and + * NaN when either is not a version. + * @param {string} a @param {string} b + */ +export function compareVersions(a, b) { + const pa = parseVersion(a) + const pb = parseVersion(b) + if (!pa || !pb) return NaN + for (let i = 0; i < 3; i++) { + if (pa.nums[i] !== pb.nums[i]) return pa.nums[i] - pb.nums[i] + } + // A release sorts after its own pre-releases. + if (pa.pre === pb.pre) return 0 + if (!pa.pre) return 1 + if (!pb.pre) return -1 + return pa.pre.localeCompare(pb.pre, undefined, { numeric: true }) +} + +/** + * Split CHANGELOG.md into releases, in file order. + * @param {string} markdown + * @returns {ChangelogRelease[]} + */ +export function parseReleases(markdown) { + /** @type {{ version: string, date: string, lines: string[] }[]} */ + const raw = [] + for (const line of String(markdown ?? '').split('\n')) { + const head = /^##\s+\[([^\]]+)\](?:\s*-\s*(.+))?\s*$/.exec(line.trim()) + if (head) { + raw.push({ version: head[1].trim(), date: (head[2] ?? '').trim(), lines: [] }) + } else if (raw.length && !/^##\s/.test(line.trim())) { + raw[raw.length - 1].lines.push(line) + } + } + return raw.map((r) => ({ version: r.version, date: r.date, sections: parseChangelog(r.lines.join('\n')) })) +} + +/** + * The releases someone skipped over by updating from `previous` to `current`: + * every version in (previous, current], newest first, up to `limit` of them. + * `omitted` counts the older ones left out. + * @param {string} markdown CHANGELOG.md + * @param {string} previous @param {string} current + * @param {number} [limit] + * @returns {{ releases: ChangelogRelease[], omitted: number }} + */ +export function releasesBetween(markdown, previous, current, limit = 5) { + if (!(compareVersions(current, previous) > 0)) return { releases: [], omitted: 0 } + const inRange = parseReleases(markdown) + .filter((r) => r.sections.length > 0) + .filter((r) => compareVersions(r.version, previous) > 0 && compareVersions(r.version, current) <= 0) + .sort((a, b) => compareVersions(b.version, a.version)) + return { releases: inRange.slice(0, limit), omitted: Math.max(0, inRange.length - limit) } +} + +/** + * Split a changelog line on backticks so `code` can render as a code span + * without handing the text to {@html}. An unclosed backtick stays literal. + * @param {string} text + * @returns {{ text: string, code: boolean }[]} + */ +export function splitInlineCode(text) { + /** @type {{ text: string, code: boolean }[]} */ + const parts = [] + const re = /`([^`]+)`/g + let last = 0 + for (let m = re.exec(text); m; m = re.exec(text)) { + if (m.index > last) parts.push({ text: text.slice(last, m.index), code: false }) + parts.push({ text: m[1], code: true }) + last = m.index + m[0].length + } + if (last < text.length) parts.push({ text: text.slice(last), code: false }) + return parts +} diff --git a/src/lib/changelog.test.js b/src/lib/changelog.test.js new file mode 100644 index 00000000..ae54d719 --- /dev/null +++ b/src/lib/changelog.test.js @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest' +import { + compareVersions, + parseChangelog, + parseReleases, + releasesBetween, + splitInlineCode, +} from './changelog.js' + +const CHANGELOG = `# Changelog + +All notable changes to Stroke are listed here, newest first. + +--- + +## [2.3.0] - 2026-10-20 + +### New Features + +#### Sidebar +- Objects tab (#101) +- Sort tables by creation time + +### Bug Fixes +- \`Esc\` ends an AI reply + +## [2.2.5] - 2026-10-10 + +### Changes +- Faster startup + +## [2.2.4] - 2026-10-05 + +### Bug Fixes +- Shortcuts fire once + +## [2.2.3] - 2026-10-01 + +### Bug Fixes +- Older fix +` + +describe('parseChangelog', () => { + it('types sub-sections by their parent and keeps the group', () => { + const sections = parseChangelog('### New Features\n\n#### Sidebar\n- One\n\n### Bug Fixes\n- Two (#12)\n') + expect(sections).toEqual([ + { title: 'Sidebar', group: 'New Features', type: 'feature', items: ['One'] }, + { title: 'Bug Fixes', group: 'Bug Fixes', type: 'fix', items: ['Two'] }, + ]) + }) + + it('ignores horizontal rules', () => { + expect(parseChangelog('### Changes\n---\n- Real item')).toEqual([ + { title: 'Changes', group: 'Changes', type: 'change', items: ['Real item'] }, + ]) + }) + + it('returns nothing for empty notes', () => { + expect(parseChangelog('')).toEqual([]) + }) +}) + +describe('compareVersions', () => { + it('orders by number, not by string', () => { + expect(compareVersions('2.10.0', '2.9.9')).toBeGreaterThan(0) + expect(compareVersions('v2.2.4', '2.2.4')).toBe(0) + }) + + it('puts a release after its pre-releases', () => { + expect(compareVersions('2.3.0', '2.3.0-beta.1')).toBeGreaterThan(0) + expect(compareVersions('2.3.0-beta.2', '2.3.0-beta.10')).toBeLessThan(0) + }) + + it('is NaN for something that is not a version', () => { + expect(compareVersions('dev', '2.2.4')).toBeNaN() + }) +}) + +describe('parseReleases', () => { + it('splits the file per version with its date', () => { + const releases = parseReleases(CHANGELOG) + expect(releases.map((r) => r.version)).toEqual(['2.3.0', '2.2.5', '2.2.4', '2.2.3']) + expect(releases[0].date).toBe('2026-10-20') + expect(releases[0].sections.map((s) => s.title)).toEqual(['Sidebar', 'Bug Fixes']) + }) +}) + +describe('releasesBetween', () => { + it('returns (previous, current], newest first', () => { + const { releases, omitted } = releasesBetween(CHANGELOG, '2.2.4', '2.3.0') + expect(releases.map((r) => r.version)).toEqual(['2.3.0', '2.2.5']) + expect(omitted).toBe(0) + }) + + it('caps the list and counts what it left out', () => { + const { releases, omitted } = releasesBetween(CHANGELOG, '2.0.0', '2.3.0', 2) + expect(releases.map((r) => r.version)).toEqual(['2.3.0', '2.2.5']) + expect(omitted).toBe(2) + }) + + it('is empty for a downgrade, the same version or an unknown one', () => { + expect(releasesBetween(CHANGELOG, '2.3.0', '2.2.4').releases).toEqual([]) + expect(releasesBetween(CHANGELOG, '2.3.0', '2.3.0').releases).toEqual([]) + expect(releasesBetween(CHANGELOG, 'dev', '2.3.0').releases).toEqual([]) + }) +}) + +describe('splitInlineCode', () => { + it('marks backtick spans as code', () => { + expect(splitInlineCode('Press `Esc` to stop')).toEqual([ + { text: 'Press ', code: false }, + { text: 'Esc', code: true }, + { text: ' to stop', code: false }, + ]) + }) + + it('leaves an unclosed backtick alone', () => { + expect(splitInlineCode('a ` b')).toEqual([{ text: 'a ` b', code: false }]) + }) +}) 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' }, + ]) +} diff --git a/src/lib/cm-sql-complete.js b/src/lib/cm-sql-complete.js index 56833093..2487dbd9 100644 --- a/src/lib/cm-sql-complete.js +++ b/src/lib/cm-sql-complete.js @@ -10,6 +10,11 @@ * sql-complete-context.js reads the caret position with a tokenizer, so names * complete inside quotes and nothing pops up inside strings or comments. * + * The grammar layer (followAt in sql-complete-context.js) adds what follows + * at each point as whole phrases: DROP TABLE | offers IF EXISTS, IF | offers + * EXISTS, ALTER TABLE t | its actions, ORDER | BY. Where the next word is + * certain the list opens after a space by itself. + * * Cost per keystroke: the candidates for a position are built once (analysis, * tiers, one object per option) and kept while only the word under the caret * changes; each keystroke then just filters and sorts them. @@ -109,6 +114,25 @@ function applyName(view, c, from, to) { if (c._reopen) setTimeout(() => startCompletion(view)) } +/** + * Take a phrase that names follow (IF EXISTS, DROP COLUMN, LEFT JOIN): write + * it with a space after it and open the list again at the names. + * @param {EditorView} view @param {Completion} c @param {number} from @param {number} to + */ +function applyPhrase(view, c, from, to) { + const spaced = view.state.sliceDoc(to, to + 1) === ' ' + view.dispatch({ + changes: { from, to, insert: spaced ? c.label : `${c.label} ` }, + selection: { anchor: from + c.label.length + 1 }, + annotations: pickedCompletion.of(c), + userEvent: 'input.complete', + }) + setTimeout(() => startCompletion(view)) +} + +/** A phrase written in lower case when that is how the statement is typed. @param {string} prefix @param {boolean} statementLower */ +const phraseLower = (prefix, statementLower) => (prefix ? /[a-z]/.test(prefix) && prefix === prefix.toLowerCase() : statementLower) + // ── Templates ──────────────────────────────────────────────────────────────── /** Monaco snippet syntax → CodeMirror's: `$0` (final caret) becomes `${}`. */ @@ -386,6 +410,21 @@ function buildCandidates(ctx, H, S, dialect, statement, rowTable) { } } + // What the grammar says comes next, first: whole phrases, in the case being typed. + const family = sqlFamily(dialect) + const lower = phraseLower(ctx.prefix, ctx.lower) + /** @type {Set} */ + const phraseWords = new Set() + /** First words of the phrases (IF of IF EXISTS): not offered alone unless they stand alone here. */ + const phraseStarts = new Set() + for (const ph of ctx.phrases) { + if (ph.only && !ph.only.includes(family)) continue + const label = lower ? ph.text.toLowerCase() : ph.text + phraseWords.add(ph.text) + if (ph.text.includes(' ')) phraseStarts.add(ph.text.slice(0, ph.text.indexOf(' '))) + add(ph.reopen ? { label, type: 'keyword', apply: applyPhrase } : { label, type: 'keyword' }, NEXT_BOOST) + } + const nextFirst = ctx.kind === 'keywords' || ctx.kind === 'statement' || ctx.kind === 'ddl' || ctx.kind === 'types' || ctx.afterExpr // DDL's own words (COLUMN, RENAME, TEMP ...) have no place in a query's clauses. const ddlWords = ctx.kind === 'statement' || ctx.kind === 'ddl' || ctx.kind === 'types' @@ -397,6 +436,8 @@ function buildCandidates(ctx, H, S, dialect, statement, rowTable) { function keywords(tier, only, besideFunctions = false) { const next = new Set(nextFirst ? ctx.next : []) for (const k of S.keywords) { + if (phraseWords.has(k.label)) continue + if (phraseStarts.has(k.label) && !next.has(k.label) && !ctx.next.includes(k.label)) continue if (!ddlWords && DDL_KEYWORD_SET.has(k.label) && !next.has(k.label)) continue if (besideFunctions && S.functionNames.has(k.label.toLowerCase())) continue if (next.has(k.label)) { add(k, NEXT_BOOST); continue } @@ -405,7 +446,15 @@ function buildCandidates(ctx, H, S, dialect, statement, rowTable) { } } - if (ctx.kind === 'qualified') { + if (ctx.columnsOf && ctx.kind !== 'qualified') { + // ALTER TABLE t DROP COLUMN |, INSERT INTO t (|: that table's columns only. + for (const c of wantColumnsOf(ctx.columnsOf.toLowerCase())) columnOption(c, 0) + if (typed && !ctx.quote) keywords(3, null) + } else if (ctx.names === 'schemas' && !ctx.quote) { + // DROP SCHEMA |: the schemas themselves. + for (const sc of H.schemas) add(/** @type {Completion} */ (name({ label: sc, type: 'schema', detail: 'schema' })), TIER[0]) + if (typed) keywords(3, null) + } else if (ctx.kind === 'qualified') { // After a dot: schema → its tables, table or alias → its columns. const left = /** @type {string} */ (ctx.qualifier).toLowerCase() if (H.schemas.some((s) => s.toLowerCase() === left)) { @@ -427,14 +476,18 @@ function buildCandidates(ctx, H, S, dialect, statement, rowTable) { for (const s of H.schemas) schemaOption(s, 1) if (typed) keywords(2, null) // `FROM (SEL` → SELECT } else if (ctx.kind === 'keywords') { - // Past the table name: the clause keywords. - keywords(0, typed ? null : TABLE_CTX_KWS) - for (const t of H.tables) tableOption(t, 7) + // Past the table name: the clause keywords. Opened by itself (the next + // word is certain), only what follows. + keywords(0, typed ? null : ctx.eager ? new Set() : TABLE_CTX_KWS) + if (typed || !ctx.eager) for (const t of H.tables) tableOption(t, 7) } else if (ctx.kind === 'types') { // A column definition, ALTER ... TYPE, CAST(x AS ...), x::... keywords(0, new Set(ctx.next)) - for (const t of typedInCapitals(ctx.prefix) ? S.typesUpper : S.types) add(t.c, TIER[t.common ? 0 : 1]) - if (sqlFamily(dialect) === 'postgres') for (const e of H.enumTypes) add(e, TIER[1]) + // ALTER COLUMN c |: a bare type only on SQL Server; elsewhere TYPE comes first. + if (!ctx.typesFor || ctx.typesFor.includes(family)) { + for (const t of typedInCapitals(ctx.prefix) ? S.typesUpper : S.types) add(t.c, TIER[t.common ? 0 : 1]) + if (family === 'postgres') for (const e of H.enumTypes) add(e, TIER[1]) + } } else if (ctx.kind === 'ddl') { // Only the statement's own words go here: a new name is not one to pick. keywords(3, typed ? null : new Set(ctx.next)) @@ -508,7 +561,7 @@ export function sqlCompletionSource(getHints, getDialect) { // snippet field stays quiet until something is typed over it (`*` and // `100` are often kept as they are). Ctrl+Space always opens. const afterCast = ctx.kind === 'types' && state.sliceDoc(pos - 2, pos) === '::' - if (!context.explicit && !ctx.prefix && !ctx.quote && ctx.kind !== 'qualified' && !afterCast) return null + if (!context.explicit && !ctx.prefix && !ctx.quote && ctx.kind !== 'qualified' && !afterCast && !ctx.eager) return null const dialect = getDialect() || 'postgres' const S = staticTemplates(sqlFamily(dialect)) @@ -516,7 +569,8 @@ export function sqlCompletionSource(getHints, getDialect) { // What the candidates depend on: the statement minus the word being typed, // and the shape of the position. Same key → same candidates. const key = [ - ctx.kind, ctx.quote, ctx.qualifier, ctx.afterExpr, ctx.prefix !== '', typedInCapitals(ctx.prefix), dialect, + ctx.kind, ctx.quote, ctx.qualifier, ctx.afterExpr, ctx.prefix !== '', typedInCapitals(ctx.prefix), + phraseLower(ctx.prefix, ctx.lower), dialect, state.sliceDoc(start, wordFrom), state.sliceDoc(to, end), ].join('\u0001') @@ -566,6 +620,8 @@ export function completionIsTypedOut(state, c) { if (n._quote || n._suffix || needsQuote(c.label, n._dialect ?? 'postgres')) return false return word === c.label } + // A one-word phrase typed in full (FROM after DELETE): Enter breaks the line. + if (c.apply === applyPhrase) return !c.label.includes(' ') && word.toLowerCase() === c.label.toLowerCase() if (c.apply) return false // A keyword typed in another case: taking it would only change the case. return word.toLowerCase() === c.label.toLowerCase() diff --git a/src/lib/cm-sql-complete.test.js b/src/lib/cm-sql-complete.test.js index da7dee64..417f609b 100644 --- a/src/lib/cm-sql-complete.test.js +++ b/src/lib/cm-sql-complete.test.js @@ -319,3 +319,103 @@ describe('completion in a trigger', () => { expect(state.selection.ranges.length).toBe(2) }) }) + +describe('grammar: what follows', () => { + it('offers IF EXISTS after DROP TABLE, then EXISTS, then the tables', () => { + expect(complete('DROP TABLE IF')?.labels[0]).toBe('IF EXISTS') + expect(complete('DROP TABLE I')?.labels[0]).toBe('IF EXISTS') + // A space after IF: the next word is certain, so the list opens by itself. + expect(complete('DROP TABLE IF ')?.labels).toEqual(['EXISTS']) + const names = complete('DROP TABLE IF EXISTS ')?.labels ?? [] + expect(names[0]).toBe('users_table') + expect(names).toContain('posts') + const explicit = complete('DROP TABLE ', { explicit: true })?.labels ?? [] + expect(explicit.slice(0, 2)).toEqual(['IF EXISTS', 'users_table']) + }) + + it('writes phrases in the case the statement is typed in', () => { + expect(complete('drop table i')?.labels[0]).toBe('if exists') + expect(complete('drop table if ')?.labels).toEqual(['exists']) + expect(complete('DROP TABLE If')?.labels[0]).toBe('IF EXISTS') + }) + + it('lists what DROP and CREATE make, per engine', () => { + const pg = complete('DROP ')?.labels ?? [] + expect(pg).toEqual(expect.arrayContaining(['TABLE', 'VIEW', 'MATERIALIZED VIEW', 'INDEX', 'SCHEMA', 'FUNCTION', 'TYPE'])) + const lite = completeOn('sqlite', 'DROP ')?.labels ?? [] + expect(lite).toEqual(expect.arrayContaining(['TABLE', 'VIEW', 'INDEX', 'TRIGGER'])) + expect(lite).not.toContain('MATERIALIZED VIEW') + expect(lite).not.toContain('SCHEMA') + expect(complete('CREATE ')?.labels).toEqual(expect.arrayContaining(['TABLE', 'OR REPLACE', 'UNIQUE INDEX', 'EXTENSION'])) + expect(completeOn('mssql', 'CREATE ')?.labels).toContain('OR ALTER') + expect(complete('CREATE OR ')?.labels).toEqual(['REPLACE']) + }) + + it('offers IF NOT EXISTS for a new table, never existing tables', () => { + expect(complete('CREATE TABLE IF')?.labels[0]).toBe('IF NOT EXISTS') + expect(complete('CREATE TABLE IF ')?.labels).toEqual(['NOT EXISTS']) + expect(complete('CREATE TABLE IF NOT ')?.labels).toEqual(['EXISTS']) + expect(complete('CREATE TABLE ', { explicit: true })?.labels).toEqual(['IF NOT EXISTS']) + expect(completeOn('mssql', 'CREATE TABLE ', { explicit: true })).toBeNull() + expect(complete('CREATE INDEX idx_posts ')?.labels).toEqual(['ON']) + expect(complete('CREATE VIEW recent ')?.labels).toEqual(['AS']) + }) + + it('lists ALTER TABLE actions as phrases, and a column\'s changes', () => { + const acts = complete('ALTER TABLE users_table ')?.labels ?? [] + expect(acts).toEqual(expect.arrayContaining(['ADD COLUMN', 'DROP COLUMN', 'ALTER COLUMN', 'RENAME TO', 'RENAME COLUMN', 'ADD CONSTRAINT', 'OWNER TO'])) + expect(completeOn('sqlite', 'ALTER TABLE users_table ')?.labels).not.toContain('ALTER COLUMN') + const col = complete('ALTER TABLE users_table ALTER COLUMN name ')?.labels ?? [] + expect(col.slice(0, 6)).toEqual(['TYPE', 'SET DATA TYPE', 'SET DEFAULT', 'DROP DEFAULT', 'SET NOT NULL', 'DROP NOT NULL']) + expect(complete('ALTER TABLE users_table ALTER COLUMN name SET ')?.labels).toEqual(['DEFAULT', 'NOT NULL', 'DATA TYPE']) + // A bare type follows only on SQL Server, and OWNER / MODIFY only as phrases. + expect(col).not.toContain('text') + expect(completeOn('mssql', 'ALTER TABLE users_table ALTER COLUMN name ')?.labels).toContain('int') + expect(acts).not.toContain('OWNER') + expect(acts).not.toContain('MODIFY') + expect(completeOn('mysql', 'ALTER TABLE users_table ')?.labels).toContain('MODIFY COLUMN') + }) + + it('offers only that table\'s columns where its columns go', () => { + const drop = complete('ALTER TABLE users_table DROP COLUMN ')?.labels ?? [] + expect(drop).toEqual(['IF EXISTS', 'id', 'name', 'createdAt']) + expect(complete('ALTER TABLE users_table RENAME COLUMN ')?.labels).toEqual(['id', 'name', 'createdAt']) + expect(complete('ALTER TABLE users_table RENAME COLUMN name ')?.labels).toEqual(['TO']) + expect(complete('INSERT INTO users_table (')?.labels).toEqual(['id', 'name', 'createdAt']) + expect(complete('INSERT INTO users_table (id, ')?.labels).toEqual(['id', 'name', 'createdAt']) + expect(complete('INSERT INTO posts (id) VALUES (1) ON CONFLICT (id) DO UPDATE SET ')?.labels).toEqual(['id', 'title', 'author_id']) + }) + + it('reads the pairs of queries and writes', () => { + expect(complete('SELECT * FROM posts ORDER ')?.labels).toEqual(['BY']) + expect(complete('SELECT * FROM posts p LEFT ')?.labels).toEqual(['JOIN', 'OUTER JOIN']) + expect(complete('SELECT * FROM posts WHERE title IS ')?.labels).toEqual(['NULL', 'NOT NULL', 'DISTINCT FROM', 'TRUE', 'FALSE']) + expect(complete('SELECT * FROM posts WHERE title IS NOT ')?.labels).toEqual(['NULL', 'DISTINCT FROM', 'TRUE', 'FALSE']) + expect(complete('DELETE ')?.labels).toEqual(['FROM']) + expect(complete('INSERT ')?.labels).toEqual(['INTO']) + expect(completeOn('mysql', 'INSERT ')?.labels).toEqual(['INTO', 'IGNORE INTO']) + expect(complete('INSERT INTO posts (id) VALUES (1) ON ')?.labels).toEqual(['CONFLICT']) + expect(completeOn('mysql', 'INSERT INTO posts (id) VALUES (1) ON ')?.labels).toEqual(['DUPLICATE KEY UPDATE']) + expect(complete('INSERT INTO posts (id) VALUES (1) ON CONFLICT ')?.labels).toEqual(['DO NOTHING', 'DO UPDATE SET', 'ON CONSTRAINT']) + expect(complete('SELECT * FROM posts JOIN post_tags t ', { explicit: true })?.labels.slice(0, 2)).toEqual(['ON', 'USING']) + expect(complete('UPDATE users_table ')?.labels).toEqual(['SET']) + }) + + it('keeps quiet after a space where the next word is open', () => { + expect(complete('SELECT * FROM posts WHERE ')).toBeNull() + expect(complete('CREATE TABLE ')).toBeNull() + expect(complete('SELECT id ')).toBeNull() + }) + + it('writes a phrase that names follow with a space, ready for the names', () => { + const r = completeOn('postgres', 'DROP TABLE I') + expect(accept(r, 'IF EXISTS')).toBe('DROP TABLE IF EXISTS ') + }) + + it('lets Enter break the line on a one-word phrase typed out', () => { + const doc = 'DELETE FROM' + const state = EditorState.create({ doc, selection: { anchor: doc.length }, extensions: [sql({ dialect: PostgreSQL })] }) + const from = complete(doc)?.options.find((o) => o.label === 'FROM') + expect(from && completionIsTypedOut(state, from)).toBe(true) + }) +}) 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 diff --git a/src/lib/components/CommandPalette.svelte b/src/lib/components/CommandPalette.svelte index 787654e1..c192b6e4 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' }, @@ -629,7 +631,7 @@ {#snippet pageRow(/** @type {typeof pageItems[number]} */ it)} run(it.action)}> - + {it.label} {#if it.keys}{/if} @@ -740,7 +742,7 @@ {#each schemas as schema (schema)} run(() => onschemachange(schema))}> - + {schema} {#if schema === activeSchema} current @@ -754,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 @@ -778,12 +780,12 @@ run(onopenai)}> - + Ask AI run(onopenaisidebar)}> - + AI sidebar @@ -791,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 @@ -807,7 +809,7 @@ {#each savedQueries as entry (entry.id)} run(() => onqueryselect(entry.sql))}> - + {entry.name} {/each} @@ -818,7 +820,7 @@ {#each queryHistory.slice(0, 20) as entry (entry.id)} run(() => onqueryselect(entry.sql))}> - + {entry.title} {/each} @@ -827,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 @@ -877,7 +879,7 @@ value="switch database connection {conn.name} {connSubtitle(conn)} {conn.type}" onSelect={() => run(() => onswitchdatabase(conn))} > - +
{conn.name} {connSubtitle(conn)} @@ -896,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} @@ -908,7 +910,7 @@ navigate('docker')}> - + Docker @@ -920,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} @@ -955,7 +957,7 @@ {#each tablesPageMatViews.items as table (table.name)} run(() => ontableselect(table.name))}> - + {table.name} run(() => ondockerlaunch('postgres'))} > - + PostgreSQL container :5433 @@ -986,7 +988,7 @@ value="launch mysql container pull run 3307" onSelect={() => run(() => ondockerlaunch('mysql'))} > - + MySQL container :3307 @@ -1004,7 +1006,7 @@ onSelect={() => run(() => onswitchdatabase(conn))} disabled={isActive} > - +
{conn.name} {connSubtitle(conn)} @@ -1018,7 +1020,7 @@ {/if} run(onopenconnection)}> - + New connection… diff --git a/src/lib/components/ConnectionModal.svelte b/src/lib/components/ConnectionModal.svelte index 5be967ec..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. @@ -2433,6 +2435,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 +3988,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 +4160,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."}

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} +
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} diff --git a/src/lib/components/SettingsDialog.svelte b/src/lib/components/SettingsDialog.svelte index 8f985041..89fa1d4a 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;", @@ -332,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))); @@ -452,6 +462,9 @@ function toggleAgentWebAccess() { settings = updateSettings({ agentWebAccess: !settings.agentWebAccess }); } + function toggleAutoUpdate() { + settings = updateSettings({ autoUpdate: !settings.autoUpdate }); + } function toggleTelemetry() { settings = updateSettings({ telemetry: !settings.telemetry }); // Takes effect immediately rather than at next launch: a privacy switch @@ -771,6 +784,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} + {@render secLabel('Privacy')} @@ -1232,6 +1264,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')}
diff --git a/src/lib/components/SqlConsole.svelte b/src/lib/components/SqlConsole.svelte index 167666ae..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. */ @@ -677,6 +683,25 @@ let saveQueryName = $state(''); let savingQuery = $state(false); + /** @param {string} undoId */ + async function revertFromLens(undoId) { + 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 @@ -1200,6 +1225,7 @@ onmodj={toggleOutput} onmodshiftb={() => { toggleHistory(); onmodshiftb?.() }} onlens={onStatementAction} + onrevert={onrevertrun ? (id) => void revertFromLens(id) : undefined} onactionsready={(actions) => { formatSql = actions.format; }} @@ -1441,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} 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?.() } 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} +
+
diff --git a/src/lib/components/StatusBar.svelte b/src/lib/components/StatusBar.svelte index 03c04483..16d55d87 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -40,6 +40,8 @@ activeConnectionId = '', mcpRunning = false, hasUpdate = false, + /** The update is downloaded and waiting: the badge offers the restart. */ + updateReady = false, onopenmcp = /** @type {() => void} */ (() => {}), onconnect = /** @type {() => void} */ (() => {}), onswitchtodb = /** @type {(db: string) => void} */ ((_db) => {}), @@ -890,10 +892,10 @@ type="button" class="flex items-center gap-1 rounded-md px-2 h-6 text-ui-2xs font-medium text-warning transition-colors hover:bg-muted/50 hover:text-warning" onclick={oncheckupdate} - title="Update available" + title={updateReady ? 'Update downloaded. It installs when you quit, or restart now.' : 'Update available'} > - - Update + + {updateReady ? 'Restart to update' : 'Update'} {@render sep()} {/if} diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index 03b84793..45d7e34d 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' @@ -11,10 +12,12 @@ import Boxes from '@lucide/svelte/icons/boxes' import FileCode2 from '@lucide/svelte/icons/file-code-2' import Terminal from '@lucide/svelte/icons/terminal' + import SquareTerminal from '@lucide/svelte/icons/square-terminal' import Sparkles from '@lucide/svelte/icons/sparkles' 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' @@ -31,7 +34,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' @@ -85,6 +88,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' @@ -149,6 +153,8 @@ txStatus, txCommit, txRollback, + inspectSql, + txExecuteQuiet, executeDdl, updateTableCell, fetchCellValue, @@ -172,6 +178,7 @@ findOrmSchemaTab, createSecurityTab, createLogsTab, + createTerminalTab, createInsightsTab, createAdvisorTab, createGolfTab, @@ -210,6 +217,7 @@ findOrmTab, findSecurityTab, findLogsTab, + findTerminalTab, findBackupTab, findJsonTab, findChartsTab, @@ -234,6 +242,7 @@ MAX_PAGE_SIZE, fetchLimitFor, PAGE_SIZE_ALL, + pageWithInsertedRow, DEFAULT_PAGE_SIZE, saveDefaultPageSize, loadDefaultPageSize, @@ -277,7 +286,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 { @@ -503,6 +512,8 @@ /** @type {import('./UpdateDialog.svelte').default | null} */ let updateDialog = $state(null) let statusBarHasUpdate = $state(false) + /** The update is downloaded: the status bar offers the restart. */ + let statusBarUpdateReady = $state(false) let sidebarOpen = $state(loadLayout().navSidebarOpen) /** The nav sidebar was open when a visual page took the width; put it back on leaving. */ let _sidebarBeforeErd = false @@ -736,6 +747,28 @@ 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')) + /** 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) @@ -2617,6 +2650,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() @@ -3699,6 +3753,19 @@ let rowSearch = $state('') openSingletonTab({ find: findObjectsTab, create: createObjectsTab }) } + /** The connection's own CLI in a real terminal. Not pro-gated: it is the + * engine's free client, Stroke only hosts it. */ + function openTerminalTab() { + const existing = findTerminalTab(tabs) + if (existing) { void activateTab(existing.id); return } + saveActiveTabState() + dropWelcomeTabs() + const tab = createTerminalTab() + tabs = [...tabs, tab] + activeTabId = tab.id + clearTableEditor() + } + /** The Redis keyspace workspace. NOT pro-gated - it's the primary (and only) * interface for a Redis connection, so it must open on connect for everyone. */ function openRedisTab() { @@ -6254,6 +6321,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 @@ -6288,6 +6450,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 @@ -6300,6 +6464,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. @@ -6345,15 +6520,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 : '', } }) } @@ -7483,18 +7666,19 @@ 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] - if (rows.length > effectivePageSize) { - rows = rows.slice(0, effectivePageSize) - } + rows = pageWithInsertedRow(rows, row, pageSize, effectivePageSize) total += 1 saveActiveTabState() toast.success('Row inserted') @@ -7503,10 +7687,15 @@ let rowSearch = $state('') // active filter is the database's call), so the count has to be redone. invalidateRowCount() await loadRows() + // In the table's own order a new row comes last. A paged view shows it + // on the last page; one that holds every row (All, windowed) can go + // straight to it, which is where the user is looking for it. + const atEnd = !hasActiveFilters && !rowSort && (pageSize === PAGE_SIZE_ALL || windowed) toast.success('Row inserted', { description: hasActiveFilters ? 'Refresh filters or go to page 1 if the row is not visible' - : undefined, + : atEnd ? 'It is at the end of the table' : undefined, + ...(atEnd ? { action: { label: 'Show', onClick: () => scrollTableBottom?.() } } : {}), }) } } catch (err) { @@ -7966,7 +8155,14 @@ let rowSearch = $state('') - (statusBarHasUpdate = true)} /> + void confirmRevert()} oncancel={() => settleRevert(false)} /> + { + statusBarHasUpdate = true + statusBarUpdateReady = state === 'ready' + }} +/> { 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() }} @@ -8454,6 +8651,21 @@ let rowSearch = $state('')
{/if} + + {#if hasTerminalTab} +
+ + {#await import('./TerminalPage.svelte')}{:then { default: TerminalPage }} + + {/await} + +
+ {/if} + {#if insightsEverOpened}
(showProGate = true)} /> {/await} @@ -9289,40 +9502,32 @@ let rowSearch = $state('') {/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 ? '' : '+')})` : ''} {/snippet} @@ -9352,40 +9557,45 @@ let rowSearch = $state('') footer now all start where the first tile starts. -->
- -
-
- + +
+
+
+ {#if connection} +
+ {welcomeTitle} + {welcomeWhere} +
+ {/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' })}
@@ -9399,13 +9609,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)} @@ -9424,7 +9634,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 · @@ -9525,6 +9735,7 @@ let rowSearch = $state('') onswitchconnection={handleSwitchDatabase} {mcpRunning} hasUpdate={statusBarHasUpdate} + updateReady={statusBarUpdateReady} onopenmcp={() => (showMcpPanel = true)} onconnect={() => (showConnectionModal = true)} onswitchtodb={switchToDb} 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/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} +
diff --git a/src/lib/components/UpdateDialog.svelte b/src/lib/components/UpdateDialog.svelte index 8616118f..cfc81292 100644 --- a/src/lib/components/UpdateDialog.svelte +++ b/src/lib/components/UpdateDialog.svelte @@ -1,7 +1,5 @@ + void openChangelog('whats-new')} +/> + {#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.'}

{heading} 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 @@
- + import { cn } from "$lib/utils.js"; import Kbd from "$lib/components/Kbd.svelte"; + import { keycaps } from "$lib/shortcuts.js"; let { ref = $bindable(null), class: className, @@ -11,10 +12,17 @@ // Split on "+" separators ("Ctrl+Shift+B") or spread Unicode codepoints ("⌘⇧B"). // Unicode spread handles symbols correctly: [...'⌘⇧B'] = ['⌘','⇧','B']. + // Rows write their chords as macOS glyphs (`⌘⇧S`, `⌃\``). Printed as written, + // Linux and Windows showed ⌘ and ⌃ for keys they call Ctrl. Read the glyphs + // as names and let `keycaps` print each platform's own spelling. + const GLYPH_NAME = { '⌘': 'Mod', '⇧': 'Shift', '⌥': 'Alt', '⌃': 'Ctrl', '↵': 'Enter' }; const tokens = $derived( !keys ? [] : - keys.includes('+') ? keys.split('+').filter(Boolean) : - [...keys] + keycaps( + keys.includes('+') + ? keys + : [...keys].map((k) => GLYPH_NAME[k] ?? k.toUpperCase()).join('+') + ) ); 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 }, 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 */ 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' }, diff --git a/src/lib/sql-complete-context.js b/src/lib/sql-complete-context.js index d48a59d2..4e6f1c9e 100644 --- a/src/lib/sql-complete-context.js +++ b/src/lib/sql-complete-context.js @@ -78,6 +78,12 @@ const NEXT = /** @type {Record} */ ({ * verb: string, * rowTable: string | null, * routine: string | null, + * phrases: Phrase[], + * eager: boolean, + * columnsOf: string | null, + * names: 'schemas' | null, + * typesFor: string[] | null, + * lower: boolean, * }} SqlCompletionContext * `predicateColumn`: the column just written in a condition, a space behind * it (`WHERE price |`): an operator comes next. `comparedColumn`: the column @@ -90,6 +96,11 @@ const NEXT = /** @type {Record} */ ({ * CAST(x AS ...), x::...); `next` holds keywords that can stand there too. * `ddl`: only keywords go here, `next` first (a new column's name, the action * after ALTER TABLE name, a column's constraints); no names are offered. + * `phrases`: what the grammar says follows (followAt), offered first; + * `eager`: the list opens after a space by itself, the next word being + * certain. `columnsOf`: only this table's columns are names here. `names`: + * schemas, not tables, are the names here. `lower`: the statement is written + * in lower case (its first keyword is), so phrases are too. * @typedef {{ name: string, qualifier: string | null }} ColumnRef */ @@ -161,6 +172,19 @@ function scan(text) { return { tokens, open: null, head } } +/** The last head scanned, and its tokens: never changed by readers. */ +let headText = /** @type {string | null} */ (null) +let headScan = /** @type {ReturnType} */ (null) + +/** scan() for the text before the word being typed, kept for the next keystroke. @param {string} text */ +function scanHead(text) { + if (text !== headText) { + headText = text + headScan = scan(text) + } + return headScan +} + /** @param {Token | undefined} tok */ const isName = (tok) => tok?.t === 'word' || tok?.t === 'qid' /** @param {Token | undefined} tok */ @@ -429,6 +453,362 @@ function triggerHead(tokens) { return null } +// ── Grammar: what can come next ────────────────────────────────────────────── +// DataGrip-style: at each point of a statement, the words that grammatically +// follow (whole phrases: IF EXISTS, ORDER BY, DO UPDATE SET), and which names. +// Read from the statement's own tokens, mostly its last few, so it costs next +// to nothing per keystroke. + +/** + * A phrase offered as one item. `only`: the engine families that have it. + * `reopen`: taking it writes a space and opens the list again (names follow). + * @typedef {{ text: string, only: string[] | null, reopen: boolean }} Phrase + * @typedef {{ + * phrases: Phrase[], + * eager: boolean, + * only: boolean, + * kind?: SqlCompletionContext['kind'], + * columnsOf?: string, + * names?: 'schemas', + * next?: string[], + * typesFor?: string[], + * }} Follow + * `eager`: what comes next is certain, so the list opens after a space by + * itself. `only`: the phrases are all that fits here (no names, no other + * clauses). `columnsOf`: only this table's columns are names here. `next`: + * the single words that stand alone here, replacing the clause's. `typesFor`: + * the engines where a bare type goes here (SQL Server's ALTER COLUMN c int). + */ + +/** @param {string} text @param {string} [only] space-separated families @param {boolean} [reopen] @returns {Phrase} */ +const P = (text, only, reopen = false) => ({ text, only: only ? only.split(' ') : null, reopen }) + +const PG_LIKE = 'postgres duckdb' +const DROP_OBJECTS = [ + P('TABLE', '', true), P('VIEW', '', true), P('MATERIALIZED VIEW', 'postgres clickhouse', true), P('INDEX', '', true), + P('SCHEMA', 'postgres mysql mssql duckdb', true), P('SEQUENCE', 'postgres mssql duckdb', true), + P('FUNCTION', 'postgres mysql mssql duckdb'), P('PROCEDURE', 'postgres mysql mssql'), P('TRIGGER', 'postgres mysql sqlite mssql'), + P('TYPE', 'postgres mssql duckdb'), P('DATABASE', 'postgres mysql mssql clickhouse'), P('EXTENSION', 'postgres'), +] +const CREATE_OBJECTS = [ + P('TABLE'), P('OR REPLACE', 'postgres mysql clickhouse duckdb'), P('OR ALTER', 'mssql'), P('VIEW'), + P('MATERIALIZED VIEW', 'postgres clickhouse'), P('INDEX'), P('UNIQUE INDEX', 'postgres mysql sqlite mssql duckdb'), + P('SCHEMA', 'postgres mysql mssql duckdb'), P('SEQUENCE', 'postgres mssql duckdb'), P('TYPE', 'postgres mssql duckdb'), + P('TRIGGER', 'postgres mysql sqlite mssql'), P('FUNCTION', 'postgres mysql mssql duckdb'), P('PROCEDURE', 'postgres mysql mssql'), + P('EXTENSION', 'postgres'), P('DATABASE', 'postgres mysql mssql clickhouse'), P('TEMPORARY TABLE', 'postgres mysql sqlite duckdb'), +] +const OR_REPLACE_OBJECTS = [P('VIEW'), P('FUNCTION', PG_LIKE), P('PROCEDURE', 'postgres mssql'), P('TRIGGER', 'postgres mssql'), P('MATERIALIZED VIEW', 'clickhouse')] +const ALTER_OBJECTS = [ + P('TABLE', '', true), P('VIEW', 'postgres mysql mssql'), P('INDEX', 'postgres mssql'), P('SEQUENCE', 'postgres mssql duckdb'), + P('SCHEMA', 'postgres mssql'), P('TYPE', 'postgres'), P('FUNCTION', 'postgres mysql mssql'), P('DATABASE', 'postgres mysql mssql'), + P('MATERIALIZED VIEW', 'postgres'), +] +/** ALTER TABLE name |: the actions, as their usual phrases. */ +const ALTER_TABLE_ACTIONS = [ + P('ADD COLUMN', 'postgres mysql sqlite duckdb clickhouse'), P('DROP COLUMN', '', true), P('ALTER COLUMN', 'postgres mssql duckdb', true), + P('RENAME COLUMN', 'postgres mysql sqlite duckdb clickhouse', true), P('RENAME TO', 'postgres mysql sqlite duckdb clickhouse'), + P('ADD CONSTRAINT', 'postgres mysql mssql duckdb'), P('DROP CONSTRAINT', 'postgres mysql mssql duckdb'), + P('ADD PRIMARY KEY', 'postgres mysql mssql duckdb'), P('ADD FOREIGN KEY', 'postgres mysql mssql'), + P('MODIFY COLUMN', 'mysql clickhouse', true), P('CHANGE COLUMN', 'mysql', true), P('OWNER TO', 'postgres'), + P('SET SCHEMA', 'postgres duckdb'), P('ENABLE TRIGGER', 'postgres mssql'), P('DISABLE TRIGGER', 'postgres mssql'), +] +/** ALTER TABLE t ALTER COLUMN c |. */ +const ALTER_COLUMN_ACTIONS = [ + P('TYPE', 'postgres duckdb'), P('SET DATA TYPE', 'postgres duckdb'), P('SET DEFAULT', 'postgres mysql duckdb'), + P('DROP DEFAULT', 'postgres mysql duckdb'), P('SET NOT NULL', 'postgres duckdb'), P('DROP NOT NULL', 'postgres duckdb'), +] +/** The engines with `IF [NOT] EXISTS` on a given statement. */ +const IF_EXISTS_DROP = '' // every engine +const IF_NOT_EXISTS = { + TABLE: 'postgres mysql sqlite duckdb clickhouse', INDEX: 'postgres sqlite duckdb', SCHEMA: 'postgres mysql duckdb', + SEQUENCE: 'postgres duckdb', VIEW: 'sqlite duckdb clickhouse', 'MATERIALIZED VIEW': 'postgres clickhouse', + DATABASE: 'mysql clickhouse', EXTENSION: 'postgres', TRIGGER: 'mysql sqlite', TYPE: '', +} +/** What a DROP / CREATE / ALTER object word can be, MATERIALIZED VIEW read as one. */ +const OBJECT_WORDS = new Set(['TABLE', 'VIEW', 'INDEX', 'SCHEMA', 'SEQUENCE', 'FUNCTION', 'PROCEDURE', 'TRIGGER', 'TYPE', 'DATABASE', 'EXTENSION']) +/** Objects whose names the hints list: tables (and views, the sidebar lists both), schemas. */ +const TABLE_LIKE = new Set(['TABLE', 'VIEW', 'MATERIALIZED VIEW']) + +/** + * The object word at `i` (MATERIALIZED VIEW as one), and where it ends. + * @param {Token[]} tokens @param {number} i + */ +function objectAt(tokens, i) { + if (kw(tokens[i]) === 'MATERIALIZED' && kw(tokens[i + 1]) === 'VIEW') return { type: 'MATERIALIZED VIEW', end: i + 2 } + const k = kw(tokens[i]) + return OBJECT_WORDS.has(k) ? { type: k, end: i + 1 } : null +} + +/** @param {Phrase[]} phrases @param {Partial} [rest] @returns {Follow} */ +const forced = (phrases, rest = {}) => ({ phrases, eager: true, only: true, ...rest }) +/** @param {Phrase[]} phrases @param {Partial} [rest] @returns {Follow} */ +const offered = (phrases, rest = {}) => ({ phrases, eager: false, only: false, ...rest }) +/** The last part of the name ending at `end` (exclusive). @param {Token[]} tokens @param {number} end */ +const lastName = (tokens, end) => /** @type {Token} */ (tokens[end - 1]).v + +/** DROP … @param {Token[]} tokens @returns {Follow | null} */ +function dropFollow(tokens) { + const n = tokens.length + if (n === 1) return forced(DROP_OBJECTS) + const o = objectAt(tokens, 1) + if (!o) return null + const names = TABLE_LIKE.has(o.type) ? 'tables' : o.type === 'SCHEMA' ? 'schemas' : null + let i = o.end + if (o.type === 'INDEX' && kw(tokens[i]) === 'CONCURRENTLY') i++ + if (n === i) { + const extra = o.type === 'INDEX' && i === o.end ? [P('CONCURRENTLY', 'postgres')] : [] + return offered([P('IF EXISTS', IF_EXISTS_DROP, true), ...extra], names === 'schemas' ? { kind: 'ddl', names } : names ? { kind: 'tables' } : { kind: 'ddl' }) + } + if (kw(tokens[i]) === 'IF') { + if (n === i + 1) return forced([P('EXISTS', '', true)]) + if (kw(tokens[i + 1]) !== 'EXISTS') return null + i += 2 + if (n === i) { + return names === 'schemas' ? { phrases: [], eager: true, only: false, kind: 'ddl', names } + : names ? { phrases: [], eager: true, only: false, kind: 'tables' } : null + } + } + // DROP TABLE a, b CASCADE: after the names. + let j = i + for (;;) { + const e = nameEnd(tokens, j) + if (e < 0) return null + if (e === n) break + if (punct(tokens[e]) !== ',') return null + j = e + 1 + if (j === n) return names === 'tables' ? { phrases: [], eager: true, only: false, kind: 'tables' } : null + } + const after = o.type === 'INDEX' ? [P('ON', 'mysql mssql', true)] : [] + return offered([P('CASCADE', 'postgres duckdb'), P('RESTRICT', 'postgres duckdb'), ...after], { kind: 'ddl' }) +} + +/** CREATE … @param {Token[]} tokens @returns {Follow | null} */ +function createFollow(tokens) { + const n = tokens.length + if (n === 1) return forced(CREATE_OBJECTS) + let i = 1 + if (kw(tokens[i]) === 'OR') { + if (n === 2) return forced([P('REPLACE', 'postgres mysql clickhouse duckdb'), P('ALTER', 'mssql')]) + i = 3 + if (n === 3) return forced(OR_REPLACE_OBJECTS) + } + while (['TEMP', 'TEMPORARY', 'UNLOGGED', 'GLOBAL', 'LOCAL'].includes(kw(tokens[i]))) { + i++ + if (n === i) return forced([P('TABLE'), P('VIEW', 'postgres sqlite duckdb'), P('SEQUENCE', 'postgres')]) + } + if (kw(tokens[i]) === 'UNIQUE') { + i++ + if (n === i) return forced([P('INDEX')]) + } + if (kw(tokens[i]) === 'MATERIALIZED' && n === i + 1) return forced([P('VIEW')]) + const o = objectAt(tokens, i) + if (!o) return null + i = o.end + if (o.type === 'INDEX' && kw(tokens[i]) === 'CONCURRENTLY') i++ + const ifNot = /** @type {Record} */ (IF_NOT_EXISTS)[o.type] + if (n === i) { + // A new name goes here: no existing names, just the words that can come first. + const words = ifNot !== undefined ? [P('IF NOT EXISTS', ifNot)] : [] + if (o.type === 'INDEX') words.push(P('CONCURRENTLY', 'postgres'), P('ON', 'postgres duckdb', true)) + if (o.type === 'SCHEMA') words.push(P('AUTHORIZATION', 'postgres mssql')) + return { phrases: words, eager: false, only: true } + } + if (kw(tokens[i]) === 'IF') { + if (n === i + 1) return forced([P('NOT EXISTS')]) + if (kw(tokens[i + 1]) === 'NOT' && n === i + 2) return forced([P('EXISTS')]) + if (kw(tokens[i + 1]) === 'NOT' && kw(tokens[i + 2]) === 'EXISTS') { + i += 3 + if (n === i) return { phrases: [], eager: false, only: true } + } + } + const e = nameEnd(tokens, i) + if (e !== n) return null + if (o.type === 'INDEX') return forced([P('ON', '', true)]) + if (o.type === 'VIEW' || o.type === 'MATERIALIZED VIEW') return forced([P('AS')]) + return null +} + +/** + * ALTER … (ALTER TABLE's own positions mostly come from alterTable(); this + * adds the phrases and which table's columns go where.) + * @param {Token[]} tokens @returns {Follow | null} + */ +function alterFollow(tokens) { + const n = tokens.length + if (n === 1) return forced(ALTER_OBJECTS) + if (kw(tokens[1]) !== 'TABLE') return null + let i = 2 + if (n === 2) return offered([P('IF EXISTS', 'postgres mssql duckdb', true), P('ONLY', 'postgres', true)], { kind: 'tables' }) + if (kw(tokens[i]) === 'IF') { + if (n === 3) return forced([P('EXISTS', '', true)]) + if (kw(tokens[3]) !== 'EXISTS') return null + i = 4 + if (n === 4) return { phrases: [P('ONLY', 'postgres', true)], eager: true, only: false, kind: 'tables' } + } + if (kw(tokens[i]) === 'ONLY') { + i++ + if (n === i) return { phrases: [], eager: true, only: false, kind: 'tables' } + } + const end = nameEnd(tokens, i) + if (end < 0) return null + const table = lastName(tokens, end) + const act = listEntry(tokens, end) + // Bare ADD / DROP / ALTER / RENAME take a column straight after; OWNER, + // MODIFY and CHANGE only come as their phrases. + if (!act.length) return { phrases: ALTER_TABLE_ACTIONS, eager: true, only: false, next: ['ADD', 'DROP', 'ALTER', 'RENAME', 'SET'] } + const verb = kw(act[0]) + const said = kw(act[1]) + const cols = { kind: /** @type {const} */ ('columns'), columnsOf: table } + if (verb === 'ADD') { + if (act.length === 1) return offered([P('COLUMN'), P('CONSTRAINT'), P('PRIMARY KEY'), P('FOREIGN KEY'), P('UNIQUE'), P('CHECK')]) + if (said === 'COLUMN' && act.length === 2) return offered([P('IF NOT EXISTS', 'postgres duckdb')]) + if (said === 'COLUMN' && kw(act[2]) === 'IF') { + if (act.length === 3) return forced([P('NOT EXISTS')]) + if (act.length === 4 && kw(act[3]) === 'NOT') return forced([P('EXISTS')]) + } + return null + } + if (verb === 'DROP') { + if (act.length === 1) return { phrases: [P('COLUMN', '', true), P('CONSTRAINT'), P('IF EXISTS', 'postgres duckdb', true)], eager: true, only: false, ...cols } + let k = said === 'COLUMN' ? 2 : 1 + if (kw(act[k]) === 'IF') { + if (act.length === k + 1) return forced([P('EXISTS', '', true)]) + if (kw(act[k + 1]) !== 'EXISTS') return null + k += 2 + } + if (said === 'CONSTRAINT') return act.length === 2 ? offered([P('IF EXISTS', 'postgres mssql duckdb')], { kind: 'ddl' }) : null + if (act.length === k) return { phrases: k === 2 && said === 'COLUMN' ? [P('IF EXISTS', 'postgres mssql duckdb', true)] : [], eager: true, only: false, ...cols } + if (act.length === k + 1 && isName(act[k])) return offered([P('CASCADE', 'postgres duckdb'), P('RESTRICT', 'postgres duckdb')], { kind: 'ddl' }) + return null + } + if (verb === 'RENAME') { + if (act.length === 1) return { phrases: [P('TO'), P('COLUMN', '', true), P('CONSTRAINT', 'postgres')], eager: true, only: false, ...cols } + if (said === 'TO') return act.length === 2 ? { phrases: [], eager: false, only: true } : null + const k = said === 'COLUMN' ? 2 : 1 + if (act.length === k) return { phrases: [], eager: true, only: false, ...cols } + if (act.length === k + 1 && isName(act[k])) return forced([P('TO')]) + if (act.length === k + 2 && kw(act[k + 1]) === 'TO') return { phrases: [], eager: false, only: true } + return null + } + if (verb === 'ALTER' || verb === 'MODIFY' || verb === 'CHANGE') { + if (act.length === 1) return { phrases: [P('COLUMN', '', true)], eager: true, only: false, ...cols } + const k = said === 'COLUMN' ? 2 : 1 + if (act.length === k) return { phrases: [], eager: true, only: false, ...cols } + if (verb !== 'ALTER') return null + if (act.length === k + 1) return { phrases: ALTER_COLUMN_ACTIONS, eager: true, only: false, next: ['TYPE'], typesFor: ['mssql'] } + const last = kw(act.at(-1)) + const before = kw(act.at(-2)) + if (act.length === k + 2 && last === 'SET') return forced([P('DEFAULT'), P('NOT NULL'), P('DATA TYPE')]) + if (act.length === k + 2 && last === 'DROP') return forced([P('DEFAULT'), P('NOT NULL')]) + if (last === 'NOT' && (before === 'SET' || before === 'DROP')) return forced([P('NULL')]) + if (last === 'DATA' && before === 'SET') return forced([P('TYPE')]) + return null + } + if (verb === 'OWNER' && act.length === 1) return forced([P('TO')]) + if (verb === 'SET' && act.length === 1) return offered([P('SCHEMA', 'postgres duckdb'), P('TABLESPACE', 'postgres'), P('LOGGED', 'postgres'), P('UNLOGGED', 'postgres')], { kind: 'ddl' }) + return null +} + +/** + * The table an INSERT writes to, by its last name part, or null. + * @param {Token[]} tokens + */ +function insertTable(tokens) { + const into = tokens.findIndex((t) => kw(t) === 'INTO') + if (into < 0) return null + const e = nameEnd(tokens, into + 1) + return e < 0 ? null : lastName(tokens, e) +} + +/** + * Queries and DML, read from the last few tokens: the pairs (ORDER BY, + * IS NOT NULL, LEFT JOIN, ON CONFLICT DO …) and an INSERT's column list. + * @param {Token[]} tokens @param {string} clause @param {string} verb + * @returns {Follow | null} + */ +function queryFollow(tokens, clause, verb) { + const n = tokens.length + const last = kw(tokens[n - 1]) + const prev = kw(tokens[n - 2]) + if (n === 1 && verb === 'INSERT') return { phrases: [P('INTO', '', true), P('IGNORE INTO', 'mysql', true), P('OR IGNORE INTO', 'sqlite', true), P('OR REPLACE INTO', 'sqlite', true)], eager: true, only: true } + if (n === 1 && verb === 'DELETE') return forced([P('FROM', '', true)]) + if (n === 1 && verb === 'TRUNCATE') return offered([P('TABLE', 'postgres mysql mssql duckdb clickhouse', true)], { kind: 'tables' }) + if (n === 1 && verb === 'WITH') return offered([P('RECURSIVE', 'postgres mysql sqlite duckdb')], { kind: 'ddl' }) + if (verb === 'UPDATE') { + // UPDATE [ONLY] t | + const at = kw(tokens[1]) === 'ONLY' ? 2 : 1 + const e = nameEnd(tokens, at) + if (e === n || (e > 0 && e + 1 === n && isName(tokens[e]) && !CLAUSES.has(kw(tokens[e])))) return { phrases: [P('SET', '', true)], eager: true, only: false } + if (last === 'SET' && clause === 'SET') return { phrases: [], eager: true, only: false } + } + if (verb === 'INSERT') { + const table = insertTable(tokens) + const into = tokens.findIndex((t) => kw(t) === 'INTO') + const e = into < 0 ? -1 : nameEnd(tokens, into + 1) + // INSERT INTO t | + if (e === n) return { phrases: [P('VALUES'), P('SELECT'), P('DEFAULT VALUES', 'postgres sqlite mssql duckdb')], eager: true, only: false } + // INSERT INTO t (a, |: only t's columns. + if (e > 0 && punct(tokens[e]) === '(' && openParen(tokens, n) === e && (punct(tokens[n - 1]) === '(' || punct(tokens[n - 1]) === ',') && table) { + return { phrases: [], eager: true, only: false, kind: 'columns', columnsOf: table } + } + // INSERT INTO t (a, b) | + if (e > 0 && punct(tokens[n - 1]) === ')' && openParen(tokens, n - 1) === e) return { phrases: [P('VALUES'), P('SELECT')], eager: true, only: false } + // … VALUES (…) ON | + if (last === 'ON' && tokens.some((t) => kw(t) === 'VALUES')) { + return forced([P('CONFLICT', 'postgres sqlite duckdb'), P('DUPLICATE KEY UPDATE', 'mysql', true)]) + } + const conflict = tokens.findLastIndex((t) => kw(t) === 'CONFLICT') + if (conflict > 0 && kw(tokens[conflict - 1]) === 'ON') { + const doPhrases = [P('DO NOTHING'), P('DO UPDATE SET', '', true)] + if (n === conflict + 1) return forced([...doPhrases, P('ON CONSTRAINT', 'postgres')]) + if (punct(tokens[n - 1]) === ')' && openParen(tokens, n - 1) === conflict + 1) return forced(doPhrases) + if (last === 'DO') return forced([P('NOTHING'), P('UPDATE SET', '', true)]) + if (last === 'UPDATE' && prev === 'DO') return forced([P('SET', '', true)]) + if (last === 'SET' && prev === 'UPDATE' && table) return { phrases: [], eager: true, only: false, kind: 'columns', columnsOf: table } + } + if (last === 'DUPLICATE' && prev === 'ON') return forced([P('KEY UPDATE', '', true)]) + if (last === 'KEY' && prev === 'DUPLICATE') return forced([P('UPDATE', '', true)]) + if (last === 'UPDATE' && prev === 'KEY' && table) return { phrases: [], eager: true, only: false, kind: 'columns', columnsOf: table } + } + if (last === 'ORDER' || last === 'GROUP' || last === 'PARTITION') return forced([P('BY', '', true)]) + if (last === 'IS') return forced([P('NULL'), P('NOT NULL'), P('DISTINCT FROM'), P('TRUE'), P('FALSE')]) + if (last === 'NOT' && prev === 'IS') return forced([P('NULL'), P('DISTINCT FROM'), P('TRUE'), P('FALSE')]) + if (last === 'DISTINCT' && (prev === 'IS' || (prev === 'NOT' && kw(tokens[n - 3]) === 'IS'))) return forced([P('FROM')]) + if (last === 'NULLS') return forced([P('FIRST'), P('LAST')]) + if (last === 'UNION' || last === 'EXCEPT' || last === 'INTERSECT') return forced([P('ALL'), P('SELECT'), P('DISTINCT')]) + if (last === 'NOT' && PREDICATE_CLAUSES.has(clause)) { + return offered([P('NULL'), P('IN'), P('LIKE'), P('ILIKE', 'postgres duckdb'), P('BETWEEN'), P('EXISTS')]) + } + if (last === 'DISTINCT' && prev === 'SELECT') return offered([P('ON', 'postgres duckdb')]) + if (clause === 'FROM' || clause === 'JOIN' || clause === 'ON' || clause === 'WHERE') { + if (last === 'LEFT' || last === 'RIGHT' || last === 'FULL') return forced([P('JOIN', '', true), P('OUTER JOIN', '', true)]) + if (last === 'INNER' || last === 'CROSS' || last === 'NATURAL' || (last === 'OUTER' && ['LEFT', 'RIGHT', 'FULL'].includes(prev))) return forced([P('JOIN', '', true)]) + } + if (clause === 'JOIN') { + // JOIN t [AS] [alias] | + const join = tokens.findLastIndex((t) => kw(t) === 'JOIN') + let e = nameEnd(tokens, join + 1) + if (e > 0 && kw(tokens[e]) === 'AS') e++ + if (e > 0 && e < n && isName(tokens[e]) && !OPEN_WORDS.has(kw(tokens[e]))) e++ + if (e === n) return offered([P('ON'), P('USING')], { kind: 'ddl' }) + } + if (last === 'CASE') return offered([P('WHEN')]) + return null +} + +/** + * What can come next at the end of `tokens`, by the statement's grammar. + * @param {Token[]} tokens @param {string} clause @param {string} verb + * @returns {Follow | null} + */ +function followAt(tokens, clause, verb) { + if (!tokens.length) return null + if (verb === 'DROP') return dropFollow(tokens) + if (verb === 'CREATE') return createFollow(tokens) + if (verb === 'ALTER') return alterFollow(tokens) + return queryFollow(tokens, clause, verb) +} + /** * The name of the CREATE FUNCTION being written, or null. * @param {Token[]} tokens @@ -445,9 +825,14 @@ function routineName(tokens) { * @returns {SqlCompletionContext | null} null where nothing should be offered */ export function sqlCompletionContext(text) { - const scanned = scan(text) + // Typing a word changes only that word: the text before it scans the same, + // so a long statement is tokenized once per word, not once per keystroke. + const word = /[A-Za-z_][\w$]*$/.exec(text)?.[0] ?? '' + const wordy = word !== '' && !/[\w$]/.test(text[text.length - word.length - 1] ?? '') + const scanned = wordy ? scanHead(text.slice(0, text.length - word.length)) : scan(text) if (!scanned) return null - const { tokens, open } = scanned + const { open } = scanned + let tokens = scanned.tokens // A body's statements end in `;`, which clears the tokens: the head the // statement opened with (CREATE TRIGGER ... ON t) is kept apart. const head = scanned.head ?? tokens @@ -460,12 +845,17 @@ export function sqlCompletionContext(text) { quote = open.quote from = open.start + 1 prefix = text.slice(from) + } else if (wordy) { + // `1.e`: the word is the tail of a number. + if (tokens.at(-1)?.t === 'num' && text[text.length - word.length - 1] === '.') return null + prefix = word + from = text.length - word.length } else { const m = /[\w$]+$/.exec(text) if (m && tokens.at(-1)?.t === 'word') { prefix = m[0] from = text.length - prefix.length - tokens.pop() + tokens = tokens.slice(0, -1) } else if (m) { // A number being typed, e.g. `= 4`. return null @@ -521,6 +911,13 @@ export function sqlCompletionContext(text) { } else { kind = 'columns' } + // The grammar's view of what follows: phrases, and sometimes a narrower kind. + /** @type {Follow | null} */ + const follow = quote || kind === 'qualified' ? null : followAt(tokens, clause, verb) + if (follow?.kind) kind = follow.kind + if (follow?.next) nextHere = follow.next + if (follow?.only) { kind = 'ddl'; nextHere = [] } + // A quote only ever holds a name. if (quote && (kind === 'keywords' || kind === 'statement' || kind === 'types' || kind === 'ddl')) kind = 'columns' @@ -572,5 +969,11 @@ export function sqlCompletionContext(text) { verb, rowTable: triggerTable(head), routine: routineName(head), + phrases: follow?.phrases ?? [], + eager: follow?.eager ?? false, + columnsOf: follow?.columnsOf ?? null, + names: follow?.names ?? null, + typesFor: follow?.typesFor ?? null, + lower: tokens[0]?.t === 'word' && /[a-z]/.test(tokens[0].v) && tokens[0].v === tokens[0].v.toLowerCase(), } } diff --git a/src/lib/sql-complete-context.test.js b/src/lib/sql-complete-context.test.js index 3368e0a1..5654fda4 100644 --- a/src/lib/sql-complete-context.test.js +++ b/src/lib/sql-complete-context.test.js @@ -139,3 +139,40 @@ describe('sqlCompletionContext in a trigger', () => { expect(ctx('CREATE OR REPLACE FUNCTION set_updated_at() RETURNS trigger AS $$ BEGIN NEW.')?.routine).toBe('set_updated_at') }) }) + +describe('sqlCompletionContext grammar', () => { + const texts = (/** @type {any} */ c) => c.phrases.map((/** @type {any} */ p) => p.text) + + it('says what follows DROP, and when it is certain', () => { + expect(ctx('DROP ')).toMatchObject({ kind: 'ddl', eager: true }) + expect(texts(ctx('DROP TABLE '))).toEqual(['IF EXISTS']) + expect(ctx('DROP TABLE ')).toMatchObject({ kind: 'tables', eager: false }) + expect(ctx('DROP TABLE IF ')).toMatchObject({ kind: 'ddl', eager: true, next: [] }) + expect(ctx('DROP TABLE IF EXISTS ')).toMatchObject({ kind: 'tables', eager: true }) + expect(ctx('DROP SCHEMA IF EXISTS ')).toMatchObject({ kind: 'ddl', names: 'schemas' }) + expect(texts(ctx('DROP TABLE users ')).slice(0, 2)).toEqual(['CASCADE', 'RESTRICT']) + }) + + it('treats a CREATE\'s name as new', () => { + expect(ctx('CREATE TABLE ')).toMatchObject({ kind: 'ddl', eager: false }) + expect(texts(ctx('CREATE TABLE '))).toEqual(['IF NOT EXISTS']) + expect(ctx('CREATE TABLE IF NOT ')).toMatchObject({ eager: true }) + expect(texts(ctx('CREATE UNIQUE INDEX i '))).toEqual(['ON']) + }) + + it('names the table whose columns go here', () => { + expect(ctx('ALTER TABLE app.users DROP COLUMN ')).toMatchObject({ kind: 'columns', columnsOf: 'users', eager: true }) + expect(ctx('INSERT INTO "Orders" (')).toMatchObject({ kind: 'columns', columnsOf: 'Orders' }) + expect(ctx('UPDATE t SET a = 1 WHERE b ')).toMatchObject({ columnsOf: null }) + }) + + it('reads the case the statement is written in', () => { + expect(ctx('drop table ').lower).toBe(true) + expect(ctx('DROP TABLE ').lower).toBe(false) + }) + + it('leaves quotes and dotted names alone', () => { + expect(ctx('DROP TABLE "').phrases).toEqual([]) + expect(ctx('DROP TABLE public.').phrases).toEqual([]) + }) +}) 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] +} 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']) + }) +}) diff --git a/src/lib/sql-undo.js b/src/lib/sql-undo.js new file mode 100644 index 00000000..49320b2d --- /dev/null +++ b/src/lib/sql-undo.js @@ -0,0 +1,1505 @@ +/** + * Revert for a write run in the SQL console. + * + * A single UPDATE, DELETE or INSERT run from the console keeps what it takes to + * put things back: the rows it is about to change are read in the same + * transaction as the write, and the statements that reverse it are written + * there and then. Revert runs them in a transaction of its own. + * + * Every value is captured as a SQL literal the database writes itself + * (`quote_nullable` on Postgres, `quote` on SQLite, hex on MySQL), never as + * JSON. A bigint above 2^53, a + * numeric with forty digits, a bytea, an array, a timestamp with microseconds: + * each comes back exactly as it was, and nothing a cell contains can end up + * parsed as SQL, because the database quoted it for its own parser. + * + * Schema changes have a revert too, where one exists: a CREATE is undone by + * dropping what it made (never CASCADE, so a drop that would take something + * else with it fails instead), ADD COLUMN by dropping the column, a RENAME by + * renaming back. DROP and CREATE OR REPLACE have none: what they removed or + * replaced is gone. + * + * What a revert can't undo it says so instead: the work of triggers, and rows + * in other tables an ON DELETE rule changed. Rows that changed again after the + * run are left alone (an UPDATE's revert only touches rows still holding the + * values it wrote), and a DELETE's revert skips keys that are taken again. + */ +import { splitSqlStatements } from './sql-statements.js' + +/** A write that touches more rows than this runs without an undo copy. */ +export const UNDO_MAX_ROWS = 10_000 +/** Or more data than this (Postgres measures it before reading anything). */ +export const UNDO_MAX_BYTES = 64 * 1024 * 1024 +/** Rows per revert statement. */ +const CHUNK = 500 + +// ── Lexer ──────────────────────────────────────────────────────────────────── + +/** + * @typedef {{ + * t: 'word' | 'ident' | 'string' | 'num' | 'param' | 'punct', + * text: string, + * up: string, + * start: number, + * end: number, + * depth: number, + * }} Tok + * `ident` is a quoted identifier with its quotes removed (`text` is the name); + * `depth` is the parenthesis depth the token sits at. + */ + +/** + * Tokens of one statement, comments and whitespace dropped. Enough of SQL to + * find clause boundaries: strings (with E'' escapes), dollar quotes, quoted + * identifiers and nested block comments never end a clause early. + * @param {string} sql + * @returns {Tok[]} + */ +export function lex(sql) { + /** @type {Tok[]} */ + const out = [] + const n = sql.length + let i = 0 + let depth = 0 + /** @param {Tok['t']} t @param {string} text @param {number} start @param {number} end */ + const push = (t, text, start, end) => out.push({ t, text, up: t === 'word' ? text.toUpperCase() : text, start, end, depth }) + while (i < n) { + const c = sql[i] + if (/\s/.test(c)) { i++; continue } + if (c === '-' && sql[i + 1] === '-') { + while (i < n && sql[i] !== '\n') i++ + continue + } + if (c === '/' && sql[i + 1] === '*') { + let level = 0 + while (i < n) { + if (sql[i] === '/' && sql[i + 1] === '*') { level++; i += 2; continue } + if (sql[i] === '*' && sql[i + 1] === '/') { level--; i += 2; if (!level) break; continue } + i++ + } + continue + } + const start = i + if (c === "'" || ((c === 'E' || c === 'e') && sql[i + 1] === "'")) { + const escapes = c !== "'" + i += escapes ? 2 : 1 + while (i < n) { + if (escapes && sql[i] === '\\') { i += 2; continue } + if (sql[i] === "'") { + if (sql[i + 1] === "'") { i += 2; continue } + i++ + break + } + i++ + } + push('string', sql.slice(start, i), start, i) + continue + } + if (c === '"' || c === '`') { + let name = '' + i++ + while (i < n) { + if (sql[i] === c) { + if (sql[i + 1] === c) { name += c; i += 2; continue } + i++ + break + } + name += sql[i++] + } + push('ident', name, start, i) + continue + } + if (c === '$') { + const tag = /^\$([A-Za-z_][\w]*)?\$/.exec(sql.slice(i)) + if (tag) { + const close = sql.indexOf(tag[0], i + tag[0].length) + i = close === -1 ? n : close + tag[0].length + push('string', sql.slice(start, i), start, i) + continue + } + const param = /^\$\d+/.exec(sql.slice(i)) + if (param) { i += param[0].length; push('param', param[0], start, i); continue } + } + if (/[A-Za-z_\u0080-\uffff]/.test(c)) { + while (i < n && /[\w$\u0080-\uffff]/.test(sql[i])) i++ + push('word', sql.slice(start, i), start, i) + continue + } + if (/\d/.test(c) || (c === '.' && /\d/.test(sql[i + 1] ?? ''))) { + while (i < n && /[\d.eE]/.test(sql[i])) i++ + push('num', sql.slice(start, i), start, i) + continue + } + if (c === ')') depth = Math.max(0, depth - 1) + push('punct', c, start, i + 1) + if (c === '(') depth++ + i++ + } + return out +} + +// ── Parsing the write ──────────────────────────────────────────────────────── + +/** + * @typedef {{ name: string, quoted: boolean }} NameRef + * @typedef {{ + * kind: 'update' | 'delete' | 'insert', + * body: string, + * table: { text: string, parts: NameRef[] }, + * only: boolean, + * alias: string, + * rowRef: string, + * setColumns: NameRef[], + * tail: string, + * insertRows: number, + * }} WritePlan + * `body` is the statement without its `;`. `rowRef` is how the statement's + * columns are qualified (its alias, else the table name as written). `tail` is + * what picks the rows: WHERE, plus ORDER BY / LIMIT where the engine has them. + */ + +const isIdent = (/** @type {Tok | undefined} */ t) => !!t && (t.t === 'ident' || t.t === 'word') + +/** + * A table name at `i`: one to three dot-separated parts. + * @param {Tok[]} toks @param {number} i @param {string} sql + * @returns {{ text: string, parts: NameRef[], end: number, last: string } | null} + */ +function readName(toks, i, sql) { + if (!isIdent(toks[i])) return null + const first = toks[i] + /** @type {NameRef[]} */ + const parts = [] + let j = i + for (;;) { + const t = toks[j] + parts.push({ name: t.text, quoted: t.t === 'ident' }) + if (toks[j + 1]?.text === '.' && isIdent(toks[j + 2]) && parts.length < 3) { j += 2; continue } + break + } + return { text: sql.slice(first.start, toks[j].end), parts, end: j + 1, last: sql.slice(toks[j].start, toks[j].end) } +} + +/** Keywords that end a table reference's alias position. */ +const AFTER_TABLE = new Set(['SET', 'WHERE', 'RETURNING', 'USING', 'ORDER', 'LIMIT', 'VALUES', 'DEFAULT', 'SELECT', 'WITH', 'ON', 'OVERRIDING', 'FROM']) + +/** + * Optional `[AS] alias` at `i`. + * @param {Tok[]} toks @param {number} i @param {string} sql + * @returns {{ alias: string, end: number }} + */ +function readAlias(toks, i, sql) { + if (toks[i]?.up === 'AS' && isIdent(toks[i + 1])) return { alias: sql.slice(toks[i + 1].start, toks[i + 1].end), end: i + 2 } + const t = toks[i] + if (t && t.depth === 0 && (t.t === 'ident' || (t.t === 'word' && !AFTER_TABLE.has(t.up)))) { + return { alias: sql.slice(t.start, t.end), end: i + 1 } + } + return { alias: '', end: i } +} + +/** @param {Tok[]} toks @param {number} from @param {Set} words */ +function findTop(toks, from, words) { + for (let i = from; i < toks.length; i++) if (toks[i].depth === 0 && toks[i].t === 'word' && words.has(toks[i].up)) return i + return -1 +} + +/** + * The columns a SET list assigns, `a = …, (b, c) = …` style. + * @param {Tok[]} toks @param {number} from @param {number} to exclusive + * @returns {NameRef[] | null} + */ +function setColumnsOf(toks, from, to) { + /** @type {NameRef[]} */ + const cols = [] + let expectTarget = true + for (let i = from; i < to; i++) { + const t = toks[i] + if (t.depth === 0 && t.text === ',') { expectTarget = true; continue } + if (!expectTarget) continue + if (t.text === '(') { + for (i++; i < to && toks[i].text !== ')'; i++) { + if (isIdent(toks[i])) cols.push({ name: toks[i].text, quoted: toks[i].t === 'ident' }) + } + expectTarget = false + continue + } + if (!isIdent(t)) return null + // `col`, or `t.col` on MySQL: the last part names the column. + let k = i + while (toks[k + 1]?.text === '.' && isIdent(toks[k + 2])) k += 2 + cols.push({ name: toks[k].text, quoted: toks[k].t === 'ident' }) + i = k + expectTarget = false + } + return cols.length ? cols : null +} + +/** + * The write in `sql`, when it is one this module can capture: a single + * UPDATE / DELETE / INSERT … VALUES on one table. Joins (UPDATE … FROM, + * DELETE … USING), CTEs, INSERT … SELECT, upserts and statements that already + * RETURN something are run as they are, without an undo copy. + * @param {string} sql + * @returns {WritePlan | DdlPlan | null} + */ +export function parseWrite(sql) { + const stmts = splitSqlStatements(String(sql ?? '')) + if (stmts.length !== 1) return null + const body = stmts[0].text.trim().replace(/;\s*$/, '').trim() + const toks = lex(body) + if (!toks.length) return null + const head = toks[0].up + if (head === 'UPDATE') return parseUpdate(body, toks) + if (head === 'DELETE') return parseDelete(body, toks) + if (head === 'INSERT') return parseInsert(body, toks) + if (head === 'CREATE') return parseCreate(body, toks) + if (head === 'ALTER') return parseAlter(body, toks) + return null +} + +/** @param {string} body @param {Tok[]} toks @returns {WritePlan | null} */ +function parseUpdate(body, toks) { + let i = 1 + if (toks[i]?.up === 'OR') i += 2 // SQLite: UPDATE OR IGNORE + const only = toks[i]?.up === 'ONLY' + if (only) i++ + const name = readName(toks, i, body) + if (!name) return null + i = name.end + if (toks[i]?.text === '*') i++ + const { alias, end } = readAlias(toks, i, body) + i = end + if (toks[i]?.up !== 'SET') return null + const stop = findTop(toks, i + 1, new Set(['FROM', 'WHERE', 'RETURNING', 'ORDER', 'LIMIT'])) + if (stop !== -1 && toks[stop].up === 'FROM') return null + const setColumns = setColumnsOf(toks, i + 1, stop === -1 ? toks.length : stop) + if (!setColumns) return null + const tail = tailOf(body, toks, stop) + if (tail === null) return null + return { kind: 'update', body, table: { text: name.text, parts: name.parts }, only, alias, rowRef: alias || name.last, setColumns, tail, insertRows: 0 } +} + +/** @param {string} body @param {Tok[]} toks @returns {WritePlan | null} */ +function parseDelete(body, toks) { + if (toks[1]?.up !== 'FROM') return null + let i = 2 + const only = toks[i]?.up === 'ONLY' + if (only) i++ + const name = readName(toks, i, body) + if (!name) return null + i = name.end + if (toks[i]?.text === '*') i++ + const { alias, end } = readAlias(toks, i, body) + i = end + if (i < toks.length && !['WHERE', 'RETURNING', 'ORDER', 'LIMIT'].includes(toks[i].up)) return null + const tail = tailOf(body, toks, i < toks.length ? i : -1) + if (tail === null) return null + return { kind: 'delete', body, table: { text: name.text, parts: name.parts }, only, alias, rowRef: alias || name.last, setColumns: [], tail, insertRows: 0 } +} + +/** @param {string} body @param {Tok[]} toks @returns {WritePlan | null} */ +function parseInsert(body, toks) { + let i = 1 + if (toks[i]?.up === 'OR') { + // INSERT OR REPLACE deletes the rows it replaces: nothing to put back. + if (toks[i + 1]?.up !== 'IGNORE' && toks[i + 1]?.up !== 'ABORT' && toks[i + 1]?.up !== 'FAIL') return null + i += 2 + } + if (toks[i]?.up !== 'INTO') return null + const name = readName(toks, i + 1, body) + if (!name) return null + i = name.end + const { alias, end } = readAlias(toks, i, body) + i = end + if (toks[i]?.text === '(') { + while (i < toks.length && !(toks[i].text === ')' && toks[i].depth === 0)) i++ + i++ + } + if (toks[i]?.up === 'OVERRIDING') i += 3 + let rows = 0 + if (toks[i]?.up === 'DEFAULT' && toks[i + 1]?.up === 'VALUES') { + rows = 1 + i += 2 + } else if (toks[i]?.up === 'VALUES') { + for (i++; i < toks.length; i++) { + const t = toks[i] + if (t.depth !== 0) continue + if (t.text === '(') { rows++; continue } + if (t.text === ',' || t.text === ')') continue + break + } + } else { + return null + } + // What follows the rows: nothing, or ON CONFLICT … DO NOTHING. + if (i < toks.length) { + if (toks[i].up !== 'ON' || toks[i + 1]?.up !== 'CONFLICT') return null + const doAt = findTop(toks, i + 2, new Set(['DO'])) + if (doAt === -1 || toks[doAt + 1]?.up !== 'NOTHING' || doAt + 2 !== toks.length) return null + } + if (!rows) return null + return { kind: 'insert', body, table: { text: name.text, parts: name.parts }, only: false, alias, rowRef: alias || name.last, setColumns: [], tail: '', insertRows: rows } +} + +/** + * WHERE / ORDER BY / LIMIT up to RETURNING, or null when the statement returns + * rows of its own (the capture adds its own RETURNING, and only one fits) or + * updates through a cursor. + * @param {string} body @param {Tok[]} toks @param {number} from index of the first tail token, -1 when none + */ +function tailOf(body, toks, from) { + if (from === -1) return '' + const ret = findTop(toks, from, new Set(['RETURNING'])) + if (ret !== -1) return null + for (let k = from; k < toks.length - 1; k++) if (toks[k].up === 'CURRENT' && toks[k + 1].up === 'OF') return null + return body.slice(toks[from].start).trim() +} + +/** + * @typedef {{ + * kind: 'ddl', + * ddl: 'create' | 'add-column' | 'rename-table' | 'rename-column', + * objectType: 'TABLE' | 'VIEW' | 'MATERIALIZED VIEW' | 'SEQUENCE' | 'INDEX' | 'SCHEMA', + * body: string, + * name: { text: string, parts: NameRef[] }, + * table: { text: string, parts: NameRef[] } | null, + * column: NameRef | null, + * to: NameRef | null, + * }} DdlPlan + * `name` is what a CREATE makes; `table` is an index's table or the table an + * ALTER changes; `column` is the column added or renamed, `to` its (or the + * table's) new name. + */ + +const CREATABLE = new Set(['TABLE', 'VIEW', 'SEQUENCE', 'INDEX', 'SCHEMA']) + +/** @param {string} body @param {Tok[]} toks @returns {DdlPlan | null} */ +function parseCreate(body, toks) { + let i = 1 + // OR REPLACE: the definition it replaced is gone. TEMP: it lives on one + // pooled connection, and the revert would run on another. + if (toks[i]?.up === 'OR') return null + if (['TEMP', 'TEMPORARY', 'GLOBAL', 'LOCAL'].includes(toks[i]?.up)) return null + if (toks[i]?.up === 'UNLOGGED') i++ + const unique = toks[i]?.up === 'UNIQUE' + if (unique) i++ + /** @type {DdlPlan['objectType'] | ''} */ + let type = '' + if (toks[i]?.up === 'MATERIALIZED' && toks[i + 1]?.up === 'VIEW') { type = 'MATERIALIZED VIEW'; i += 2 } + else if (CREATABLE.has(toks[i]?.up)) { type = /** @type {DdlPlan['objectType']} */ (toks[i].up); i++ } + if (!type || (unique && type !== 'INDEX')) return null + // CONCURRENTLY can't run inside the transaction the capture uses. + if (type === 'INDEX' && toks[i]?.up === 'CONCURRENTLY') return null + if (toks[i]?.up === 'IF' && toks[i + 1]?.up === 'NOT' && toks[i + 2]?.up === 'EXISTS') i += 3 + // An unnamed index gets a generated name; CREATE SCHEMA AUTHORIZATION takes the role's. + if (toks[i]?.up === 'ON' || toks[i]?.up === 'AUTHORIZATION') return null + const name = readName(toks, i, body) + if (!name) return null + /** @type {DdlPlan['table']} */ + let table = null + if (type === 'INDEX') { + let j = name.end + if (toks[j]?.up !== 'ON') return null + j++ + if (toks[j]?.up === 'ONLY') j++ + const t = readName(toks, j, body) + if (!t) return null + table = { text: t.text, parts: t.parts } + } + return { kind: 'ddl', ddl: 'create', objectType: type, body, name: { text: name.text, parts: name.parts }, table, column: null, to: null } +} + +/** @param {Tok | undefined} t @returns {NameRef | null} */ +const refOf = (t) => (isIdent(t) ? { name: /** @type {Tok} */ (t).text, quoted: /** @type {Tok} */ (t).t === 'ident' } : null) + +/** @param {string} body @param {Tok[]} toks @returns {DdlPlan | null} */ +function parseAlter(body, toks) { + if (toks[1]?.up !== 'TABLE') return null + let i = 2 + if (toks[i]?.up === 'IF' && toks[i + 1]?.up === 'EXISTS') i += 2 + if (toks[i]?.up === 'ONLY') i++ + const table = readName(toks, i, body) + if (!table) return null + i = table.end + if (toks[i]?.text === '*') i++ + // One action: `ADD a int, ADD b int` would need one revert per action. + for (let k = i; k < toks.length; k++) if (toks[k].depth === 0 && toks[k].text === ',') return null + const base = { kind: /** @type {const} */ ('ddl'), objectType: /** @type {const} */ ('TABLE'), body, name: { text: table.text, parts: table.parts }, table: { text: table.text, parts: table.parts } } + if (toks[i]?.up === 'ADD') { + i++ + const saidColumn = toks[i]?.up === 'COLUMN' + if (saidColumn) i++ + if (toks[i]?.up === 'IF' && toks[i + 1]?.up === 'NOT' && toks[i + 2]?.up === 'EXISTS') i += 3 + // ADD CONSTRAINT / PRIMARY KEY / UNIQUE / FOREIGN KEY / CHECK: not a column. + if (!saidColumn && ['CONSTRAINT', 'PRIMARY', 'UNIQUE', 'FOREIGN', 'CHECK', 'EXCLUDE'].includes(toks[i]?.up)) return null + const column = refOf(toks[i]) + return column ? { ...base, ddl: 'add-column', column, to: null } : null + } + if (toks[i]?.up === 'RENAME') { + i++ + if (toks[i]?.up === 'TO') { + const to = refOf(toks[i + 1]) + return to && i + 2 === toks.length ? { ...base, ddl: 'rename-table', column: null, to } : null + } + if (toks[i]?.up === 'CONSTRAINT') return null + if (toks[i]?.up === 'COLUMN') i++ + const column = refOf(toks[i]) + const to = refOf(toks[i + 2]) + return column && to && toks[i + 1]?.up === 'TO' && i + 3 === toks.length ? { ...base, ddl: 'rename-column', column, to } : null + } + return null +} + +// ── Dialects ───────────────────────────────────────────────────────────────── + +/** + * @typedef {{ + * name: string, + * type: string, + * pk: boolean, + * generated: boolean, + * identityAlways: boolean, + * dataType?: string, + * }} ColumnMeta + * `dataType` is MySQL's bare type (`varchar`, `blob`), which decides how a + * value is captured. + * @typedef {{ + * qualified: string, + * columns: ColumnMeta[], + * pk: ColumnMeta[], + * triggers: boolean, + * cascades: boolean, + * }} TableMeta + */ + +/** @param {string} name */ +const dq = (name) => `"${String(name).replace(/"/g, '""')}"` +/** @param {string} name */ +const bq = (name) => `\`${String(name).replace(/`/g, '``')}\`` +/** An identifier quoted for the dialect: MySQL reads "x" as a string. @param {string} name @param {'postgres' | 'sqlite' | 'mysql'} dialect */ +const qi = (name, dialect) => (dialect === 'mysql' ? bq(name) : dq(name)) + +/** + * MySQL text the database can't misread whatever its sql_mode: the UTF-8 bytes + * in hex, as a utf8mb4 string. QUOTE() would be shorter, but it escapes with + * backslashes, which NO_BACKSLASH_ESCAPES turns into literal characters. + * @param {string} s + */ +function mysqlText(s) { + const hex = [...new TextEncoder().encode(s)].map((b) => b.toString(16).padStart(2, '0')).join('') + return `CONVERT(X'${hex}' USING utf8mb4)` +} + +/** + * A string literal for SQL text this module wrote itself (a table name as the + * statement spelled it). Dollar-quoted on Postgres, so no setting changes how + * it reads. + * @param {string} s @param {'postgres' | 'sqlite' | 'mysql'} dialect + */ +function textLiteral(s, dialect) { + if (dialect === 'mysql') return mysqlText(s) + if (dialect === 'sqlite') return `'${s.replace(/'/g, "''")}'` + let tag = '$q$' + for (let k = 0; s.includes(tag); k++) tag = `$q${k}$` + return `${tag}${s}${tag}` +} + +/** + * The metadata column a statement's name refers to: exact when quoted, folded + * otherwise (Postgres lowercases, SQLite ignores case). + * @param {ColumnMeta[]} cols @param {NameRef} ref @param {'postgres' | 'sqlite' | 'mysql'} dialect + */ +function findColumn(cols, ref, dialect) { + if (ref.quoted) return cols.find((c) => c.name === ref.name) ?? null + if (dialect === 'postgres') return cols.find((c) => c.name === ref.name.toLowerCase()) ?? null + // SQLite and MySQL column names ignore case. + return cols.find((c) => c.name.toLowerCase() === ref.name.toLowerCase()) ?? null +} + +/** @param {WritePlan} plan */ +const fromClause = (plan) => `${plan.only ? 'ONLY ' : ''}${plan.table.text}${plan.alias ? ` AS ${plan.alias}` : ''}` + +/** + * Catalog query for the table the write names, resolved exactly as the + * statement resolves it (search_path, quoting). + * @param {WritePlan} plan + */ +export function pgMetaSql(plan) { + return `SELECT a.attname::text, format_type(a.atttypid, a.atttypmod), (a.attgenerated <> '')::text, + (a.attidentity = 'a')::text, COALESCE(a.attnum = ANY (i.indkey), false)::text, + EXISTS (SELECT 1 FROM pg_trigger t WHERE t.tgrelid = c.oid AND NOT t.tgisinternal)::text, + EXISTS (SELECT 1 FROM pg_constraint f WHERE f.confrelid = c.oid AND f.contype = 'f' AND f.confdeltype IN ('c', 'n', 'd'))::text, + c.relkind::text, format('%I.%I', n.nspname, c.relname) +FROM pg_class c +JOIN pg_namespace n ON n.oid = c.relnamespace +JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped +LEFT JOIN pg_index i ON i.indrelid = c.oid AND i.indisprimary +WHERE c.oid = to_regclass(${textLiteral(plan.table.text, 'postgres')}) +ORDER BY a.attnum` +} + +/** + * @param {unknown[][]} rows pgMetaSql's + * @returns {TableMeta | string} the table, or why it can't be reverted + */ +export function pgReadMeta(rows) { + if (!rows.length) return '' + const kind = String(rows[0][7]) + if (kind !== 'r' && kind !== 'p') return 'only tables can be reverted' + /** @type {ColumnMeta[]} */ + const columns = rows.map((r) => ({ + name: String(r[0]), + type: String(r[1]), + generated: r[2] === 'true', + identityAlways: r[3] === 'true', + pk: r[4] === 'true', + })) + return { + qualified: String(rows[0][8]), + columns, + pk: columns.filter((c) => c.pk), + triggers: rows[0][5] === 'true', + cascades: rows[0][6] === 'true', + } +} + +/** + * The SQLite equivalents: a table's columns (generated ones are hidden 2 or 3, + * the key is pk > 0), whether it is a table at all, and its triggers. + * @param {WritePlan} plan + */ +export function sqliteMetaSqls(plan) { + const parts = plan.table.parts + const table = parts[parts.length - 1].name + const schema = parts.length > 1 ? dq(parts[parts.length - 2].name) : 'main' + const lit = textLiteral(table, 'sqlite') + return [ + `SELECT name, type, pk, hidden FROM pragma_table_xinfo(${lit}, ${textLiteral(schema.replace(/^"|"$/g, ''), 'sqlite')}) ORDER BY cid`, + `SELECT type, (SELECT count(*) FROM ${schema}.sqlite_master WHERE type = 'trigger' AND tbl_name = ${lit} COLLATE NOCASE) FROM ${schema}.sqlite_master WHERE name = ${lit} COLLATE NOCASE AND type IN ('table', 'view')`, + ] +} + +/** + * @param {unknown[][]} columnRows @param {unknown[][]} tableRows + * @param {WritePlan} plan + * @returns {TableMeta | string} + */ +export function sqliteReadMeta(columnRows, tableRows, plan) { + if (!tableRows.length || !columnRows.length) return '' + if (String(tableRows[0][0]) !== 'table') return 'only tables can be reverted' + /** @type {ColumnMeta[]} */ + const columns = columnRows.map((r) => ({ + name: String(r[0]), + type: String(r[1] ?? ''), + pk: Number(r[2]) > 0, + generated: Number(r[3]) === 2 || Number(r[3]) === 3, + identityAlways: false, + })) + /** @type {ColumnMeta[]} */ + let pk = columnRows + .filter((r) => Number(r[2]) > 0) + .sort((a, b) => Number(a[2]) - Number(b[2])) + .map((r) => /** @type {ColumnMeta} */ (columns.find((c) => c.name === String(r[0])))) + // No declared key: the rowid addresses the row (a WITHOUT ROWID table always + // declares one). + if (!pk.length) pk = [{ name: 'rowid', type: 'integer', pk: true, generated: false, identityAlways: false }] + return { qualified: plan.table.text, columns, pk, triggers: Number(tableRows[0][1]) > 0, cascades: false } +} + +/** The schema a MySQL name lives in: its own qualifier, else the connection's database. @param {{ parts: NameRef[] }} name */ +function mysqlSchema(name) { + return name.parts.length > 1 ? mysqlText(name.parts[name.parts.length - 2].name) : 'DATABASE()' +} + +/** + * MySQL's catalog for the table: columns with their bare type and key, plus + * the table's kind and engine (only InnoDB rolls back), its triggers and the + * ON DELETE rules that point at it. + * @param {WritePlan} plan + */ +export function mysqlMetaSql(plan) { + const name = mysqlText(plan.table.parts[plan.table.parts.length - 1].name) + return `SELECT c.COLUMN_NAME, c.COLUMN_TYPE, LOWER(c.DATA_TYPE), c.COLUMN_KEY, c.EXTRA, t.TABLE_TYPE, t.ENGINE, + (SELECT COUNT(*) FROM information_schema.TRIGGERS g WHERE g.EVENT_OBJECT_SCHEMA = t.TABLE_SCHEMA AND g.EVENT_OBJECT_TABLE = t.TABLE_NAME), + (SELECT COUNT(*) FROM information_schema.REFERENTIAL_CONSTRAINTS r WHERE r.UNIQUE_CONSTRAINT_SCHEMA = t.TABLE_SCHEMA AND r.REFERENCED_TABLE_NAME = t.TABLE_NAME AND r.DELETE_RULE IN ('CASCADE', 'SET NULL', 'SET DEFAULT')), + CONCAT('\`', REPLACE(t.TABLE_SCHEMA, '\`', '\`\`'), '\`.\`', REPLACE(t.TABLE_NAME, '\`', '\`\`'), '\`') +FROM information_schema.TABLES t +JOIN information_schema.COLUMNS c ON c.TABLE_SCHEMA = t.TABLE_SCHEMA AND c.TABLE_NAME = t.TABLE_NAME +WHERE t.TABLE_SCHEMA = ${mysqlSchema(plan.table)} AND t.TABLE_NAME = ${name} +ORDER BY c.ORDINAL_POSITION` +} + +/** Types a MySQL value can't be restored from text for (no exact literal). */ +const MYSQL_SPATIAL = new Set(['geometry', 'point', 'linestring', 'polygon', 'multipoint', 'multilinestring', 'multipolygon', 'geometrycollection', 'geomcollection']) + +/** + * @param {unknown[][]} rows mysqlMetaSql's + * @returns {TableMeta | string} + */ +export function mysqlReadMeta(rows) { + if (!rows.length) return '' + if (String(rows[0][5]) !== 'BASE TABLE') return 'only tables can be reverted' + if (String(rows[0][6]).toLowerCase() !== 'innodb') return `only InnoDB tables can be reverted, and this one is ${rows[0][6]}` + /** @type {ColumnMeta[]} */ + const columns = rows.map((r) => ({ + name: String(r[0]), + type: String(r[1]), + dataType: String(r[2]), + pk: String(r[3]) === 'PRI', + generated: /GENERATED/i.test(String(r[4] ?? '')), + identityAlways: false, + })) + return { + qualified: String(rows[0][9]), + columns, + pk: columns.filter((c) => c.pk), + triggers: Number(rows[0][7]) > 0, + cascades: Number(rows[0][8]) > 0, + } +} + +const MYSQL_NUMERIC = new Set(['tinyint', 'smallint', 'mediumint', 'int', 'integer', 'bigint', 'decimal', 'numeric', 'float', 'double', 'real', 'year']) +const MYSQL_BINARY = new Set(['binary', 'varbinary', 'tinyblob', 'blob', 'mediumblob', 'longblob']) + +/** + * The SQL that reads a column as a literal of itself. + * @param {ColumnMeta} c @param {string} ref how the query names the column + * @param {'postgres' | 'sqlite' | 'mysql'} dialect + */ +function literalOf(c, ref, dialect) { + if (dialect === 'postgres') return `quote_nullable(${ref})` + if (dialect === 'sqlite') return `quote(${ref})` + const t = c.dataType ?? '' + if (MYSQL_NUMERIC.has(t)) return `IF(${ref} IS NULL, 'NULL', CAST(${ref} AS CHAR))` + if (t === 'bit') return `IF(${ref} IS NULL, 'NULL', CAST(${ref} + 0 AS CHAR))` + if (MYSQL_BINARY.has(t)) return `IF(${ref} IS NULL, 'NULL', CONCAT('X''', HEX(${ref}), ''''))` + return `IF(${ref} IS NULL, 'NULL', CONCAT('CONVERT(X''', HEX(CAST(${ref} AS CHAR)), ''' USING utf8mb4)'))` +} + + +/** + * How many rows the write will touch, and (Postgres) how much the captured + * columns weigh, before anything is read. + * @param {WritePlan} plan @param {TableMeta} meta @param {ColumnMeta[]} captured @param {'postgres' | 'sqlite' | 'mysql'} dialect + */ +export function sizeSql(plan, meta, captured, dialect) { + if (dialect === 'sqlite') return `SELECT count(*) FROM (SELECT 1 FROM ${fromClause(plan)} ${plan.tail})` + if (dialect === 'mysql') { + // A derived table, so an UPDATE's ORDER BY … LIMIT counts what it touches. + const len = captured.map((c) => `COALESCE(LENGTH(${plan.rowRef}.${bq(c.name)}), 0)`).join(' + ') || '0' + return `SELECT COUNT(*), COALESCE(SUM(__b), 0) FROM (SELECT ${len} AS __b FROM ${fromClause(plan)} ${plan.tail}) AS __s` + } + const bytes = plan.kind === 'delete' + ? `pg_column_size(${plan.rowRef}.*)` + : captured.map((c) => `COALESCE(pg_column_size(${plan.rowRef}.${dq(c.name)}), 0)`).join(' + ') + return `SELECT count(*)::text, COALESCE(sum(${bytes}), 0)::text FROM ${fromClause(plan)} ${plan.tail}`.trim() +} + +/** + * The rows as they are before the write: each captured column as a literal, + * locked on Postgres so the write changes exactly these. + * @param {WritePlan} plan @param {ColumnMeta[]} captured @param {'postgres' | 'sqlite' | 'mysql'} dialect + */ +export function captureSql(plan, captured, dialect) { + const cols = captured.map((c) => literalOf(c, `${plan.rowRef}.${colRef(c, dialect)}`, dialect)).join(', ') + const lock = dialect === 'sqlite' ? '' : ' FOR UPDATE' + return `SELECT ${cols} FROM ${fromClause(plan)} ${plan.tail}`.trim() + lock +} + +/** + * The INSERT with the new rows' keys returned as literals. Inside a CTE, so + * the transaction path (which reads rows from SELECT and WITH) returns them. + * Postgres only: SQLite has no INSERT inside WITH. + * @param {WritePlan} plan @param {TableMeta} meta + */ +export function insertReturningSql(plan, meta) { + const keys = meta.pk.map((c, k) => `quote_nullable(${dq(c.name)}) AS k${k}`).join(', ') + return `WITH __ins AS ( +${plan.body} +RETURNING ${keys} +) SELECT ${meta.pk.map((_, k) => `k${k}`).join(', ')} FROM __ins` +} + +/** + * The updated rows as they are after the write, by key, for the revert's guard. + * @param {TableMeta} meta @param {ColumnMeta[]} captured @param {string[][]} keys literal tuples + * @param {'postgres' | 'sqlite' | 'mysql'} dialect + */ +export function afterSql(meta, captured, keys, dialect) { + const cols = captured.map((c) => literalOf(c, colRef(c, dialect), dialect)).join(', ') + return `SELECT ${cols} FROM ${meta.qualified} WHERE ${keyIn(meta.pk, keys, dialect)}` +} + +/** @param {ColumnMeta} c @param {'postgres' | 'sqlite' | 'mysql'} dialect */ +const colRef = (c, dialect) => (c.name === 'rowid' && dialect === 'sqlite' ? 'rowid' : qi(c.name, dialect)) + +/** + * `key IN (…)` for literal key tuples. + * @param {ColumnMeta[]} pk @param {string[][]} keys @param {'postgres' | 'sqlite' | 'mysql'} dialect + */ +function keyIn(pk, keys, dialect) { + if (pk.length === 1) return `${colRef(pk[0], dialect)} IN (${keys.map((k) => k[0]).join(', ')})` + const cols = `(${pk.map((c) => colRef(c, dialect)).join(', ')})` + const tuples = keys.map((k) => `(${k.join(', ')})`).join(', ') + return dialect === 'sqlite' ? `${cols} IN (VALUES ${tuples})` : `${cols} IN (${tuples})` +} + +/** @param {unknown[]} a @param {number} size */ +function chunks(a, size) { + const out = [] + for (let i = 0; i < a.length; i += size) out.push(a.slice(i, i + size)) + return out +} + +/** + * Revert of an UPDATE: put back the old values of the columns it set, only in + * rows that still hold what it wrote. + * @param {TableMeta} meta @param {ColumnMeta[]} setCols + * @param {{ key: string[], before: string[], after: string[] }[]} rows literals + * @param {'postgres' | 'sqlite' | 'mysql'} dialect + * @returns {string[]} + */ +export function revertUpdateSql(meta, setCols, rows, dialect) { + if (dialect === 'mysql') { + // One UPDATE per row; the guard compares each value's bytes as text, so a + // case-insensitive collation can't call a changed value unchanged. + return chunks(rows, CHUNK).map((part) => part.map((r) => { + const set = setCols.map((c, k) => `${bq(c.name)} = ${r.before[k]}`).join(', ') + const where = [ + ...meta.pk.map((c, k) => `${bq(c.name)} = ${r.key[k]}`), + ...setCols.map((c, k) => (c.dataType === 'bit' + // A BIT reads back as raw bytes; its literal is the number. + ? `${bq(c.name)} + 0 <=> ${r.after[k]}` + : `HEX(CAST(${bq(c.name)} AS CHAR)) <=> HEX(CAST(${r.after[k]} AS CHAR))`)), + ].join(' AND ') + return `UPDATE ${meta.qualified} SET ${set} WHERE ${where};` + }).join('\n')) + } + if (dialect === 'sqlite') { + return chunks(rows, CHUNK).map((part) => part.map((r) => { + const set = setCols.map((c, k) => `${dq(c.name)} = ${r.before[k]}`).join(', ') + const where = [ + ...meta.pk.map((c, k) => `${colRef(c, dialect)} = ${r.key[k]}`), + ...setCols.map((c, k) => `${dq(c.name)} IS ${r.after[k]}`), + ].join(' AND ') + return `UPDATE ${meta.qualified} SET ${set} WHERE ${where};` + }).join('\n')) + } + // One statement per chunk: the old values ride in a VALUES list (text) and + // are cast to each column's type; the guard compares text forms, which works + // for every type, json included (it has no = operator). + const keyNames = meta.pk.map((_, k) => `k${k}`) + const oldNames = setCols.map((_, k) => `o${k}`) + const newNames = setCols.map((_, k) => `a${k}`) + return chunks(rows, CHUNK).map((part) => { + const values = part.map((r) => `(${[...r.key, ...r.before, ...r.after].join(', ')})`).join(',\n ') + const set = setCols.map((c, k) => `${dq(c.name)} = __v.${oldNames[k]}::${c.type}`).join(', ') + const where = [ + ...meta.pk.map((c, k) => `__t.${dq(c.name)} = __v.${keyNames[k]}::${c.type}`), + ...setCols.map((c, k) => `__t.${dq(c.name)}::text IS NOT DISTINCT FROM __v.${newNames[k]}`), + ].join('\n AND ') + return `UPDATE ${meta.qualified} AS __t SET ${set}\nFROM (VALUES\n ${values}\n) AS __v(${[...keyNames, ...oldNames, ...newNames].join(', ')})\nWHERE ${where};` + }) +} + +/** + * Revert of a DELETE: insert the rows back, skipping keys that are taken again. + * @param {TableMeta} meta @param {ColumnMeta[]} cols @param {string[][]} rows literals + * @param {'postgres' | 'sqlite' | 'mysql'} dialect + * @returns {string[]} + */ +export function revertDeleteSql(meta, cols, rows, dialect) { + const names = cols.map((c) => colRef(c, dialect)).join(', ') + const overriding = dialect === 'postgres' && cols.some((c) => c.identityAlways) ? ' OVERRIDING SYSTEM VALUE' : '' + return chunks(rows, CHUNK).map((part) => { + const values = part.map((r) => `(${r.join(', ')})`).join(',\n ') + if (dialect === 'mysql') { + // A taken key updates nothing (and reports nothing): MySQL's DO NOTHING. + // Not INSERT IGNORE, which also turns bad values into defaults. + const noop = bq((meta.pk[0] ?? cols[0]).name) + return `INSERT INTO ${meta.qualified} (${names}) VALUES\n ${values}\nON DUPLICATE KEY UPDATE ${noop} = ${noop};` + } + return dialect === 'sqlite' + ? `INSERT OR IGNORE INTO ${meta.qualified} (${names}) VALUES\n ${values};` + : `INSERT INTO ${meta.qualified} (${names})${overriding} VALUES\n ${values}\nON CONFLICT DO NOTHING;` + }) +} + +/** + * Revert of an INSERT: delete the rows it added, by key. + * @param {TableMeta} meta @param {string[][]} keys literals + * @param {'postgres' | 'sqlite' | 'mysql'} dialect + * @returns {string[]} + */ +export function revertInsertSql(meta, keys, dialect) { + return chunks(keys, CHUNK).map((part) => `DELETE FROM ${meta.qualified} WHERE ${keyIn(meta.pk, /** @type {string[][]} */ (part), dialect)};`) +} + +// ── Running a write with an undo copy ──────────────────────────────────────── + +/** + * @typedef {{ + * columns?: unknown[], + * rows?: unknown[][], + * rowCount?: number | null, + * message?: string | null, + * queryMs?: number, + * sql?: string, + * }} SqlResult + * @typedef {{ + * inspect: (sql: string) => Promise, + * begin: () => Promise, + * exec: (sql: string) => Promise, + * run: (sql: string) => Promise, + * commit: () => Promise, + * rollback: () => Promise, + * }} UndoIo + * `inspect` reads outside any transaction (and can be stopped); `exec` reads + * inside it; `run` is the user's statement, inside it. + * @typedef {{ + * id: string, + * dialect: 'postgres' | 'sqlite' | 'mysql', + * kind: 'update' | 'delete' | 'insert' | 'ddl', + * table: string, + * rows: number, + * columns: string[], + * statements: string[], + * warnings: string[], + * sql: string, + * at: number, + * connection?: string, + * words?: { title: string, body: string, note: string, done: string, action: string, destructive: boolean }, + * precheck?: { sql: string, says: string }, + * }} UndoRecord + * A schema change carries its own `words` (the dialog's and the toast's) and + * may carry a `precheck`: a count read when the dialog opens, said as `says` + * with `{rows}` in it (how many rows a dropped table still holds). + * @typedef {{ result: SqlResult, undo: UndoRecord | null, note: string } | { fallback: true, note: string }} UndoOutcome + * `fallback`: run the statement the ordinary way; `note` says why there is no + * undo copy, when there is a reason worth telling ('' otherwise). + */ + +/** @param {string} dialect @returns {'postgres' | 'sqlite' | 'mysql' | null} */ +export function undoDialect(dialect) { + if (dialect === 'postgres') return 'postgres' + if (dialect === 'sqlite') return 'sqlite' + if (dialect === 'mysql' || dialect === 'mariadb') return 'mysql' + return null +} + +let seq = 0 + +/** The name a statement means: Postgres folds unquoted names to lowercase. @param {NameRef} ref @param {'postgres' | 'sqlite' | 'mysql'} dialect */ +const folded = (ref, dialect) => (ref.quoted || dialect !== 'postgres' ? ref.name : ref.name.toLowerCase()) + +const DROP_NOUN = /** @type {const} */ ({ TABLE: 'table', VIEW: 'view', 'MATERIALIZED VIEW': 'materialized view', SEQUENCE: 'sequence', INDEX: 'index', SCHEMA: 'schema' }) + +/** + * The first value of the first row, as text. + * @param {SqlResult} r + */ +const cell = (r) => { + const v = r.rows?.[0]?.[0] + return v === null || v === undefined ? null : String(v) +} + +/** + * A schema change with its revert: what it made is dropped, what it renamed is + * renamed back. Checked and run in one transaction (DDL is transactional on + * Postgres and SQLite), so the object it reports is the one it made. + * @param {DdlPlan} plan @param {'postgres' | 'sqlite' | 'mysql'} dialect @param {UndoIo} io + * @returns {Promise} + */ +async function runDdlWithUndo(plan, dialect, io) { + if (dialect !== 'postgres' && (plan.objectType === 'MATERIALIZED VIEW' || plan.objectType === 'SEQUENCE' || plan.objectType === 'SCHEMA')) { + return { fallback: true, note: '' } + } + try { + await io.begin() + } catch { + return { fallback: true, note: '' } + } + /** @type {SqlResult} */ + let result + /** @type {{ revert: string, words: NonNullable, table: string, precheck?: UndoRecord['precheck'] } | null} */ + let made = null + let note = '' + try { + if (dialect === 'postgres') await io.exec("SET LOCAL lock_timeout = '5s'") + const step = dialect === 'postgres' ? pgDdlSteps(plan) : dialect === 'mysql' ? mysqlDdlSteps(plan) : sqliteDdlSteps(plan) + const before = step.before ? await io.exec(step.before) : null + if (step.existed(before)) { + // IF NOT EXISTS met an existing object: the statement makes nothing. + result = await io.run(plan.body) + note = 'it already existed' + } else { + result = await io.run(plan.body) + const after = step.after ? await io.exec(step.after(before)) : null + made = step.made(before, after) + } + await io.commit() + } catch { + try { await io.rollback() } catch { /* already closed */ } + return { fallback: true, note: '' } + } + return { + result: { ...result, rowCount: null, message: null }, + undo: made + ? { + id: `undo-${Date.now().toString(36)}-${++seq}`, + dialect, + kind: 'ddl', + table: made.table, + rows: 0, + columns: [], + statements: [made.revert], + warnings: [], + sql: plan.body, + at: Date.now(), + words: made.words, + precheck: made.precheck, + } + : null, + note, + } +} + +/** + * @typedef {{ + * before: string | null, + * existed: (before: SqlResult | null) => boolean, + * after: ((before: SqlResult | null) => string) | null, + * made: (before: SqlResult | null, after: SqlResult | null) => { revert: string, words: NonNullable, table: string, precheck?: UndoRecord['precheck'] } | null, + * }} DdlSteps + * `before` runs ahead of the statement, `after` behind it; `made` turns what + * they read into the revert, or null when there is nothing to undo. + */ + +/** @param {DdlPlan} plan @returns {DdlSteps} */ +function pgDdlSteps(plan) { + const lit = (/** @type {string} */ s) => textLiteral(s, 'postgres') + /** An oid read back from the catalog: digits only, or nothing. @param {string | null} v */ + const oid = (v) => (v && /^\d+$/.test(v) ? v : null) + const relOf = (/** @type {string} */ id) => `SELECT format('%I.%I', n.nspname, c.relname) FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.oid = ${id}` + const tableText = plan.table?.text ?? '' + + if (plan.ddl === 'create') { + const type = plan.objectType + const last = folded(plan.name.parts[plan.name.parts.length - 1], 'postgres') + // An index lives in its table's schema, whatever the search path says. + const indexSchema = `(SELECT n.nspname FROM pg_class t JOIN pg_namespace n ON n.oid = t.relnamespace WHERE t.oid = to_regclass(${lit(tableText)}))` + const existsSql = type === 'SCHEMA' + ? `SELECT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = ${lit(last)})::text` + : type === 'INDEX' + ? `SELECT (to_regclass(format('%I.%I', ${indexSchema}, ${lit(last)})) IS NOT NULL)::text` + : `SELECT (to_regclass(${lit(plan.name.text)}) IS NOT NULL)::text` + const nameSql = type === 'SCHEMA' + ? `SELECT quote_ident(${lit(last)})` + : type === 'INDEX' + ? `SELECT format('%I.%I', ${indexSchema}, ${lit(last)})` + : `SELECT format('%I.%I', n.nspname, c.relname) FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.oid = to_regclass(${lit(plan.name.text)})` + return { + before: existsSql, + existed: (b) => cell(/** @type {SqlResult} */ (b)) === 'true', + after: () => nameSql, + made: (_b, a) => { + const q = a ? cell(a) : null + if (!q) return null + return createWords(type, q, `DROP ${type} ${q};`, type === 'TABLE' || type === 'MATERIALIZED VIEW' ? `SELECT count(*)::text FROM ${q}` : '') + }, + } + } + + if (plan.ddl === 'add-column') { + const col = folded(/** @type {NameRef} */ (plan.column), 'postgres') + return { + before: `SELECT c.oid::text, EXISTS (SELECT 1 FROM pg_attribute a WHERE a.attrelid = c.oid AND a.attname = ${lit(col)} AND NOT a.attisdropped)::text FROM pg_class c WHERE c.oid = to_regclass(${lit(tableText)})`, + existed: (b) => b?.rows?.[0]?.[1] === 'true', + after: (b) => `SELECT q.name, quote_ident(${lit(col)}) FROM (${relOf(oid(cell(/** @type {SqlResult} */ (b))) ?? 'NULL')}) AS q(name)`, + made: (_b, a) => { + const q = a?.rows?.[0]?.[0] + const c = a?.rows?.[0]?.[1] + if (!q || !c) return null + return { + revert: `ALTER TABLE ${q} DROP COLUMN ${c};`, + table: String(q), + words: { title: 'Revert ADD COLUMN', body: `Drops the column ${c} it added to ${q}.`, note: 'Values written to it since are lost with it.', done: `Dropped column ${c} from ${q}`, action: 'Drop column', destructive: true }, + } + }, + } + } + + if (plan.ddl === 'rename-table') { + return { + before: `SELECT c.oid::text, quote_ident(c.relname) FROM pg_class c WHERE c.oid = to_regclass(${lit(tableText)})`, + existed: () => false, + after: (b) => relOf(oid(cell(/** @type {SqlResult} */ (b))) ?? 'NULL'), + made: (b, a) => { + const was = b?.rows?.[0]?.[1] + const now = a ? cell(a) : null + if (!was || !now) return null + return { + revert: `ALTER TABLE ${now} RENAME TO ${was};`, + table: now, + words: { title: 'Revert RENAME', body: `Renames ${now} back to ${was}.`, note: '', done: `Renamed back to ${was}`, action: 'Rename back', destructive: false }, + } + }, + } + } + + // rename-column + const from = folded(/** @type {NameRef} */ (plan.column), 'postgres') + return { + before: `SELECT c.oid::text, a.attnum::text, quote_ident(a.attname) FROM pg_class c JOIN pg_attribute a ON a.attrelid = c.oid WHERE c.oid = to_regclass(${lit(tableText)}) AND a.attname = ${lit(from)} AND NOT a.attisdropped`, + existed: () => false, + after: (b) => { + const id = oid(String(b?.rows?.[0]?.[0] ?? '')) ?? 'NULL' + const num = oid(String(b?.rows?.[0]?.[1] ?? '')) ?? 'NULL' + return `SELECT format('%I.%I', n.nspname, c.relname), quote_ident(a.attname) FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace JOIN pg_attribute a ON a.attrelid = c.oid WHERE c.oid = ${id} AND a.attnum = ${num}` + }, + made: (b, a) => { + const was = b?.rows?.[0]?.[2] + const q = a?.rows?.[0]?.[0] + const now = a?.rows?.[0]?.[1] + if (!was || !q || !now) return null + return { + revert: `ALTER TABLE ${q} RENAME COLUMN ${now} TO ${was};`, + table: String(q), + words: { title: 'Revert RENAME COLUMN', body: `Renames the column ${now} of ${q} back to ${was}.`, note: '', done: `Renamed column ${now} back to ${was}`, action: 'Rename back', destructive: false }, + } + }, + } +} + +/** @param {DdlPlan} plan @returns {DdlSteps} */ +function sqliteDdlSteps(plan) { + const lit = (/** @type {string} */ s) => textLiteral(s, 'sqlite') + const parts = (plan.table ?? plan.name).parts + const schemaRef = parts.length > 1 ? dq(parts[parts.length - 2].name) : 'main' + const schemaName = parts.length > 1 ? parts[parts.length - 2].name : 'main' + const prefix = parts.length > 1 ? `${schemaRef}.` : '' + + if (plan.ddl === 'create') { + const type = plan.objectType + const nameParts = plan.name.parts + const last = nameParts[nameParts.length - 1].name + const masterSchema = nameParts.length > 1 ? dq(nameParts[nameParts.length - 2].name) : 'main' + return { + before: `SELECT count(*) FROM ${masterSchema}.sqlite_master WHERE type = ${lit(type.toLowerCase())} AND name = ${lit(last)} COLLATE NOCASE`, + existed: (b) => Number(cell(/** @type {SqlResult} */ (b)) ?? 0) > 0, + after: null, + made: () => createWords(type, plan.name.text, `DROP ${type} ${plan.name.text};`, type === 'TABLE' ? `SELECT count(*) FROM ${plan.name.text}` : ''), + } + } + const tableLast = parts[parts.length - 1].name + if (plan.ddl === 'add-column') { + const col = /** @type {NameRef} */ (plan.column).name + return { + before: `SELECT count(*) FROM pragma_table_xinfo(${lit(tableLast)}, ${lit(schemaName)}) WHERE name = ${lit(col)} COLLATE NOCASE`, + existed: (b) => Number(cell(/** @type {SqlResult} */ (b)) ?? 0) > 0, + after: null, + made: () => ({ + revert: `ALTER TABLE ${plan.name.text} DROP COLUMN ${dq(col)};`, + table: plan.name.text, + words: { title: 'Revert ADD COLUMN', body: `Drops the column ${col} it added to ${plan.name.text}.`, note: 'Values written to it since are lost with it.', done: `Dropped column ${col} from ${plan.name.text}`, action: 'Drop column', destructive: true }, + }), + } + } + if (plan.ddl === 'rename-table') { + const to = /** @type {NameRef} */ (plan.to).name + return { + before: null, + existed: () => false, + after: null, + made: () => ({ + revert: `ALTER TABLE ${prefix}${dq(to)} RENAME TO ${dq(tableLast)};`, + table: `${prefix}${to}`, + words: { title: 'Revert RENAME', body: `Renames ${prefix}${to} back to ${tableLast}.`, note: '', done: `Renamed back to ${tableLast}`, action: 'Rename back', destructive: false }, + }), + } + } + const from = /** @type {NameRef} */ (plan.column).name + const to = /** @type {NameRef} */ (plan.to).name + return { + before: null, + existed: () => false, + after: null, + made: () => ({ + revert: `ALTER TABLE ${plan.name.text} RENAME COLUMN ${dq(to)} TO ${dq(from)};`, + table: plan.name.text, + words: { title: 'Revert RENAME COLUMN', body: `Renames the column ${to} of ${plan.name.text} back to ${from}.`, note: '', done: `Renamed column ${to} back to ${from}`, action: 'Rename back', destructive: false }, + }), + } +} + +/** + * MySQL's schema changes. Its DDL commits on its own, so the transaction only + * orders the reads around it: whether the object was there, then its full name. + * @param {DdlPlan} plan @returns {DdlSteps} + */ +function mysqlDdlSteps(plan) { + const lit = mysqlText + /** The `schema`.`name` a catalog row names, quoted. @param {string} schemaCol @param {string} nameCol */ + const quoted = (schemaCol, nameCol) => `CONCAT('\`', REPLACE(${schemaCol}, '\`', '\`\`'), '\`.\`', REPLACE(${nameCol}, '\`', '\`\`'), '\`')` + const target = plan.table ?? plan.name + const tableName = lit(target.parts[target.parts.length - 1].name) + const tableSchema = mysqlSchema(target) + const tableRow = `FROM information_schema.TABLES WHERE TABLE_SCHEMA = ${tableSchema} AND TABLE_NAME = ${tableName}` + + if (plan.ddl === 'create') { + const type = plan.objectType + const last = plan.name.parts[plan.name.parts.length - 1].name + if (type === 'INDEX') { + return { + before: `SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = ${tableSchema} AND TABLE_NAME = ${tableName} AND INDEX_NAME = ${lit(last)}`, + existed: (b) => Number(cell(/** @type {SqlResult} */ (b)) ?? 0) > 0, + after: () => `SELECT ${quoted('TABLE_SCHEMA', 'TABLE_NAME')} ${tableRow}`, + made: (_b, a) => { + const t = a ? cell(a) : null + if (!t) return null + return createWords('INDEX', `${bq(last)} on ${t}`, `DROP INDEX ${bq(last)} ON ${t};`, '') + }, + } + } + const nameRow = `FROM information_schema.TABLES WHERE TABLE_SCHEMA = ${mysqlSchema(plan.name)} AND TABLE_NAME = ${lit(last)}` + return { + before: `SELECT COUNT(*) ${nameRow}`, + existed: (b) => Number(cell(/** @type {SqlResult} */ (b)) ?? 0) > 0, + after: () => `SELECT ${quoted('TABLE_SCHEMA', 'TABLE_NAME')} ${nameRow}`, + made: (_b, a) => { + const q = a ? cell(a) : null + if (!q) return null + return createWords(type, q, `DROP ${type} ${q};`, type === 'TABLE' ? `SELECT COUNT(*) FROM ${q}` : '') + }, + } + } + + if (plan.ddl === 'add-column') { + const col = /** @type {NameRef} */ (plan.column).name + return { + before: `SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ${tableSchema} AND TABLE_NAME = ${tableName} AND COLUMN_NAME = ${lit(col)}`, + existed: (b) => Number(cell(/** @type {SqlResult} */ (b)) ?? 0) > 0, + after: () => `SELECT ${quoted('TABLE_SCHEMA', 'TABLE_NAME')} ${tableRow}`, + made: (_b, a) => { + const q = a ? cell(a) : null + if (!q) return null + return { + revert: `ALTER TABLE ${q} DROP COLUMN ${bq(col)};`, + table: q, + words: { title: 'Revert ADD COLUMN', body: `Drops the column ${col} it added to ${q}.`, note: 'Values written to it since are lost with it.', done: `Dropped column ${col} from ${q}`, action: 'Drop column', destructive: true }, + } + }, + } + } + + // Renames: the schema is read before, so the revert names the table where it is. + const schemaBefore = `SELECT REPLACE(TABLE_SCHEMA, '\`', '\`\`') ${tableRow}` + if (plan.ddl === 'rename-table') { + const from = target.parts[target.parts.length - 1].name + const to = /** @type {NameRef} */ (plan.to).name + return { + before: schemaBefore, + existed: () => false, + after: null, + made: (b) => { + const schema = b ? cell(b) : null + if (!schema) return null + const now = `\`${schema}\`.${bq(to)}` + return { + revert: `ALTER TABLE ${now} RENAME TO \`${schema}\`.${bq(from)};`, + table: now, + words: { title: 'Revert RENAME', body: `Renames ${now} back to ${from}.`, note: '', done: `Renamed back to ${from}`, action: 'Rename back', destructive: false }, + } + }, + } + } + const from = /** @type {NameRef} */ (plan.column).name + const to = /** @type {NameRef} */ (plan.to).name + return { + before: schemaBefore, + existed: () => false, + after: null, + made: (b) => { + const schema = b ? cell(b) : null + if (!schema) return null + const q = `\`${schema}\`.${bq(target.parts[target.parts.length - 1].name)}` + return { + revert: `ALTER TABLE ${q} RENAME COLUMN ${bq(to)} TO ${bq(from)};`, + table: q, + words: { title: 'Revert RENAME COLUMN', body: `Renames the column ${to} of ${q} back to ${from}.`, note: '', done: `Renamed column ${to} back to ${from}`, action: 'Rename back', destructive: false }, + } + }, + } +} + +/** + * The revert of a CREATE, in words. + * @param {DdlPlan['objectType']} type @param {string} q the object, quoted and qualified + * @param {string} revert @param {string} countSql rows it holds now, '' when it holds none + */ +function createWords(type, q, revert, countSql) { + const noun = DROP_NOUN[type] + return { + revert, + table: q, + words: { + title: `Revert CREATE ${type}`, + body: `Drops the ${noun} ${q} it created.`, + note: type === 'SCHEMA' + ? 'If anything was created in it since, the drop fails and changes nothing.' + : 'If something depends on it now, the drop fails and changes nothing.', + done: `Dropped ${noun} ${q}`, + action: `Drop ${noun}`, + destructive: true, + }, + precheck: countSql ? { sql: countSql, says: 'Dropping it deletes the {rows} it holds now.' } : undefined, + } +} + +/** + * Read the table's columns and key, check the write is one this can undo, and + * that it touches few enough rows to keep a copy of. + * @param {WritePlan} plan @param {'postgres' | 'sqlite' | 'mysql'} dialect @param {UndoIo} io + * @returns {Promise<{ fallback: true, note: string } | { m: TableMeta, setCols: ColumnMeta[], restore: ColumnMeta[], captured: ColumnMeta[] }>} + */ +async function prepare(plan, dialect, io) { + /** @type {TableMeta | string} */ + let meta + if (dialect === 'postgres') { + meta = pgReadMeta((await io.inspect(pgMetaSql(plan))).rows ?? []) + } else if (dialect === 'mysql') { + meta = mysqlReadMeta((await io.inspect(mysqlMetaSql(plan))).rows ?? []) + } else { + const [colSql, tableSql] = sqliteMetaSqls(plan) + meta = sqliteReadMeta((await io.inspect(colSql)).rows ?? [], (await io.inspect(tableSql)).rows ?? [], plan) + } + if (typeof meta === 'string') return { fallback: true, note: meta } + const m = meta + if (!m.pk.length && plan.kind !== 'delete') return { fallback: true, note: `${m.qualified} has no primary key` } + + /** @type {ColumnMeta[]} */ + const setCols = [] + if (plan.kind === 'update') { + for (const ref of plan.setColumns) { + const col = findColumn(m.columns, ref, dialect) + // An unknown column: the write fails, and the ordinary run says where. + if (!col) return { fallback: true, note: '' } + if (col.pk) return { fallback: true, note: 'it changes the primary key' } + if (!setCols.includes(col)) setCols.push(col) + } + } + // A SQLite table without a declared key keeps its rowid on the way back. + const rowid = dialect === 'sqlite' && m.pk.length === 1 && m.pk[0].name === 'rowid' && !m.columns.some((c) => c.name === 'rowid') + const restore = [...(rowid ? m.pk : []), ...m.columns.filter((c) => !c.generated)] + const captured = plan.kind === 'update' ? [...m.pk, ...setCols] : restore + if (captured.some((c) => MYSQL_SPATIAL.has(c.dataType ?? ''))) return { fallback: true, note: 'spatial values have no exact literal' } + + if (plan.kind === 'insert') { + if (plan.insertRows > UNDO_MAX_ROWS) return { fallback: true, note: `it inserts over ${UNDO_MAX_ROWS.toLocaleString()} rows` } + } else { + const size = (await io.inspect(sizeSql(plan, m, captured, dialect))).rows?.[0] ?? [] + const rows = Number(size[0] ?? 0) + const bytes = Number(size[1] ?? 0) + if (rows > UNDO_MAX_ROWS) return { fallback: true, note: `it changes over ${UNDO_MAX_ROWS.toLocaleString()} rows` } + if (bytes > UNDO_MAX_BYTES) return { fallback: true, note: 'the rows it changes are too large to keep a copy of' } + } + return { m, setCols, restore, captured } +} + +/** + * Run `plan` with an undo copy. Anything that goes wrong before the write + * commits (including the write itself failing) rolls back and asks for the + * ordinary run, which reports a failure the usual way, with its position. + * @param {WritePlan | DdlPlan} plan @param {'postgres' | 'sqlite' | 'mysql'} dialect @param {UndoIo} io + * @returns {Promise} + */ +export async function runWithUndo(plan, dialect, io) { + if (plan.kind === 'ddl') return runDdlWithUndo(plan, dialect, io) + // Inserts are Postgres only: SQLite has no INSERT inside WITH, and MySQL + // returns no keys a count could be trusted to rebuild. + if (plan.kind === 'insert' && dialect !== 'postgres') return { fallback: true, note: '' } + /** @type {{ m: TableMeta, setCols: ColumnMeta[], restore: ColumnMeta[], captured: ColumnMeta[] }} */ + let ready + try { + const prep = await prepare(plan, dialect, io) + if ('fallback' in prep) return prep + ready = prep + } catch (e) { + // Stop stops the run; anything else (an odd name, an old server) just + // means no undo copy, and the ordinary run reports any real problem. + if (/cancel/i.test(String(e))) throw e + return { fallback: true, note: '' } + } + const { m, setCols, restore, captured } = ready + + try { + await io.begin() + } catch { + return { fallback: true, note: '' } + } + /** @type {SqlResult} */ + let result + /** @type {string[]} */ + let statements = [] + let rows = 0 + try { + // A write waiting on someone else's lock can't be stopped from here: give + // up quickly and let the ordinary (stoppable) run wait instead. + if (dialect === 'postgres') await io.exec("SET LOCAL lock_timeout = '5s'") + if (plan.kind === 'insert') { + const res = await io.run(insertReturningSql(plan, m)) + const keys = /** @type {string[][]} */ ((res.rows ?? []).map((r) => r.map(String))) + rows = keys.length + statements = revertInsertSql(m, keys, dialect) + // The keys are this module's, not a result to show. + result = { ...res, columns: [], rows: [], rowCount: rows, message: null } + } else { + const before = /** @type {string[][]} */ (((await io.exec(captureSql(plan, captured, dialect))).rows ?? []).map((r) => r.map(String))) + const res = await io.run(plan.body) + result = { ...res, message: null } + rows = Number(res.rowCount ?? 0) + // MySQL counts the rows it changed, not the ones it matched: a row that + // already held the new value is in the copy and reverts to itself. + if (dialect === 'mysql' ? rows > before.length : rows !== before.length) { + // Not the rows that were read: a volatile WHERE, or something else + // writing. The write stands; it just has no undo. + await io.commit() + return { result, undo: null, note: 'the rows it changed could not be read exactly' } + } + if (plan.kind === 'delete') { + statements = revertDeleteSql(m, restore, before, dialect) + } else { + const pkN = m.pk.length + const keys = before.map((r) => r.slice(0, pkN)) + const after = /** @type {string[][]} */ (((await io.exec(afterSql(m, captured, keys, dialect))).rows ?? []).map((r) => r.map(String))) + const byKey = new Map(after.map((r) => [r.slice(0, pkN).join('\u0000'), r.slice(pkN)])) + const changed = before.map((r) => ({ + key: r.slice(0, pkN), + before: r.slice(pkN), + after: byKey.get(r.slice(0, pkN).join('\u0000')) ?? [], + })) + if (changed.some((r) => r.after.length !== setCols.length)) { + await io.commit() + return { result, undo: null, note: 'the rows it changed could not be read back' } + } + statements = revertUpdateSql(m, setCols, changed, dialect) + } + } + await io.commit() + } catch { + try { await io.rollback() } catch { /* already closed */ } + return { fallback: true, note: '' } + } + + /** @type {string[]} */ + const warnings = [] + if (m.triggers) warnings.push(`${m.qualified} has triggers. What they did when this ran is not undone, and they run again for the revert.`) + if (plan.kind === 'delete' && m.cascades) warnings.push('Rows in other tables that ON DELETE rules removed or changed are not restored.') + return { + result, + undo: rows + ? { + id: `undo-${Date.now().toString(36)}-${++seq}`, + dialect, + kind: plan.kind, + table: m.qualified, + rows, + columns: setCols.map((c) => c.name), + statements, + warnings, + sql: plan.body, + at: Date.now(), + } + : null, + note: '', + } +} + +// ── Describing a revert ────────────────────────────────────────────────────── + +/** @param {number} n @param {string} one @param {string} [many] */ +const count = (n, one, many = `${one}s`) => `${n.toLocaleString()} ${n === 1 ? one : many}` + +/** @param {string[]} names */ +function listNames(names) { + if (names.length <= 1) return names.join('') + if (names.length === 2) return `${names[0]} and ${names[1]}` + return `${names.slice(0, -1).join(', ')} and ${names.at(-1)}` +} + +/** + * The confirm dialog's words for a revert: its title, what it does, what it + * leaves alone, and the button that does it. + * @param {UndoRecord} u + * @returns {{ title: string, body: string, note: string, action: string, destructive: boolean }} + */ +export function describeUndo(u) { + if (u.words) return { title: u.words.title, body: u.words.body, note: u.words.note, action: u.words.action, destructive: u.words.destructive } + const rows = count(u.rows, 'row') + if (u.kind === 'update') { + return { + title: 'Revert UPDATE', + body: `Puts back the previous ${listNames(u.columns)} in ${rows} of ${u.table}.`, + note: 'Rows that changed again since it ran are left as they are.', + action: `Revert ${rows}`, + destructive: false, + } + } + if (u.kind === 'delete') { + return { + title: 'Revert DELETE', + body: `Inserts the ${count(u.rows, 'deleted row')} back into ${u.table}.`, + note: 'A row whose key is taken again is skipped.', + action: `Restore ${rows}`, + destructive: false, + } + } + return { + title: 'Revert INSERT', + body: `Deletes the ${rows} it inserted from ${u.table}.`, + note: 'Rows changed since are deleted too.', + action: `Delete ${rows}`, + destructive: true, + } +} + +/** + * The toast after a revert ran. + * @param {UndoRecord} u @param {number} affected rows the revert changed + * @returns {{ ok: boolean, title: string, description: string }} + */ +export function revertSummary(u, affected) { + if (u.words) return { ok: true, title: u.words.done, description: '' } + const verb = u.kind === 'insert' ? 'Deleted' : u.kind === 'delete' ? 'Restored' : 'Reverted' + if (affected >= u.rows) return { ok: true, title: `${verb} ${count(u.rows, 'row')} in ${u.table}`, description: '' } + const skipped = u.rows - affected + return { + ok: false, + title: `${verb} ${affected.toLocaleString()} of ${count(u.rows, 'row')} in ${u.table}`, + description: u.kind === 'delete' + ? `${count(skipped, 'row')} ${skipped === 1 ? 'was' : 'were'} skipped: the key is taken again.` + : `${count(skipped, 'row')} had changed since and ${skipped === 1 ? 'was' : 'were'} left alone.`, + } +} + +// ── Kept undo copies ───────────────────────────────────────────────────────── + +/** Copies kept in memory, newest last. Never written to disk: they hold row data. */ +const kept = new Map() +const KEEP = 30 + +/** @param {UndoRecord} u */ +export function keepUndo(u) { + kept.set(u.id, u) + while (kept.size > KEEP) kept.delete(kept.keys().next().value) +} + +/** @param {string} id @returns {UndoRecord | null} */ +export function getUndo(id) { + return kept.get(id) ?? null +} + +/** A revert runs once. @param {string} id */ +export function dropUndo(id) { + kept.delete(id) +} diff --git a/src/lib/sql-undo.test.js b/src/lib/sql-undo.test.js new file mode 100644 index 00000000..80f791dd --- /dev/null +++ b/src/lib/sql-undo.test.js @@ -0,0 +1,262 @@ +import { describe, it, expect } from 'vitest' +import { + parseWrite, lex, pgReadMeta, sqliteReadMeta, mysqlReadMeta, captureSql, sizeSql, insertReturningSql, + revertUpdateSql, revertDeleteSql, revertInsertSql, runWithUndo, describeUndo, revertSummary, +} from './sql-undo.js' + +describe('parseWrite', () => { + it('reads a plain UPDATE', () => { + expect(parseWrite('UPDATE users SET name = \'x\', "Email" = lower(email) WHERE id > 3;')).toMatchObject({ + kind: 'update', table: { text: 'users' }, alias: '', rowRef: 'users', + setColumns: [{ name: 'name', quoted: false }, { name: 'Email', quoted: true }], + tail: 'WHERE id > 3', + }) + }) + + it('keeps aliases, ONLY and qualified names', () => { + expect(parseWrite('update only public."Users" as u set active = false where u.id = 1')).toMatchObject({ + kind: 'update', only: true, table: { text: 'public."Users"' }, alias: 'u', rowRef: 'u', + }) + expect(parseWrite('DELETE FROM app.orders o WHERE o.total < 0')).toMatchObject({ + kind: 'delete', table: { text: 'app.orders' }, alias: 'o', tail: 'WHERE o.total < 0', + }) + }) + + it('reads multi-column SET targets', () => { + expect(parseWrite('UPDATE t SET (a, "B") = (1, 2), c = 3')?.setColumns.map((c) => c.name)).toEqual(['a', 'B', 'c']) + }) + + it('does not end a clause inside strings, comments or parentheses', () => { + const p = parseWrite("-- note\nUPDATE t SET v = 'WHERE x' /* WHERE */, w = (SELECT 1 WHERE true) WHERE id = 2") + expect(p?.setColumns.map((c) => c.name)).toEqual(['v', 'w']) + expect(p?.tail).toBe('WHERE id = 2') + }) + + it('counts INSERT rows', () => { + expect(parseWrite("INSERT INTO t (a, b) VALUES (1, '(x)'), (2, 'y')")).toMatchObject({ kind: 'insert', insertRows: 2 }) + expect(parseWrite('INSERT INTO t DEFAULT VALUES')).toMatchObject({ kind: 'insert', insertRows: 1 }) + expect(parseWrite('INSERT INTO t (a) VALUES (1) ON CONFLICT DO NOTHING')).toMatchObject({ kind: 'insert', insertRows: 1 }) + }) + + it('leaves alone what it cannot undo', () => { + for (const sql of [ + 'SELECT 1', + 'UPDATE t SET a = 1 FROM u WHERE u.id = t.id', + 'DELETE FROM t USING u WHERE u.id = t.id', + 'UPDATE t SET a = 1 RETURNING *', + 'DELETE FROM t WHERE CURRENT OF c', + 'INSERT INTO t SELECT * FROM u', + 'INSERT INTO t (a) VALUES (1) ON CONFLICT (a) DO UPDATE SET a = 2', + 'INSERT INTO t (a) VALUES (1) RETURNING id', + 'INSERT OR REPLACE INTO t (a) VALUES (1)', + 'WITH x AS (SELECT 1) UPDATE t SET a = 1', + 'UPDATE t SET a = 1; DELETE FROM t', + ]) expect(parseWrite(sql), sql).toBeNull() + }) + + it('lexes E strings and dollar quotes as single tokens', () => { + expect(lex("E'a\\'b' $x$ ; $x$ \"q\"\"r\"").map((t) => t.t)).toEqual(['string', 'string', 'ident']) + }) +}) + +const pgMeta = /** @type {import('./sql-undo.js').TableMeta} */ (pgReadMeta([ + ['id', 'bigint', 'false', 'true', 'true', 'false', 'true', 'r', 'public.users'], + ['name', 'text', 'false', 'false', 'false', 'false', 'true', 'r', 'public.users'], + ['slug', 'text', 'true', 'false', 'false', 'false', 'true', 'r', 'public.users'], +])) + +describe('Postgres SQL', () => { + it('reads the catalog rows', () => { + expect(pgMeta).toMatchObject({ qualified: 'public.users', triggers: false, cascades: true }) + expect(pgMeta.pk.map((c) => c.name)).toEqual(['id']) + expect(pgReadMeta([['a', 'int', 'false', 'false', 'false', 'false', 'false', 'v', 'public.v']])).toBe('only tables can be reverted') + }) + + it('captures with the database quoting each value', () => { + const plan = /** @type {import('./sql-undo.js').WritePlan} */ (parseWrite('UPDATE users u SET name = upper(name) WHERE u.id < 10')) + expect(captureSql(plan, [pgMeta.columns[0], pgMeta.columns[1]], 'postgres')) + .toBe('SELECT quote_nullable(u."id"), quote_nullable(u."name") FROM users AS u WHERE u.id < 10 FOR UPDATE') + expect(sizeSql(plan, pgMeta, [pgMeta.columns[0]], 'postgres')) + .toBe('SELECT count(*)::text, COALESCE(sum(COALESCE(pg_column_size(u."id"), 0)), 0)::text FROM users AS u WHERE u.id < 10') + }) + + it('returns inserted keys through a CTE', () => { + const plan = /** @type {import('./sql-undo.js').WritePlan} */ (parseWrite("INSERT INTO users (name) VALUES ('a')")) + expect(insertReturningSql(plan, pgMeta)).toBe("WITH __ins AS (\nINSERT INTO users (name) VALUES ('a')\nRETURNING quote_nullable(\"id\") AS k0\n) SELECT k0 FROM __ins") + }) + + it('reverts an UPDATE only where the written value is still there', () => { + const [sql] = revertUpdateSql(pgMeta, [pgMeta.columns[1]], [{ key: ["'1'"], before: ["'old'"], after: ["'NEW'"] }], 'postgres') + expect(sql).toContain('"name" = __v.o0::text') + expect(sql).toContain('__t."id" = __v.k0::bigint') + expect(sql).toContain('__t."name"::text IS NOT DISTINCT FROM __v.a0') + }) + + it('restores deleted rows, overriding an always-identity key', () => { + const cols = pgMeta.columns.filter((c) => !c.generated) + const [sql] = revertDeleteSql(pgMeta, cols, [["'1'", "'a'"]], 'postgres') + expect(sql).toBe('INSERT INTO public.users ("id", "name") OVERRIDING SYSTEM VALUE VALUES\n (\'1\', \'a\')\nON CONFLICT DO NOTHING;') + }) + + it('chunks long reverts', () => { + const keys = Array.from({ length: 1201 }, (_, i) => [`'${i}'`]) + expect(revertInsertSql(pgMeta, keys, 'postgres')).toHaveLength(3) + }) +}) + +describe('SQLite SQL', () => { + const plan = /** @type {import('./sql-undo.js').WritePlan} */ (parseWrite('UPDATE notes SET body = 1')) + it('falls back to the rowid without a declared key', () => { + const meta = /** @type {import('./sql-undo.js').TableMeta} */ (sqliteReadMeta([['body', 'TEXT', 0, 0]], [['table', 0]], plan)) + expect(meta.pk.map((c) => c.name)).toEqual(['rowid']) + expect(captureSql(plan, [...meta.pk, meta.columns[0]], 'sqlite')).toBe('SELECT quote(notes.rowid), quote(notes."body") FROM notes') + expect(revertUpdateSql(meta, [meta.columns[0]], [{ key: ['3'], before: ["'a'"], after: ['1'] }], 'sqlite')[0]) + .toBe('UPDATE notes SET "body" = \'a\' WHERE rowid = 3 AND "body" IS 1;') + }) +}) + +describe('runWithUndo', () => { + /** A fake database: answers by the shape of each query. */ + function fakeIo(/** @type {Record} */ over = {}) { + /** @type {string[]} */ + const log = [] + const io = { + log, + inspect: async (/** @type {string} */ sql) => { + log.push(`inspect ${sql.slice(0, 20)}`) + if (sql.includes('pg_class')) return { rows: [['id', 'integer', 'false', 'false', 'true', 'false', 'false', 'r', 'public.t'], ['v', 'text', 'false', 'false', 'false', 'false', 'false', 'r', 'public.t']] } + return { rows: [[over.count ?? '2', '10']] } + }, + begin: async () => { log.push('begin') }, + exec: async (/** @type {string} */ sql) => { + log.push(`exec ${sql.slice(0, 24)}`) + if (sql.includes('FOR UPDATE')) return { rows: [["'1'", "'a'"], ["'2'", "'b'"]] } + if (sql.startsWith('SELECT quote_nullable("id")')) return { rows: [["'1'", "'A'"], ["'2'", "'B'"]] } + return { rows: [] } + }, + run: async (/** @type {string} */ sql) => { + log.push(`run ${sql}`) + if (over.runFails) throw new Error('Query failed: boom') + return { columns: [], rows: [], rowCount: over.affected ?? 2, message: '2 rows affected (not committed yet)' } + }, + commit: async () => { log.push('commit') }, + rollback: async () => { log.push('rollback') }, + } + return io + } + + it('keeps an undo copy of an UPDATE', async () => { + const io = fakeIo() + const out = await runWithUndo(/** @type {any} */ (parseWrite('UPDATE t SET v = upper(v)')), 'postgres', io) + expect('undo' in out && out.undo).toMatchObject({ kind: 'update', table: 'public.t', rows: 2, columns: ['v'] }) + expect('result' in out && out.result.message).toBeNull() + expect(io.log.at(-1)).toBe('commit') + }) + + it('rolls back and asks for the ordinary run when the write fails', async () => { + const io = fakeIo({ runFails: true }) + expect(await runWithUndo(/** @type {any} */ (parseWrite('UPDATE t SET v = 1')), 'postgres', io)).toEqual({ fallback: true, note: '' }) + expect(io.log.at(-1)).toBe('rollback') + }) + + it('keeps the write but no copy when the counts disagree', async () => { + const out = await runWithUndo(/** @type {any} */ (parseWrite('UPDATE t SET v = 1')), 'postgres', fakeIo({ affected: 3 })) + expect(out).toMatchObject({ undo: null, note: 'the rows it changed could not be read exactly' }) + }) + + it('skips the copy for big writes and key changes', async () => { + expect(await runWithUndo(/** @type {any} */ (parseWrite('DELETE FROM t')), 'postgres', fakeIo({ count: '20000' }))) + .toEqual({ fallback: true, note: 'it changes over 10,000 rows' }) + expect(await runWithUndo(/** @type {any} */ (parseWrite('UPDATE t SET id = 5')), 'postgres', fakeIo())) + .toEqual({ fallback: true, note: 'it changes the primary key' }) + }) + + it('lets Stop through', async () => { + const io = fakeIo() + io.inspect = async () => { throw new Error('Query cancelled') } + await expect(runWithUndo(/** @type {any} */ (parseWrite('UPDATE t SET v = 1')), 'postgres', io)).rejects.toThrow('cancelled') + }) +}) + +describe('describing', () => { + const u = /** @type {import('./sql-undo.js').UndoRecord} */ ({ id: 'x', dialect: 'postgres', kind: 'update', table: 'public.users', rows: 6, columns: ['name', 'email'], statements: [], warnings: [], sql: '', at: 0 }) + it('says what a revert does', () => { + expect(describeUndo(u)).toMatchObject({ title: 'Revert UPDATE', body: 'Puts back the previous name and email in 6 rows of public.users.', action: 'Revert 6 rows', destructive: false }) + expect(revertSummary(u, 6)).toEqual({ ok: true, title: 'Reverted 6 rows in public.users', description: '' }) + expect(revertSummary(u, 4)).toMatchObject({ ok: false, title: 'Reverted 4 of 6 rows in public.users' }) + }) +}) + +describe('schema changes', () => { + it('reads the CREATEs it can undo', () => { + expect(parseWrite('CREATE TABLE test (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, "column" text NOT NULL);')) + .toMatchObject({ kind: 'ddl', ddl: 'create', objectType: 'TABLE', name: { text: 'test' } }) + expect(parseWrite('create unique index if not exists users_email on public.users (lower(email))')) + .toMatchObject({ ddl: 'create', objectType: 'INDEX', name: { text: 'users_email' }, table: { text: 'public.users' } }) + expect(parseWrite('CREATE MATERIALIZED VIEW app.totals AS SELECT 1')).toMatchObject({ objectType: 'MATERIALIZED VIEW', name: { text: 'app.totals' } }) + expect(parseWrite('CREATE UNLOGGED TABLE scratch (a int)')).toMatchObject({ objectType: 'TABLE' }) + }) + + it('reads single ALTER TABLE actions', () => { + expect(parseWrite('ALTER TABLE users ADD COLUMN nickname text DEFAULT \'\'')).toMatchObject({ ddl: 'add-column', column: { name: 'nickname' }, table: { text: 'users' } }) + expect(parseWrite('alter table only app.items add "Qty2" int')).toMatchObject({ ddl: 'add-column', column: { name: 'Qty2', quoted: true } }) + expect(parseWrite('ALTER TABLE users RENAME TO members')).toMatchObject({ ddl: 'rename-table', to: { name: 'members' } }) + expect(parseWrite('ALTER TABLE users RENAME COLUMN name TO full_name')).toMatchObject({ ddl: 'rename-column', column: { name: 'name' }, to: { name: 'full_name' } }) + }) + + it('leaves alone what it cannot undo', () => { + for (const sql of [ + 'CREATE OR REPLACE VIEW v AS SELECT 1', + 'CREATE TEMP TABLE t (a int)', + 'CREATE INDEX ON t (a)', + 'CREATE INDEX CONCURRENTLY i ON t (a)', + 'CREATE SCHEMA AUTHORIZATION bob', + 'CREATE FUNCTION f() RETURNS int AS $$ SELECT 1 $$ LANGUAGE sql', + 'ALTER TABLE t ADD CONSTRAINT c CHECK (a > 0)', + 'ALTER TABLE t ADD PRIMARY KEY (a)', + 'ALTER TABLE t ADD a int, ADD b int', + 'ALTER TABLE t DROP COLUMN a', + 'ALTER TABLE t RENAME CONSTRAINT a TO b', + 'DROP TABLE t', + ]) expect(parseWrite(sql), sql).toBeNull() + }) + + it('describes a schema revert in its own words', () => { + const u = /** @type {import('./sql-undo.js').UndoRecord} */ ({ id: 'x', dialect: 'postgres', kind: 'ddl', table: 'public.test', rows: 0, columns: [], statements: ['DROP TABLE public.test;'], warnings: [], sql: '', at: 0, + words: { title: 'Revert this CREATE TABLE?', body: 'Drops the table public.test it created.', note: 'n', done: 'Dropped table public.test', action: 'Drop table', destructive: true } }) + expect(describeUndo(u).title).toBe('Revert this CREATE TABLE?') + expect(describeUndo(u).action).toBe('Drop table') + expect(revertSummary(u, 0)).toEqual({ ok: true, title: 'Dropped table public.test', description: '' }) + }) +}) + +describe('MySQL', () => { + const meta = /** @type {import('./sql-undo.js').TableMeta} */ (mysqlReadMeta([ + ['id', 'bigint', 'bigint', 'PRI', 'auto_increment', 'BASE TABLE', 'InnoDB', 0, 1, '`t`.`w`'], + ['s', 'varchar(20)', 'varchar', '', '', 'BASE TABLE', 'InnoDB', 0, 1, '`t`.`w`'], + ['b', 'blob', 'blob', '', '', 'BASE TABLE', 'InnoDB', 0, 1, '`t`.`w`'], + ['bt', 'bit(5)', 'bit', '', '', 'BASE TABLE', 'InnoDB', 0, 1, '`t`.`w`'], + ])) + + it('reads the catalog and refuses tables that cannot roll back', () => { + expect(meta).toMatchObject({ qualified: '`t`.`w`', cascades: true }) + expect(mysqlReadMeta([['id', 'int', 'int', 'PRI', '', 'BASE TABLE', 'MyISAM', 0, 0, '`t`.`m`']])).toBe('only InnoDB tables can be reverted, and this one is MyISAM') + }) + + it('captures values as hex the sql_mode cannot misread, and quotes names with backticks', () => { + const plan = /** @type {import('./sql-undo.js').WritePlan} */ (parseWrite('UPDATE `w` SET s = 1, b = 2, bt = 3 WHERE id = 1')) + const sql = captureSql(plan, meta.columns, 'mysql') + expect(sql).toContain("IF(`w`.`id` IS NULL, 'NULL', CAST(`w`.`id` AS CHAR))") + expect(sql).toContain("CONCAT('CONVERT(X''', HEX(CAST(`w`.`s` AS CHAR)), ''' USING utf8mb4)')") + expect(sql).toContain("CONCAT('X''', HEX(`w`.`b`), '''')") + expect(sql).toContain('CAST(`w`.`bt` + 0 AS CHAR)') + expect(sql.endsWith('FOR UPDATE')).toBe(true) + }) + + it('reverts with backticks, a byte-exact guard and a no-op on taken keys', () => { + const [upd] = revertUpdateSql(meta, [meta.columns[1], meta.columns[3]], [{ key: ['1'], before: ["CONVERT(X'61' USING utf8mb4)", '5'], after: ["CONVERT(X'62' USING utf8mb4)", '6'] }], 'mysql') + expect(upd).toBe("UPDATE `t`.`w` SET `s` = CONVERT(X'61' USING utf8mb4), `bt` = 5 WHERE `id` = 1 AND HEX(CAST(`s` AS CHAR)) <=> HEX(CAST(CONVERT(X'62' USING utf8mb4) AS CHAR)) AND `bt` + 0 <=> 6;") + const [del] = revertDeleteSql(meta, meta.columns, [['1', "CONVERT(X'61' USING utf8mb4)", "X''", '0']], 'mysql') + expect(del).toBe("INSERT INTO `t`.`w` (`id`, `s`, `b`, `bt`) VALUES\n (1, CONVERT(X'61' USING utf8mb4), X'', 0)\nON DUPLICATE KEY UPDATE `id` = `id`;") + }) +}) 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 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') diff --git a/src/lib/table-query.js b/src/lib/table-query.js index 145c1ba8..781b1f1e 100644 --- a/src/lib/table-query.js +++ b/src/lib/table-query.js @@ -79,6 +79,26 @@ export function fetchLimitFor(pageSize, total, totalIsForThisView) { return totalIsForThisView && total > 0 ? Math.min(total, MAX_PAGE_SIZE) : MAX_PAGE_SIZE } +/** + * The page after a row is added at its top, as the grid shows an insert. + * + * A page of a fixed size keeps its size: the last row moves on to the next + * page. "All" has no next page, so it grows by the row. It used to be cut to + * its fetch limit like any page, and with "All" that limit is the row count + * from before the insert: the new row went in at the top and the last row + * dropped off the bottom, so the grid no longer matched the table. + * @template T + * @param {T[]} rows the page as shown + * @param {T} row the inserted row + * @param {number} pageSize the chosen size, or PAGE_SIZE_ALL + * @param {number} limit the page's fetch limit (fetchLimitFor) + * @returns {T[]} + */ +export function pageWithInsertedRow(rows, row, pageSize, limit) { + const next = [row, ...rows] + return pageSize !== PAGE_SIZE_ALL && next.length > limit ? next.slice(0, limit) : next +} + /** Clamp an arbitrary input to a valid page size (or the "All" sentinel). */ export function clampPageSize(/** @type {unknown} */ n) { const num = Number(n) diff --git a/src/lib/table-query.test.js b/src/lib/table-query.test.js index 585403ca..3f8a4060 100644 --- a/src/lib/table-query.test.js +++ b/src/lib/table-query.test.js @@ -8,6 +8,7 @@ import { createFilter, clampPageSize, fetchLimitFor, + pageWithInsertedRow, pageSizeLabel, readRowsResponse, ANY_COLUMN, @@ -329,3 +330,20 @@ describe('fetchLimitFor', () => { expect(fetchLimitFor(PAGE_SIZE_ALL, MAX_PAGE_SIZE * 3, true)).toBe(MAX_PAGE_SIZE) }) }) + +describe('pageWithInsertedRow', () => { + const page = [[1], [2], [3]] + + it('keeps every row on "All", where the limit is the count from before the insert', () => { + // The limit for "All" is the row count (3); the insert makes 4. + expect(pageWithInsertedRow(page, [4], PAGE_SIZE_ALL, fetchLimitFor(PAGE_SIZE_ALL, 3, true))).toEqual([[4], [1], [2], [3]]) + }) + + it('keeps a fixed-size page at its size, the last row moving to the next page', () => { + expect(pageWithInsertedRow(page, [4], 3, 3)).toEqual([[4], [1], [2]]) + }) + + it('grows a page that is not full yet', () => { + expect(pageWithInsertedRow(page, [4], 50, 50)).toEqual([[4], [1], [2], [3]]) + }) +}) 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 +} 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') + }) +}) 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 } +} 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') + }) +}) 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 } +} 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)', + } +}