diff --git a/.changeset/fix-bug-v2.0.0.md b/.changeset/fix-bug-v2.0.0.md new file mode 100644 index 00000000..da482460 --- /dev/null +++ b/.changeset/fix-bug-v2.0.0.md @@ -0,0 +1,19 @@ +### New Features +- Cell values open in a code editor with line numbers, folding, and find and replace +- ER diagram: Ctrl+scroll zooms, scroll pans, Shift+scroll pans sideways +- Build release installers locally with `npm run tauri:build:mac` and `npm run tauri:build:win` + +### Bug Fixes +- Window no longer flickers on startup +- A quick reconnect on startup opens straight onto your tables +- Closing the window quits Stroke instead of leaving it running in the tray +- No clipboard permission prompt on Windows when opening the connect dialog +- Grid no longer flickers when switching tables or opening related rows +- Related rows stay on screen while the next lookup loads +- Hide all in the columns menu stays available after relationship columns load +- Shift+Tab moves up the sidebar, database and connection lists on Linux +- Clicking a saved connection no longer leaves a focus outline on it +- License activation no longer stutters or drops confetti over the app + +### Changes +- The new tab no longer repeats connection details already shown in the title and status bar diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d2553ab3..3749b116 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,7 +56,10 @@ jobs: # Extract changelog for this version from CHANGELOG.md. # Grabs everything between the matching "## [X.Y.Z]" header and the next one. - VERSION="${TAG#v}" + # VERSION goes in the environment: it used to trail the script as + # `VERSION=...`, which node receives as an argument, so the script + # looked for "## [undefined]" and every release said "No changelog". + export VERSION="${TAG#v}" CHANGELOG=$(node -e " const fs = require('fs') const lines = fs.readFileSync('CHANGELOG.md', 'utf8').split('\n') @@ -69,7 +72,7 @@ jobs: if (inside) out.push(l) } process.stdout.write(out.join('\n').trim()) - " VERSION="$VERSION") + ") cat > /tmp/notes.md << EOF ${CHANGELOG:-No changelog available for this release.} @@ -78,7 +81,7 @@ jobs: ### Install - **Easiest — package managers (no security warnings):** + **Package managers (no security warnings):** \`\`\`sh # macOS @@ -91,13 +94,13 @@ jobs: **Or download directly below:** - **macOS** — \`stroke_*_aarch64.dmg\` (Apple Silicon) · \`stroke_*_x64.dmg\` (Intel) + **macOS:** \`stroke_*_aarch64.dmg\` (Apple Silicon) · \`stroke_*_x64.dmg\` (Intel) > Ad-hoc signed. If macOS says it's "damaged", run once: \`xattr -cr /Applications/Stroke.app\` - **Windows** — \`stroke_*_x64-setup.exe\` (recommended) · \`.msi\` - > Unsigned. On SmartScreen, click **More info → Run anyway**. + **Windows:** \`stroke_*_x64-setup.exe\` (recommended) · \`.msi\` + > Unsigned. On SmartScreen, click **More info**, then **Run anyway**. - **Linux** — \`.deb\` · \`.rpm\` · \`.AppImage\` + **Linux:** \`.deb\` · \`.rpm\` · \`.AppImage\` Built from \`${SHA}\`. EOF diff --git a/README.md b/README.md index bdaa3ae4..1c7e268c 100644 --- a/README.md +++ b/README.md @@ -418,6 +418,20 @@ Or use the included Arch helper: npm run tauri:build:arch ``` +**Release installers on your own machine** — no CI needed: + +```bash +npm run tauri:build:mac # macOS .app + .dmg (on a Mac) +npm run tauri:build:win # Windows x64 setup .exe - native on Windows, + # cross-compiled from macOS/Linux +npm run tauri:build:local -- mac-x64 # also: host (default), linux +``` + +The Windows cross-build needs NSIS, LLVM and `cargo-xwin` (macOS: +`brew install nsis llvm && cargo install --locked cargo-xwin`); the script checks +for each and prints what is missing. macOS and Linux bundles only build on their +own OS. Details and the DuckDB workaround are in `scripts/build-local.mjs`. + --- ## Contributing diff --git a/index.html b/index.html index 87bb48ad..1823a4b4 100644 --- a/index.html +++ b/index.html @@ -27,7 +27,17 @@ theme = prefersDark ? 'dark' : 'light' } document.documentElement.dataset.theme = theme - if (!lightThemes.includes(theme)) document.documentElement.classList.add('dark') + const isLight = lightThemes.includes(theme) + if (!isLight) document.documentElement.classList.add('dark') + // The page boots at opacity 0 and stays there until the first finished + // screen, so must carry a painted background of its own - with + // nothing but the transition below, a transparent root shows the + // webview's white backdrop for the whole of startup. Inline, because the + // theme stylesheet arrives with the bundle, well after the first paint. + // These two are the base light/dark --background, and they match + // LIGHT_SURFACE / DARK_SURFACE in src-tauri/src/lib.rs so the window, + // the webview and the page all come up the same colour. + document.documentElement.style.backgroundColor = isLight ? '#f7f7f7' : '#080808' let zoom = 1 if (parsed?.zoom != null) zoom = Number(parsed.zoom) else if (parsed?.fontSize != null) zoom = Number(parsed.fontSize) / 14 @@ -41,6 +51,7 @@ const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches !== false document.documentElement.dataset.theme = prefersDark ? 'dark' : 'light' if (prefersDark) document.documentElement.classList.add('dark') + document.documentElement.style.backgroundColor = prefersDark ? '#080808' : '#f7f7f7' } ;(function () { var ua = navigator.userAgent || '' diff --git a/package-lock.json b/package-lock.json index b1b91447..1004f990 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,21 @@ { "name": "stroke", - "version": "1.24.0", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "stroke", - "version": "1.24.0", + "version": "2.0.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { + "@codemirror/commands": "^6.11.1", + "@codemirror/lang-html": "^6.4.12", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/language": "^6.12.4", + "@codemirror/search": "^6.7.2", + "@codemirror/state": "^6.7.6", + "@codemirror/view": "^6.43.13", "@dagrejs/dagre": "^3.0.0", "@fontsource-variable/fira-code": "^5.2.7", "@fontsource-variable/geist": "^5.2.9", @@ -21,6 +28,7 @@ "@fontsource/ibm-plex-sans": "^5.2.8", "@hugeicons/core-free-icons": "^4.2.2", "@hugeicons/svelte": "^1.1.4", + "@lezer/highlight": "^1.2.4", "@tailwindcss/vite": "^4.3.0", "@tanstack/svelte-hotkeys": "^0.10.0", "@tauri-apps/plugin-autostart": "^2.5.1", @@ -94,6 +102,142 @@ "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", "license": "Apache-2.0" }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.3", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz", + "integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-css": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", + "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.1.7" + } + }, + "node_modules/@codemirror/lang-html": { + "version": "6.4.12", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.12.tgz", + "integrity": "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.12" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", + "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-json": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", + "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.2.tgz", + "integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.6", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz", + "integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.13", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.13.tgz", + "integrity": "sha512-sihaFrUzAsYBQsL9J2t69y8nfMQGwcYmggAZsk+kjPbjYZMyuf2hU8tUNTZ+P+isb6XRr8JE22TZlJxBoVdH1A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@dagrejs/dagre": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-3.0.0.tgz", @@ -115,6 +259,7 @@ "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" @@ -126,6 +271,7 @@ "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -291,6 +437,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" } @@ -346,6 +493,74 @@ "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", "license": "MIT" }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/css": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.8.tgz", + "integrity": "sha512-EJn1zcL9qoDptief6ipWKZKLiOpXkxSe0+t8CH9oiMVcZlq7NBWrjCqnc/41EIjeo/ITj1gFFiATdTkaJDL+Og==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/highlight": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.4.tgz", + "integrity": "sha512-4UeRVFO0lsBaM7GA32zOqbmqNPme9jedo8Ey+5c5656MU3MLitMLIA8aCZTYjUMEpEsuSeXEm78Uea6Iq3bvSg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/html": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", + "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.5.tgz", + "integrity": "sha512-sWg4yX1J6XW67AaAynVt0iwF0M5c+np36TEu+P2ifAJ8haRYvHnWDV28r1jdwnJehWCwXECutAUy56K4RBZIyg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, "node_modules/@lucide/svelte": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.16.0.tgz", @@ -356,6 +571,12 @@ "svelte": "^5" } }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz", + "integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==", + "license": "MIT" + }, "node_modules/@mermaid-js/parser": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.0.tgz", @@ -461,9 +682,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -480,9 +698,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -499,9 +714,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -518,9 +730,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -537,9 +746,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -556,9 +762,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1865,6 +2068,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2043,6 +2247,12 @@ "layout-base": "^1.0.0" } }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, "node_modules/cross-env": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", @@ -2081,6 +2291,7 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.4.tgz", "integrity": "sha512-HIN5Pmd9MrX9BkV7tDwnOcEJCSFvCpc8X97h3f508J6I5FsqAY65wKOCvgH2CuP42CaahWaz4tuh32SOOIH7ww==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10" } @@ -2490,6 +2701,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" } @@ -3642,6 +3854,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" }, @@ -3968,6 +4181,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/style-mod": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz", + "integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==", + "license": "MIT" + }, "node_modules/style-to-object": { "version": "1.0.14", "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", @@ -3988,6 +4207,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", @@ -4047,6 +4267,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" @@ -4075,7 +4296,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", @@ -4280,6 +4502,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", @@ -4488,9 +4711,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4511,9 +4731,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4534,9 +4751,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4557,9 +4771,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4723,6 +4934,12 @@ } } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 9e4e9626..04824c3b 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,9 @@ "tauri:fresh": "VITE_FRESH_START=1 tauri dev", "tauri:build": "tauri build", "tauri:build:arch": "cross-env NO_STRIP=1 tauri build", + "tauri:build:local": "node scripts/build-local.mjs", + "tauri:build:mac": "node scripts/build-local.mjs mac", + "tauri:build:win": "node scripts/build-local.mjs windows", "fixtures": "bash scripts/fixtures.sh", "fixtures:status": "bash scripts/fixtures.sh status", "fixtures:reset": "bash scripts/fixtures.sh reset", @@ -43,6 +46,13 @@ "vitest": "^4.1.11" }, "dependencies": { + "@codemirror/commands": "^6.11.1", + "@codemirror/lang-html": "^6.4.12", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/language": "^6.12.4", + "@codemirror/search": "^6.7.2", + "@codemirror/state": "^6.7.6", + "@codemirror/view": "^6.43.13", "@dagrejs/dagre": "^3.0.0", "@fontsource-variable/fira-code": "^5.2.7", "@fontsource-variable/geist": "^5.2.9", @@ -55,6 +65,7 @@ "@fontsource/ibm-plex-sans": "^5.2.8", "@hugeicons/core-free-icons": "^4.2.2", "@hugeicons/svelte": "^1.1.4", + "@lezer/highlight": "^1.2.4", "@tailwindcss/vite": "^4.3.0", "@tanstack/svelte-hotkeys": "^0.10.0", "@tauri-apps/plugin-autostart": "^2.5.1", diff --git a/scripts/build-local.mjs b/scripts/build-local.mjs new file mode 100644 index 00000000..d15beebd --- /dev/null +++ b/scripts/build-local.mjs @@ -0,0 +1,176 @@ +#!/usr/bin/env node +/** + * Stroke — local release builds + * ============================= + * Build installers on your own machine instead of CI. Plain Node, no deps. + * + * node scripts/build-local.mjs [target] [-- extra tauri build args] + * + * Targets: + * host (default) whatever this machine builds natively + * mac macOS, this Mac's architecture → .app + .dmg (macOS only) + * mac-x64 macOS Intel (x86_64-apple-darwin) → .app + .dmg (macOS only) + * windows Windows x64 NSIS installer → *-setup.exe + * native on Windows; cross-compiled with cargo-xwin on macOS/Linux + * linux .deb / .rpm / .AppImage (Linux only) + * + * Examples: + * npm run tauri:build:win + * npm run tauri:build:local -- mac-x64 + * npm run tauri:build:local -- windows -- --debug + * + * Cross-building Windows from macOS/Linux needs NSIS, LLVM (clang-cl, llvm-rc) + * and cargo-xwin - linking uses the rust-lld that ships with Rust. The script + * checks for each and says how to install what is missing. macOS and Linux + * bundles can only be built on their own OS (Tauri needs the platform's webview + * SDK), so those targets refuse elsewhere. + * + * DuckDB (bundled C++) does not compile under clang-cl as shipped: it marks + * deleted functions `__declspec(dllexport)`, which clang rejects. Defining + * DUCKDB_STATIC_BUILD empties that macro - correct here, as it links statically. + * It goes in through `CL`, the one channel left: cargo-xwin overwrites + * CXXFLAGS_, and `_CL_` is appended after `--`, where clang-cl reads it + * as a file name. + */ + +import { spawnSync } from 'node:child_process' +import { existsSync, readdirSync, statSync } from 'node:fs' +import { delimiter, join, resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const HOST = process.platform // 'darwin' | 'win32' | 'linux' +const IS_WIN = HOST === 'win32' +const WIN_TRIPLE = 'x86_64-pc-windows-msvc' + +const argv = process.argv.slice(2) +const sep = argv.indexOf('--') +const ownArgs = sep === -1 ? argv : argv.slice(0, sep) +const passthrough = sep === -1 ? [] : argv.slice(sep + 1) +const target = (ownArgs[0] ?? 'host').toLowerCase() + +/** @param {string} msg */ +function fail(msg) { + console.error(`\n✖ ${msg}\n`) + process.exit(1) +} + +/** @param {string} cmd @param {NodeJS.ProcessEnv} env */ +function has(cmd, env = process.env) { + const r = spawnSync(IS_WIN ? 'where' : 'which', [cmd], { env, stdio: 'ignore' }) + return r.status === 0 +} + +/** @param {string} cmd @param {string[]} args @param {NodeJS.ProcessEnv} env */ +function run(cmd, args, env = process.env) { + console.log(`\n$ ${cmd} ${args.join(' ')}\n`) + const r = spawnSync(cmd, args, { cwd: ROOT, env, stdio: 'inherit', shell: IS_WIN }) + if (r.status !== 0) process.exit(r.status ?? 1) +} + +/** @param {string} triple */ +function ensureRustTarget(triple) { + const r = spawnSync('rustup', ['target', 'list', '--installed'], { encoding: 'utf8' }) + if (r.status !== 0) fail('rustup not found - install Rust from https://rustup.rs') + if (!r.stdout.split('\n').includes(triple)) run('rustup', ['target', 'add', triple]) +} + +/** @param {string[]} tauriArgs @param {NodeJS.ProcessEnv} [env] */ +function tauriBuild(tauriArgs, env) { + run('npx', ['tauri', 'build', ...tauriArgs, ...passthrough], env) +} + +/** Every installer/bundle under a bundle dir, one level of subfolders deep. @param {string} dir */ +function listArtifacts(dir) { + if (!existsSync(dir)) return [] + const out = [] + for (const kind of readdirSync(dir)) { + const sub = join(dir, kind) + if (!statSync(sub).isDirectory()) continue + for (const f of readdirSync(sub)) { + if (/\.(dmg|app|exe|msi|deb|rpm|AppImage)$/.test(f)) out.push(join(sub, f)) + } + } + return out +} + +/** @param {string | null} triple */ +function report(triple) { + const dir = join(ROOT, 'src-tauri', 'target', ...(triple ? [triple] : []), 'release', 'bundle') + const files = listArtifacts(dir) + console.log('\n✔ Build finished.' + (files.length ? ' Artifacts:' : '')) + for (const f of files) console.log(` ${f}`) +} + +function buildMac(/** @type {string | null} */ triple) { + if (HOST !== 'darwin') fail('macOS bundles can only be built on macOS.') + if (triple) ensureRustTarget(triple) + tauriBuild(triple ? ['--target', triple] : []) + report(triple) +} + +function buildLinux() { + if (HOST !== 'linux') { + fail('Linux bundles can only be built on Linux (they link against webkit2gtk). Use a Linux machine or VM.') + } + tauriBuild([]) + report(null) +} + +function buildWindows() { + if (IS_WIN) { + tauriBuild([]) + report(null) + return + } + + const env = { ...process.env } + // Homebrew's llvm is keg-only: installed, but not on PATH. + if (HOST === 'darwin' && !has('clang-cl', env)) { + const brewLlvm = ['/opt/homebrew/opt/llvm/bin', '/usr/local/opt/llvm/bin'].find((p) => existsSync(join(p, 'clang-cl'))) + if (brewLlvm) env.PATH = `${brewLlvm}${delimiter}${env.PATH ?? ''}` + } + + const mac = HOST === 'darwin' + const missing = [ + ['makensis', mac ? 'brew install nsis' : 'sudo apt install nsis'], + ['clang-cl', mac ? 'brew install llvm' : 'sudo apt install clang llvm'], + ['llvm-rc', mac ? 'brew install llvm' : 'sudo apt install llvm'], + ['cargo-xwin', 'cargo install --locked cargo-xwin'], + ].filter(([cmd]) => !has(cmd, env)) + if (missing.length) { + const hints = [...new Set(missing.map(([, hint]) => hint))] + fail(`Missing tools for the Windows cross-build: ${missing.map(([c]) => c).join(', ')}\n Install with:\n ${hints.join('\n ')}`) + } + + ensureRustTarget(WIN_TRIPLE) + env.CL = [env.CL, '/DDUCKDB_STATIC_BUILD'].filter(Boolean).join(' ') + // First run downloads the MSVC CRT + Windows SDK (~700MB) into the cargo-xwin + // cache; cargo-xwin accepts the Microsoft license on your behalf. + tauriBuild(['--runner', 'cargo-xwin', '--target', WIN_TRIPLE], env) + report(WIN_TRIPLE) +} + +switch (target) { + case 'host': + if (HOST === 'darwin') buildMac(null) + else if (IS_WIN) buildWindows() + else buildLinux() + break + case 'mac': + case 'macos': + buildMac(null) + break + case 'mac-x64': + buildMac('x86_64-apple-darwin') + break + case 'win': + case 'windows': + buildWindows() + break + case 'linux': + buildLinux() + break + default: + fail(`Unknown target "${target}". Use one of: host, mac, mac-x64, windows, linux.`) +} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index fb53aee1..75930a1c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -116,6 +116,7 @@ dependencies = [ "duckdb", "ed25519-dalek", "futures", + "gdk", "getrandom 0.2.17", "hex", "hmac", @@ -149,6 +150,9 @@ dependencies = [ "tower-http 0.5.2", "urlencoding", "uuid", + "webkit2gtk", + "webview2-com", + "windows-core 0.61.2", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1cabdc24..7a9bbbe6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -21,7 +21,7 @@ tauri-build = { version = "2.6.2", features = [] } serde_json = { version = "1.0", features = ["preserve_order"] } serde = { version = "1.0", features = ["derive"] } log = "0.4" -tauri = { version = "2.11.2", features = ["macos-private-api", "tray-icon", "image-png"] } +tauri = { version = "2.11.2", features = ["macos-private-api", "image-png"] } tauri-plugin-opener = "2" tauri-plugin-log = "2" tauri-plugin-updater = "2" @@ -71,6 +71,19 @@ objc2-app-kit = { version = "0.3", features = ["NSWindow", "NSView", "NSColor", objc2-quartz-core = { version = "0.3", features = ["CALayer"] } objc2-web-kit = { version = "0.3", features = ["WKWebView"] } +# Same job as the macOS block above, for the other two engines: paint the +# webview's pre-paint backdrop so startup never flashes white. +# (set_windows_webview_backdrop / set_linux_webview_backdrop in lib.rs.) +# Both are already in the tree via tauri/wry - pinned here to the versions wry +# resolves so this never links a second copy of either binding. +[target.'cfg(target_os = "windows")'.dependencies] +webview2-com = "0.38" +windows-core = "0.61" + +[target.'cfg(target_os = "linux")'.dependencies] +webkit2gtk = { version = "=2.0.2", features = ["v2_38"] } +gdk = "0.18" + [dev-dependencies] # `start_paused` in #[tokio::test] - lets the connect-deadline test fire the # timeout on a virtual clock instead of waiting CONNECT_DEADLINE for real. diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 052ee223..1d50b54f 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -288,6 +288,8 @@ pub fn open_new_window(app: tauri::AppHandle) -> Result<(), String> { .inner_size(1280.0, 800.0) .min_inner_size(960.0, 600.0) .resizable(true) + // Hidden until its page reveals itself, same as the main window. + .visible(false) .background_color(tauri::window::Color(8, 8, 8, 255)); #[cfg(target_os = "macos")] @@ -302,11 +304,22 @@ pub fn open_new_window(app: tauri::AppHandle) -> Result<(), String> { } let window = builder.build().map_err(|e| e.to_string())?; - let _ = window.show(); - let _ = window.set_focus(); + crate::arm_reveal_failsafe(&window); Ok(()) } +/// Show the calling window. Windows are built hidden and the frontend calls this +/// on its first finished screen (src/lib/app-reveal.js). An app command rather +/// than the core `window.show` so it works from every window label - the +/// capability file only grants core window permissions to `main`. +#[tauri::command] +pub fn reveal_window(window: tauri::WebviewWindow) { + if !window.is_visible().unwrap_or(false) { + let _ = window.show(); + let _ = window.set_focus(); + } +} + /// Restart the application - called after an update is installed. #[tauri::command] pub fn restart_app(app: tauri::AppHandle) { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 36a955e6..62709b47 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -28,11 +28,7 @@ mod web_search; use db::{ActiveConnection, DbState, TunnelState}; use mcp::McpState; use std::sync::{Arc, Mutex}; -use tauri::{ - menu::{Menu, MenuItem, PredefinedMenuItem}, - tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, - Manager, -}; +use tauri::Manager; // The surface behind the page, shown whenever the webview has yet to composite a // frame - a cold start, a reload, or any moment the UI is mid-repaint. The @@ -51,6 +47,96 @@ fn surface_for_theme(theme: tauri::Theme) -> tauri::window::Color { } } +/// How long a window may stay hidden before it is shown regardless. +/// +/// Windows are built hidden and the frontend shows them on its first finished +/// screen (src/lib/app-reveal.js). The frontend has its own 2.5s failsafe; this +/// one covers the case where no JS runs at all (a broken bundle, a webview that +/// never loads), which would otherwise leave a live process with no window. +const REVEAL_FAILSAFE: std::time::Duration = std::time::Duration::from_secs(4); + +pub(crate) fn arm_reveal_failsafe(window: &tauri::WebviewWindow) { + let window = window.clone(); + std::thread::spawn(move || { + std::thread::sleep(REVEAL_FAILSAFE); + if !window.is_visible().unwrap_or(true) { + let _ = window.show(); + } + }); +} + +/// Paint the webview's own backdrop, whichever webview this platform uses. +/// +/// The window background set by `set_background_color` is behind the webview +/// widget, and the widget fills the window - so what the user actually sees +/// before the page paints is the *webview's* backdrop, which defaults to white +/// on all three engines. The page then boots at `opacity: 0` (index.html) and +/// stays there until the first finished screen, so that white sits on screen +/// for the whole startup, not just a frame. +/// +/// Each engine names the knob differently and none of them is reachable through +/// Tauri's own API: +/// - WKWebView (macOS): `underPageBackgroundColor` +/// - WebView2 (Windows): `ICoreWebView2Controller2::DefaultBackgroundColor` +/// - WebKitGTK (Linux): `webkit_web_view_set_background_color` +fn set_webview_backdrop(window: &tauri::WebviewWindow, color: tauri::window::Color) { + #[cfg(target_os = "macos")] + set_macos_webview_backdrop(window, color); + #[cfg(target_os = "windows")] + set_windows_webview_backdrop(window, color); + #[cfg(target_os = "linux")] + set_linux_webview_backdrop(window, color); + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + let _ = (window, color); +} + +/// WebView2's pre-paint colour. +/// +/// `DefaultBackgroundColor` lives on `ICoreWebView2Controller2`, a later +/// revision of the controller Tauri hands back, so the interface is queried for +/// rather than assumed - on a runtime too old to carry it the cast fails and the +/// backdrop stays at the default instead of the call being a hard error. +#[cfg(target_os = "windows")] +fn set_windows_webview_backdrop(window: &tauri::WebviewWindow, color: tauri::window::Color) { + use webview2_com::Microsoft::Web::WebView2::Win32::{ + ICoreWebView2Controller2, COREWEBVIEW2_COLOR, + }; + use windows_core::Interface; + + let tauri::window::Color(r, g, b, a) = color; + let _ = window.with_webview(move |webview| { + if let Ok(controller) = webview.controller().cast::() { + // A is the alpha channel of the backdrop itself: a translucent value + // would let the (white) host window show through again, so it stays + // fully opaque whatever the caller passed for the window colour. + let _ = unsafe { + controller.SetDefaultBackgroundColor(COREWEBVIEW2_COLOR { + A: a, + R: r, + G: g, + B: b, + }) + }; + } + }); +} + +/// WebKitGTK's pre-paint colour. +#[cfg(target_os = "linux")] +fn set_linux_webview_backdrop(window: &tauri::WebviewWindow, color: tauri::window::Color) { + use webkit2gtk::WebViewExt; + + let tauri::window::Color(r, g, b, a) = color; + let _ = window.with_webview(move |webview| { + webview.inner().set_background_color(&gdk::RGBA::new( + r as f64 / 255.0, + g as f64 / 255.0, + b as f64 / 255.0, + a as f64 / 255.0, + )); + }); +} + /// Paint the webview's own backdrop on macOS. /// /// `WebviewWindow::set_background_color` is documented as "not implemented for @@ -181,24 +267,6 @@ fn unlock_macos_webview_frame_rate(window: &tauri::WebviewWindow) { }); } -/// Resolve the tray icon that matches the current system appearance. -/// A dark mark sits on the light menu bar; a light mark on the dark menu bar, -/// so the logo stays visible regardless of the OS theme. -fn tray_icon_for_theme( - app: &tauri::AppHandle, - theme: tauri::Theme, -) -> Option> { - let name = match theme { - tauri::Theme::Dark => "icons/tray-light.png", - _ => "icons/tray-dark.png", - }; - let path = app - .path() - .resolve(name, tauri::path::BaseDirectory::Resource) - .ok()?; - tauri::image::Image::from_path(path).ok() -} - #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { // Linux WebKitGTK rendering fix - set before any threads spawn. @@ -272,6 +340,15 @@ pub fn run() { .min_inner_size(960.0, 600.0) .resizable(true) .maximized(true) + // Hidden until the page has its first finished screen: revealApp() + // in src/lib/app-reveal.js shows it. A window visible from build() + // put every pre-paint layer on screen in turn - the host surface, + // the webview backdrop, the maximize resize - and on Windows that + // read as the window flickering between two blacks for half a second + // before the app appeared. tao keeps MAXIMIZED across the hide, and + // show() issues SW_SHOW then SW_MAXIMIZE, so the OS still records a + // real maximized state (minimize -> restore keeps working). + .visible(false) // Never let the default white surface show. The real theme is only // known to the frontend (localStorage), so start on the dark base - // 11 of the 16 themes are dark - and correct to light right after @@ -327,8 +404,8 @@ pub fn run() { let window_theme = window.theme().unwrap_or(tauri::Theme::Dark); let surface = surface_for_theme(window_theme); let _ = window.set_background_color(Some(surface)); - #[cfg(target_os = "macos")] - set_macos_webview_backdrop(&window, surface); + set_webview_backdrop(&window, surface); + arm_reveal_failsafe(&window); #[cfg(target_os = "macos")] { @@ -374,71 +451,17 @@ pub fn run() { .level(log::LevelFilter::Info) .build(), )?; - // ── System tray ─────────────────────────────────────────────────── - let show_item = MenuItem::with_id(app, "show", "Open Stroke", true, None::<&str>)?; - let quit_item = MenuItem::with_id(app, "quit", "Quit Stroke", true, None::<&str>)?; - let sep = PredefinedMenuItem::separator(app)?; - - let tray_menu = Menu::with_items(app, &[&show_item, &sep, &quit_item])?; - - // Dedicated tray icon (Stroke mark) chosen to stay visible against the - // current system menu-bar theme; swapped live on ThemeChanged below. - let initial_theme = window.theme().unwrap_or(tauri::Theme::Light); - let tray_icon = tray_icon_for_theme(app.handle(), initial_theme) - .unwrap_or_else(|| app.default_window_icon().unwrap().clone()); - - let _tray = TrayIconBuilder::with_id("main-tray") - .icon(tray_icon) - .menu(&tray_menu) - .show_menu_on_left_click(false) - .on_menu_event(|app, event| match event.id.as_ref() { - "show" => { - if let Some(w) = app.get_webview_window("main") { - let _ = w.show(); - let _ = w.set_focus(); - } - } - "quit" => app.exit(0), - _ => {} - }) - .on_tray_icon_event(|tray, event| { - if let TrayIconEvent::Click { - button: MouseButton::Left, - button_state: MouseButtonState::Up, - .. - } = event - { - let app = tray.app_handle(); - if let Some(w) = app.get_webview_window("main") { - let _ = w.show(); - let _ = w.set_focus(); - } - } - }) - .build(app)?; - - // ── Hide to tray on close instead of quitting ───────────────────── + // No tray and no hide-on-close: closing the last window quits the + // process. Hiding to the tray left a live instance behind on every + // close, and each relaunch added another icon next to the old ones. let app_handle = app.handle().clone(); window.on_window_event(move |event| match event { - tauri::WindowEvent::CloseRequested { api, .. } => { - api.prevent_close(); - if let Some(w) = app_handle.get_webview_window("main") { - let _ = w.hide(); - } - } - // Keep the tray mark visible when the OS flips light/dark, and keep - // the pre-paint surface on the same end of the scale. + // Keep the pre-paint surface on the same end of the scale as the OS. tauri::WindowEvent::ThemeChanged(theme) => { - if let Some(tray) = app_handle.tray_by_id("main-tray") { - if let Some(icon) = tray_icon_for_theme(&app_handle, *theme) { - let _ = tray.set_icon(Some(icon)); - } - } if let Some(w) = app_handle.get_webview_window("main") { let surface = surface_for_theme(*theme); let _ = w.set_background_color(Some(surface)); - #[cfg(target_os = "macos")] - set_macos_webview_backdrop(&w, surface); + set_webview_backdrop(&w, surface); } } _ => {} @@ -463,6 +486,7 @@ pub fn run() { commands::ai_fetch_page, commands::read_file, commands::open_new_window, + commands::reveal_window, commands::restart_app, commands::toggle_devtools, commands::test_postgres_connection, @@ -597,9 +621,8 @@ pub fn run() { .build(tauri::generate_context!()) .expect("error while running tauri application") .run(|app, event| { - // Real exit only (tray Quit / OS shutdown) - the hide-to-tray - // CloseRequested path never reaches here. Reap the OmniRoute proxy - // we spawned, or it survives every app quit. + // 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 { app.state::().kill_now(); } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 77168e8a..95e3bc0a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -37,10 +37,6 @@ "icons/icon.icns", "icons/icon.ico" ], - "resources": [ - "icons/tray-light.png", - "icons/tray-dark.png" - ], "macOS": { "signingIdentity": "-" }, diff --git a/src/App.svelte b/src/App.svelte index bfbbac4a..d2178f04 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -69,10 +69,8 @@ if ((e.ctrlKey || e.metaKey) && e.key === 'p') { e.preventDefault(); e.stopPropagation() } }, { capture: true }) - // Fade in the page now that theme + layout are ready. The window uses native - // OS decorations and opens maximized via the Tauri builder, so the OS owns - // all window state - no JS show/maximize/geometry handling needed here. - document.documentElement.style.opacity = '1' + // No fade-in here: AppLockGate has not rendered anything yet. The first + // complete screen reveals the page - see $lib/app-reveal.js. }) diff --git a/src/lib/app-reveal.js b/src/lib/app-reveal.js new file mode 100644 index 00000000..99fa5ea4 --- /dev/null +++ b/src/lib/app-reveal.js @@ -0,0 +1,47 @@ +// The page boots at opacity 0 (index.html) and is faded in exactly once, by the +// first screen that is actually complete: the shell after its startup decision +// (reconnect overlay, welcome, onboarding), the lock screen, the trial screen, +// or the crash screen. Revealing earlier - on App mount - faded in an empty page +// while AppLockGate was still reading the keychain, and the shell then popped in +// unfaded on top of it. +// +// The native window is built hidden too (src-tauri/src/lib.rs) and is shown +// here, so nothing that paints before the page (host surface, webview backdrop, +// the maximize resize) ever reaches the screen. + +const FAILSAFE_MS = 2500 + +let revealed = false + +/** Show the native window. No-op outside Tauri (browser dev). */ +async function showWindow() { + try { + const { invoke } = await import('@tauri-apps/api/core') + await invoke('reveal_window') + } catch { /* browser dev, or the window is already up */ } +} + +/** Has the page been revealed yet? Lets a screen skip an exit animation that + * would otherwise play, unseen, while the window is still hidden. */ +export function isRevealed() { + return revealed +} + +/** Fade the page in. Idempotent - only the first caller does anything. */ +export function revealApp() { + if (revealed || typeof document === 'undefined') return + revealed = true + // Show first: a hidden WebKitGTK window never fires requestAnimationFrame, + // so the fade below would wait forever. Then one frame so whatever the + // caller just set (overlay, modal) is in the DOM before the fade starts. + void showWindow().finally(() => { + requestAnimationFrame(() => { + document.documentElement.style.opacity = '1' + }) + }) +} + +/** Never leave a blank window: reveal regardless if no screen claimed it. */ +export function armRevealFailsafe() { + setTimeout(revealApp, FAILSAFE_MS) +} diff --git a/src/lib/clipboard.js b/src/lib/clipboard.js index 72c631d6..1c92078c 100644 --- a/src/lib/clipboard.js +++ b/src/lib/clipboard.js @@ -7,9 +7,52 @@ // no such restriction, so we transparently patch `navigator.clipboard` to use it. // Every existing `navigator.clipboard.writeText(...)` call site (and Monaco's) // then works with no per-call changes. +// +// Reads go the same way, for a different reason: the web read API is permission +// gated, and asking for that permission puts a "wants to see text and images +// copied to the clipboard" dialog over the app. `readClipboardText` never calls +// it, so the dialog never appears. let _installed = false +/** @type {Promise | null} */ +let _pluginPromise = null + +/** The Tauri clipboard plugin, or null in browser dev where it isn't bundled. */ +function plugin() { + if (!_pluginPromise) { + _pluginPromise = import('@tauri-apps/plugin-clipboard-manager').catch(() => null) + } + return _pluginPromise +} + +/** + * Read the clipboard as text, through Rust, never through the web API. + * + * `navigator.clipboard.readText()` is a permission-gated call: WebView2 raises + * a "…wants to see text and images copied to the clipboard / Block / Allow" + * dialog over the app the first time it runs, and WKWebView raises the system + * paste prompt. Nothing in this app is worth that interruption, so the web read + * API is never called - not even as a fallback. The Tauri plugin reads the OS + * clipboard from Rust with no prompt at all. + * + * Returns '' when there is no text to read: an empty clipboard, an image, or + * browser dev where the plugin isn't there. Every caller treats that as + * "nothing to paste", so a read never has to be guarded. + * + * @returns {Promise} + */ +export async function readClipboardText() { + try { + const mod = await plugin() + if (typeof mod?.readText !== 'function') return '' + return String((await mod.readText()) ?? '') + } catch { + // Empty clipboard, or it holds something that isn't text - both are "nothing". + return '' + } +} + export async function installClipboardBridge() { if (_installed) return _installed = true @@ -19,19 +62,11 @@ export async function installClipboardBridge() { // Lazily load the Tauri plugin - absent in browser dev, where the native API // works fine, so we leave the clipboard untouched there. - let tauriWrite = null - let tauriRead = null - try { - const mod = await import('@tauri-apps/plugin-clipboard-manager') - tauriWrite = mod.writeText - tauriRead = mod.readText - } catch { - return - } + const mod = await plugin() + const tauriWrite = mod?.writeText if (typeof tauriWrite !== 'function') return const nativeWrite = typeof clip.writeText === 'function' ? clip.writeText.bind(clip) : null - const nativeRead = typeof clip.readText === 'function' ? clip.readText.bind(clip) : null try { clip.writeText = async (/** @type {any} */ text) => { @@ -44,11 +79,10 @@ export async function installClipboardBridge() { if (!execCommandCopy(s)) throw e } } - if (typeof tauriRead === 'function') { - clip.readText = async () => { - try { return await tauriRead() } catch { return nativeRead ? nativeRead() : '' } - } - } + // Same for reads, so a stray `navigator.clipboard.readText()` - ours, or + // one inside a dependency - can never raise the webview's permission + // dialog. It resolves to '' instead of falling back to the web API. + clip.readText = readClipboardText // Monaco sniffs the UA for Safari, which WebKitGTK also matches, and installs // a Safari clipboard workaround: on *every* click and keydown in an editor it diff --git a/src/lib/components/AppErrorBoundary.svelte b/src/lib/components/AppErrorBoundary.svelte index 2821d747..31aa91a9 100644 --- a/src/lib/components/AppErrorBoundary.svelte +++ b/src/lib/components/AppErrorBoundary.svelte @@ -4,6 +4,7 @@ import Bug from '@lucide/svelte/icons/bug' // Static: StudioShell already imports it, so it is in the main chunk either way. import ReportIssueDialog from './ReportIssueDialog.svelte' + import { revealApp } from '$lib/app-reveal.js' let { children } = $props() @@ -25,7 +26,7 @@ } - { try { console.error('[app-boundary]', e) } catch { /* noop */ } }}> + { try { console.error('[app-boundary]', e) } catch { /* noop */ } revealApp() }}> {@render children()} {#snippet failed(error, reset)} diff --git a/src/lib/components/AppLockScreen.svelte b/src/lib/components/AppLockScreen.svelte index ef3149fa..434a084a 100644 --- a/src/lib/components/AppLockScreen.svelte +++ b/src/lib/components/AppLockScreen.svelte @@ -3,6 +3,7 @@ // cold start the app is not mounted behind it at all (see AppLockGate); after // an idle auto-lock the session is still there, covered and key-deaf. import { onMount } from 'svelte' + import { revealApp } from '$lib/app-reveal.js' import Logo from './Logo.svelte' import Icon from './Icon.svelte' import PinField from './PinField.svelte' @@ -20,6 +21,7 @@ let isTauri = $state(false) onMount(() => { isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window + revealApp() }) /** @param {MouseEvent} e */ diff --git a/src/lib/components/CellEditorPanel.svelte b/src/lib/components/CellEditorPanel.svelte index a8328c90..ba304b8b 100644 --- a/src/lib/components/CellEditorPanel.svelte +++ b/src/lib/components/CellEditorPanel.svelte @@ -49,11 +49,6 @@ import { cn } from '$lib/utils.js' import { toast } from '$lib/components/ui/sonner/toast.svelte.js' import Kbd from './Kbd.svelte' - import JsonTree from './JsonTree.svelte' - import Search from '@lucide/svelte/icons/search' - import { searchJson, matchOffsets, splitHighlight } from '$lib/json-search.js' - import Braces from '@lucide/svelte/icons/braces' - import { resetInputHistory } from '$lib/input-shortcuts.js' let { open = $bindable(false), @@ -106,6 +101,9 @@ let original = $state('') /** @type {HTMLTextAreaElement | null} */ let area = $state(null) + /** The raw pane's editor (CodeMirror). `area` is now only the heavy-value window. */ + /** @type {any} */ + let cm = $state(null) /** @type {HTMLElement | null} */ let root = $state(null) @@ -139,21 +137,32 @@ original = text draft = text seedToken++ + // Structured text opens unwrapped: pretty-printed JSON is short lines + // already, and unwrapped is what lets the gutter number them. Prose keeps + // wrapping. Alt+Z still flips it either way. + wrap = !/^\s*[[{]/.test(text) // Undo/redo, word-delete and line-delete for every plain field in the app // live in `input-shortcuts.js`, and its history is keyed by element. This // textarea outlives the cell it is showing, so the history has to be // dropped whenever it is re-pointed - otherwise ⌘Z in one cell walks back // into the value of a cell you have already left. - queueMicrotask(() => { - resetInputHistory(area) - // Focus the textarea on open, because editing is what the raw pane is - // for - but only then. A JSON cell opens on the tree, and taking focus - // there would cost the thing that makes this a dock rather than a - // dialog: the grid keeps the cursor, arrow keys still move it, and the - // panel follows. Escape from the grid closes the dock through the grid's - // own handler; Escape from inside the dock goes through `onRootKey`. - if (justOpened && rawOpen) area?.focus() - }) + // Opening the dock puts the caret in the editor - but only on open. Moving + // the grid cursor with the dock already up re-points it without taking + // focus, so arrow keys keep walking the grid. Escape from the editor + // closes the dock (`onRootKey`). + if (justOpened) focusOnReady = true + }) + + /** + * Focus waits for the editor to exist. It is lazy-loaded, so on the first + * open `cm` is still null when the cell is seeded - the old `cm?.focus()` + * ran then and did nothing. + */ + let focusOnReady = $state(false) + $effect(() => { + if (!focusOnReady || !cm || !open) return + focusOnReady = false + queueMicrotask(() => cm?.focus()) }) const dirty = $derived(draft !== original) @@ -174,8 +183,6 @@ const HEAVY_VALUE_CHARS = 2 * 1024 * 1024 const heavy = $derived(draft.length > HEAVY_VALUE_CHARS) - /** Set by "Render it anyway" - one parse, on demand, for a heavy value. */ - let forceParse = $state(false) /** * How much of a heavy value the raw pane shows at once. @@ -227,12 +234,14 @@ windowDraft = next windowLen = next.length } - // A new cell is a new decision. - $effect(() => { void colName; void sourceHint; forceParse = false }) - const parsed = $derived.by(() => { - const t = draft.trim() - if (!t || (t[0] !== '{' && t[0] !== '[')) return null + /** + * The one thing left of the old tree pane: saying when the text on screen is + * not the whole value. The tree itself is gone - the editor already shows the + * structure (colours, folding, brackets), and the tree re-ran JSON.parse over + * the whole value on every keystroke to draw a second copy of it. + */ + const notice = $derived.by(() => { // A capped cell ships its first 16KB, which for a JSON value stops // mid-token: parsing it can only ever fail, and reporting that failure as // "invalid JSON" sent people looking for a corrupt row that does not exist. @@ -258,14 +267,7 @@ error: `Loaded ${formatBytes(draft.length)}, which is as much of this value as this view holds. The rest is not shown, so it cannot be parsed or edited here - read it with a query if you need all of it.`, } } - if (heavy && !forceParse) { - return { - ok: false, - heavy: true, - error: `${formatBytes(draft.length)} of JSON. Building a tree from it means parsing the whole thing, which takes long enough to be felt - so it waits until you ask.`, - } - } - try { return { ok: true, value: JSON.parse(t) } } catch (e) { return { ok: false, error: String(e) } } + return null }) let loadingFull = $state(false) @@ -287,97 +289,9 @@ if (n < 1024 * 1024) return `${(n / 1024).toFixed(n < 10 * 1024 ? 1 : 0)} KB` return `${(n / 1024 / 1024).toFixed(1)} MB` } - const isTreeable = $derived(!!parsed?.ok) - // A heavy value has no tree until it is asked for, so the raw text is the - // thing to show - and the pane it lives in is the one that was hidden. - $effect(() => { if (heavy && !forceParse) rawOpen = true }) + /** The editor is the only pane now; kept as a name for the heavy-window paths. */ + const rawOpen = true - /** - * Whether the raw text sits beside the tree. - * - * A JSON cell opens as the tree alone, because that is what you came to do - - * read the shape of it. The raw pane is one click away and is where editing - * happens. Plain text has no tree, so it is always the raw pane and this - * never applies. - * - * Latched on `isTreeable` rather than set from the seed effect: the dock - * follows the cell cursor, and re-deciding this on every move would override - * a choice made two rows ago. It flips only when the cursor crosses between a - * structured value and a flat one, which is when the old choice stops - * meaning anything. - */ - let rawOpen = $state(false) - let _lastTreeable = /** @type {boolean | null} */ (null) - $effect(() => { - const t = isTreeable - if (t === _lastTreeable) return - _lastTreeable = t - rawOpen = !t - }) - - // ── Find, inside the value ──────────────────────────────────────────────── - // - // A tree is the one shape you cannot scan: what you are looking for is behind - // a chevron three levels down. Typing here opens exactly the branches that - // lead to a hit and leaves the rest closed, and highlights the run that - // matched. The raw pane marks its hits too - see the highlight layer in the - // template. It used to only step the caret from match to match, on the - // reasoning that a textarea cannot be highlighted: true of the element - // itself, but the marks can be drawn behind it. Stepping alone meant typing a - // query showed "1/1" over a value with nothing marked on it, and even after - // pressing Enter the selection was invisible the moment focus went back to - // the find box. - let query = $state('') - /** @type {HTMLInputElement | null} */ - let findEl = $state(null) - let hit = $state(0) - - const treeSearch = $derived( - isTreeable && query ? searchJson(parsed?.value, query) : null, - ) - // Searching a value this size means walking it per keystroke, twice (offsets, - // then highlight runs). The find box still works on everything under the line. - const rawHits = $derived(query && !heavy ? matchOffsets(draft, query) : []) - /** @type {HTMLElement | null} */ - let hlEl = $state(null) - /** - * The raw value split into plain and matched runs, each hit carrying its index - * so the one the caret is on can be brighter than the others. Null when there - * is nothing to mark, which is what keeps the layer out of the DOM entirely - * for the overwhelmingly common case of no query. - */ - const rawRuns = $derived.by(() => { - if (!query || heavy || !rawHits.length) return null - let n = -1 - return splitHighlight(draft, query).map((run) => ({ - t: run.t, - hit: run.hit ? ++n : -1, - })) - }) - /** What the counter says: tree rows while the tree is what you are reading. */ - const hitCount = $derived(rawOpen && !isTreeable ? rawHits.length : (treeSearch?.count ?? rawHits.length)) - - // A query that no longer matches anything should not leave the step index - // pointing past the end of the list. - $effect(() => { - const n = rawHits.length - if (hit >= n) hit = n ? n - 1 : 0 - }) - - /** Select the nth match in the raw pane and scroll it into view. */ - function stepRaw(/** @type {number} */ dir) { - if (!rawHits.length || !area) return - hit = (hit + dir + rawHits.length) % rawHits.length - const at = rawHits[hit] - if (!rawOpen) rawOpen = true - area.focus() - area.setSelectionRange(at, at + query.length) - // `blur`/`focus` is what makes a textarea scroll to the selection in WebKit. - const before = area.scrollTop - area.blur() - area.focus() - if (area.scrollTop === before) area.scrollTop = before - } /** * Put the stored value back. @@ -392,22 +306,12 @@ if (readOnly || !dirty) return // The tree pane has no textarea to write through; nothing to keep undoable // there either, since the tree is not what you edited it with. - if (!rawOpen || !area) { + if (!rawOpen || !cm) { draft = original return } - area.focus() - area.setSelectionRange(0, area.value.length) - const ok = document.execCommand?.('insertText', false, original) - if (!ok) { - area.setRangeText(original, 0, area.value.length, 'end') - area.dispatchEvent(new Event('input', { bubbles: true })) - } - } - - function clearFind() { - query = '' - hit = 0 + // One editor transaction, so ⌘Z brings the edited text back. + cm.replaceAll(original) } // `split` allocates an array the size of the line count; on a one-line 18MB @@ -440,10 +344,6 @@ // toggle still works if you want it. $effect(() => { if (heavy) wrap = false }) - /** One entry per logical line. Values are a cell, not a file - no windowing. */ - const lineNumbers = $derived(Array.from({ length: Math.max(1, lines) }, (_, i) => i + 1)) - /** Sized to the widest number it will draw, so the text does not shift. */ - const gutterW = $derived(Math.max(2, String(Math.max(1, lines)).length) * 8 + 14) // The editor's type metrics, as whole pixels. The gutter has to sit on the // same baseline grid as the text beside it, and a fractional line-height @@ -451,18 +351,6 @@ // rounds it the other way - by line 30 the numbers no longer line up. const LINE_H = 20 - /** @type {HTMLElement | null} */ - let gutterEl = $state(null) - function syncGutter() { - if (gutterEl && area) gutterEl.scrollTop = area.scrollTop - // The highlight layer has to track both axes: with wrapping off the textarea - // scrolls sideways, and a mark that does not follow it lands on the wrong - // characters rather than merely looking untidy. - if (hlEl && area) { - hlEl.scrollTop = area.scrollTop - hlEl.scrollLeft = area.scrollLeft - } - } function apply() { if (readOnly || !dirty) { open = false; return } @@ -479,25 +367,6 @@ } } - /** @param {KeyboardEvent} e */ - function onFindKey(e) { - if (e.key === 'Escape') { - e.preventDefault() - e.stopPropagation() - // First Escape gives up the search, second closes the dock - the same - // order a browser's find bar uses. - if (query) { clearFind(); return } - open = false - return - } - if (e.key === 'Enter') { - e.preventDefault() - e.stopPropagation() - if (rawHits.length) stepRaw(e.shiftKey ? -1 : 1) - return - } - } - /** * Escape, from anywhere in the dock. * @@ -511,63 +380,36 @@ * @param {KeyboardEvent} e */ function onRootKey(e) { + // A key the editor already handled (Escape closing its find panel, Mod-F + // opening it) is not the dock's: without this, the Escape that closed find + // also closed the whole dock. + if (e.defaultPrevented) return if (e.key === 'Escape') { e.preventDefault() e.stopPropagation() open = false return } - // ⌘F / Ctrl+F puts the caret in the find field, as it does everywhere else. + // ⌘F / Ctrl+F from anywhere in the dock opens the editor's find panel. + // Inside the editor its own keymap has already done it (defaultPrevented). if ((e.metaKey || e.ctrlKey) && (e.key === 'f' || e.key === 'F')) { e.preventDefault() e.stopPropagation() - findEl?.focus() - findEl?.select() + cm?.find() return } } /** @param {KeyboardEvent} e */ - function onKey(e) { + /** The panel's keys, handed to the editor so they win over its defaults. */ + const editorKeys = [ // Cmd/Ctrl+Enter applies, matching every other multi-line editor in the app. - if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { - e.preventDefault() - apply() - return - } - // Alt+Z toggles wrap, as it does in VS Code and every editor that copied it. - if (e.altKey && (e.key === 'z' || e.key === 'Z')) { - e.preventDefault() - wrap = !wrap - return - } - // Alt+R reverts. In the same Alt+letter family as the wrap toggle, and - // `preventDefault` is what stops macOS inserting ® instead - the same reason - // Alt+Z above does not leave an Ω behind. - if (e.altKey && (e.key === 'r' || e.key === 'R')) { - e.preventDefault() - revert() - return - } - // Tab indents rather than leaving the field - this is an editor, and the - // values that need it are JSON and SQL. Shift+Tab still tabs out. - // - // Inserted through `execCommand`, not by assigning `draft`: a state write - // changes the value without an `input` event, so the shared undo stack - // never records the step and ⌘Z afterwards skipped straight past the - // indent (or, on the first edit, appeared to do nothing at all). - if (e.key === 'Tab' && !e.shiftKey && !readOnly && area) { - e.preventDefault() - const ok = document.execCommand?.('insertText', false, ' ') - if (!ok) { - // execCommand is deprecated and can refuse; setRangeText + a dispatched - // `input` is the same change through the supported path. - const { selectionStart: a0, selectionEnd: a1 } = area - area.setRangeText(' ', a0, a1, 'end') - area.dispatchEvent(new Event('input', { bubbles: true })) - } - } - } + { key: 'Mod-Enter', run: () => { apply(); return true } }, + // Alt+Z toggles wrap, Alt+R reverts - VS Code's keys. + { key: 'Alt-z', run: () => { wrap = !wrap; return true } }, + { key: 'Alt-r', run: () => { revert(); return true } }, + ] + + {sourceHint} {/if} {#if isNull && !dirty} NULL @@ -635,69 +479,15 @@ edited {/if} -
- - -
-
- {#if isTreeable} - - - {/if} + +
+ {/if} +
+ {/if}
- - {#if !wrap && rawOpen} - - {/if} - - {#if rawOpen && rawRuns} - - {/if} {#if heavy && rawOpen} - - {#if parsed} - -
-
- - {parsed.ok ? 'Tree' : 'Preview'} - - {#if parsed.truncated} - truncated value - {:else if !parsed.ok} - invalid JSON - {/if} -
-
- {#if parsed.ok && query && !treeSearch?.count} -

- No match for {query} in this value. -

- {:else if parsed.ok} - { - const text = typeof v === 'string' ? v : JSON.stringify(v, null, 2) - void navigator.clipboard?.writeText(text) - toast.success('Copied') - }} - /> - {:else} -

{parsed.error}

- {#if parsed.heavy} - -

- The raw text is already here, and Find works on it under 2 MB. -

- {/if} - {#if parsed.truncated && oversize && onloadfull} - -

- Fetched for this row only - the page stays light. -

- {/if} - {/if} -
-
- {/if} +
+ + {#await import('./CodeEditor.svelte') then { default: CodeEditor }} + + {/await} +
diff --git a/src/lib/components/CodeEditor.svelte b/src/lib/components/CodeEditor.svelte new file mode 100644 index 00000000..e63679d2 --- /dev/null +++ b/src/lib/components/CodeEditor.svelte @@ -0,0 +1,614 @@ + + +
diff --git a/src/lib/components/ConnectionModal.svelte b/src/lib/components/ConnectionModal.svelte index 36527229..a1b2809b 100644 --- a/src/lib/components/ConnectionModal.svelte +++ b/src/lib/components/ConnectionModal.svelte @@ -42,6 +42,7 @@ import { Popover, PopoverTrigger, PopoverContent } from "$lib/components/ui/popover/index.js"; import PasswordInput from "./PasswordInput.svelte"; import { requireUnlock } from "$lib/stores/app-lock.js"; + import { readClipboardText } from "$lib/clipboard.js"; import { Checkbox } from "$lib/components/ui/checkbox/index.js"; import { ScrollArea } from "$lib/components/ui/scroll-area/index.js"; import { Button } from "$lib/components/ui/button/index.js"; @@ -528,38 +529,27 @@ /** * A connection string in the clipboard is where this dialog usually starts - * copied out of a provider dashboard, a .env, or a teammate's message - and - * the first thing anyone does here is paste it. So the dialog pastes it. + * the first thing anyone does here is paste it. So clicking into the paste + * bar pastes it. * - * Strictly: only a string that parses as a connection URI, only into an empty - * bar, and nothing is applied until Continue is pressed. It saves the paste, + * Strictly: only on a click into the bar (never on open or window focus - on + * macOS every clipboard read can raise the system paste prompt), only a string + * that parses as a connection URI, only into an empty bar, and nothing is + * applied until Continue is pressed. It saves the paste, * not the decision. A `.env` line is unwrapped (`DATABASE_URL="postgres://…"`) * because that is the form the string is usually copied in. */ /** What the clipboard last put in the bar, so a refill can tell its own text from typing. */ let clipboardFilled = ""; - /** A string the user cleared away. Putting it back on the next focus would be a fight. */ + /** A string the user cleared away. Putting it back on the next click would be a fight. */ let clipboardDismissed = ""; - // Re-read on every window focus, not only on open: the usual shape of this is - // copying the string from a provider dashboard in the browser and coming - // back, and by then the dialog has been open for a minute. Both events are - // bound - a Tauri window focus fires `focus` on the webview, and a workspace - // switch or an unminimise only fires `visibilitychange`. - $effect(() => { - if (!open) return; - const reread = () => { if (step === "pick") void prefillFromClipboard(); }; - const onVisible = () => { if (!document.hidden) reread(); }; - window.addEventListener("focus", reread); - document.addEventListener("visibilitychange", onVisible); - return () => { - window.removeEventListener("focus", reread); - document.removeEventListener("visibilitychange", onVisible); - }; - }); - async function prefillFromClipboard() { + // Typing beats the clipboard, always - and then there is no reason to read it. + if (!open || step !== "pick") return; + if (quickUri.trim() && quickUri !== clipboardFilled) return; try { - const raw = String((await navigator.clipboard.readText()) ?? "").trim(); + const raw = (await readClipboardText()).trim(); if (!raw || raw.length > 2000 || raw.includes("\n")) return; const unwrapped = raw .replace(/^(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=\s*/, "") @@ -567,8 +557,7 @@ .trim(); const candidate = detectConnectionUri(raw) ? raw : detectConnectionUri(unwrapped) ? unwrapped : ""; if (!candidate) return; - // Anything the user did in the meantime wins - this lands a tick or two - // after the dialog opened, and re-runs every time the window is focused. + // Anything the user did while the read was in flight wins. if (!open || step !== "pick") return; if (candidate === quickUri) return; // already there if (candidate === clipboardDismissed) return; // they cleared this one away @@ -1472,8 +1461,8 @@ quickUri = ""; quickHint = ""; void refreshLocal(); - void prefillFromClipboard(); // The paste bar takes focus: the modal opens, you paste, you press Enter. + // Focus alone does not read the clipboard - only a click into the bar does. // Only on the front page - a saved connection opens straight into its form. void tick().then(() => { if (step === "pick") quickUriEl?.focus(); }); }); @@ -2879,7 +2868,7 @@
{#if isSel} + {/if} @@ -3211,6 +3204,7 @@ aria-label="Paste a connection string" spellcheck="false" class="h-9 pl-8 font-mono text-ui-xs" + onclick={() => void prefillFromClipboard()} oninput={(e) => { quickHint = ""; // Cleared on purpose: do not hand the same string diff --git a/src/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte index 4af7adda..040674bb 100644 --- a/src/lib/components/DataTable.svelte +++ b/src/lib/components/DataTable.svelte @@ -8,6 +8,7 @@ import { increaseZoom, decreaseZoom, resetZoom, appPreviewDml, appTableStyle, TABLE_STYLES, normalizeTableStyle, appVimMode, appTableAlign, appNativeScroll, appRowSpacing, appZebraRows, rowSpacingHeight, appNumberGrouping, appHighlightActiveRow, appGridFontSize, appImagePreview, appOpenUrlsOnClick, appRowNumbers } from '$lib/stores/settings.js' import { createSmoothScroll, wheelPixels } from '$lib/smooth-scroll.js' import { isJsonColumnType } from '$lib/cell-expand.js' + import { readClipboardText } from '$lib/clipboard.js' import { setVimSubMode } from '$lib/vim/vim.js' import { toast } from "$lib/components/ui/sonner/toast.svelte.js"; import * as ContextMenu from "$lib/components/ui/context-menu/index.js"; @@ -5302,6 +5303,10 @@ import FilterX from "@lucide/svelte/icons/filter-x"; const ctx = _ctx const read = _readColor if (!ctx || !read || !canvasEl || !colorProbe) return + // Drop any scroll delta seen while holding: the first real frame after a + // hold is a different table and must repaint whole, never blit the old one. + if (holdPaint && _surfaceHasFrame) { _blitDy = 0; return } + _surfaceHasFrame = true // ── Scroll blitting ────────────────────────────────────────────────────── // After the truncation cache, `fillText` is the whole remaining draw cost: @@ -5664,6 +5669,22 @@ import FilterX from "@lucide/svelte/icons/filter-x"; let loadingSpan = $state(/** @type {{first:number,last:number}|null} */ (null)) /** A window that lands quickly must not flash a pill on its way past. */ const SPAN_SHOW_AFTER = 220 + /** `loading`, held back the same way: a fetch that lands within a frame or + * two never flashes a spinner. */ + let loadingVisible = $state(false) + $effect(() => { + if (!loading) { loadingVisible = false; return } + const t = setTimeout(() => (loadingVisible = true), SPAN_SHOW_AFTER) + return () => clearTimeout(t) + }) + /** Paint holding. A table switch empties `columns` the moment it starts, and + * drawing that frame blanked the grid for the whole round trip - the old + * table, then nothing, then the new one. For the same grace window as the + * spinner the canvas keeps the last table's pixels and the swap lands as one + * frame. Never on a surface that holds nothing yet: a fresh or just-resized + * canvas is blank (or black in WebKit) and has to be painted. */ + const holdPaint = $derived(loading && columns.length === 0 && !loadingVisible) + let _surfaceHasFrame = false /** Reactivity budget: the pill re-reads at most this often, not per frame. */ const SPAN_PUBLISH_MS = 150 @@ -6726,6 +6747,8 @@ import FilterX from "@lucide/svelte/icons/filter-x"; * rather than short-circuiting on unchanged dimensions. That guarantees the * visible canvas is transformed + painted on every mount / tab switch (a * short-circuit here risked leaving a fresh canvas untransformed → blank). */ + /** Tallest the grid viewport has been - see the height in syncCanvasSurface. */ + let _tallestViewportH = 0 function syncCanvasSurface() { const canvas = canvasEl const probe = colorProbe @@ -6739,7 +6762,15 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // keeps _ctx and the on-screen canvas in lockstep. _ctx = canvas.getContext('2d') const cssW = Math.max(1, Math.round(_viewportWidth)) - const cssH = Math.max(1, Math.round(_viewportHeight)) + // Height is not simply the viewport's. Resizing the backing store on WebKit + // swaps the new bitmap in a frame before the new box size lands, so the + // related-rows dock opening (it shortens the viewport) showed one frame of + // the grid stretched by old/new. So a shrink leaves the canvas as tall as + // the viewport has been, clipped by the scroll container - but never taller + // than the content, so it can never add scroll range the table did not + // already have. Short tables still follow the viewport exactly. + if (_viewportHeight > _tallestViewportH) _tallestViewportH = _viewportHeight + const cssH = Math.max(1, Math.round(Math.max(_viewportHeight, Math.min(spacerHeight, _tallestViewportH)))) const dpr = Math.min(window.devicePixelRatio || 1, 2) const bw = Math.max(1, Math.round(cssW * dpr)) const bh = Math.max(1, Math.round(cssH * dpr)) @@ -6752,6 +6783,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; canvas.width = bw canvas.height = bh resized = true + _surfaceHasFrame = false } // canvas.width/height assignment resets the transform, so always (re)apply it. if (_ctx) _ctx.setTransform(dpr, 0, 0, dpr, 0, 0) @@ -6974,6 +7006,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // First-paint guard for the layout effect below (see there). let _firstPaintDone = false + let _lastLayoutVh = 0 // Layout / sizing effect - resize the backing store when geometry or viewport // changes. Tracks ONLY dependencies that can change the canvas dimensions, // geometry, or the full set of drawn data. Interaction state (selection, focus, @@ -6994,6 +7027,10 @@ import FilterX from "@lucide/svelte/icons/filter-x"; void vexprTotalW; void _vcolFns void zoomState.value; void canvasZoom const { ok, resized } = syncCanvasSurface() + // The canvas can outgrow the viewport (see syncCanvasSurface), and what sits + // below the fold is stale - a dock closing has to paint it in the same frame. + const vhChanged = _viewportHeight !== _lastLayoutVh + _lastLayoutVh = _viewportHeight if (ok) { // Paint synchronously only on the FIRST mount (so a fresh canvas never // composites as an untransformed, unpainted "black" grid) and whenever the @@ -7003,7 +7040,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // rAF-coalesced scheduleDraw() below is enough and the previously // unconditional synchronous draw() (which double-painted every update) is // skipped. - if (!_firstPaintDone || resized) draw() + if (!_firstPaintDone || resized || vhChanged) draw() _firstPaintDone = true scheduleDraw() } @@ -7016,6 +7053,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; void hoveredRow; void hoveredColName; void _resizeHoverCol; void resizingColName void selected; void focusedRow; void focusedCol; void selAnchor; void editingCell void pendingEdits; void pendingDeletes; void insertSaving; void fkSubview + void holdPaint if (_ctx) scheduleDraw() }) @@ -7467,9 +7505,6 @@ import FilterX from "@lucide/svelte/icons/filter-x";
-{#if loading && columns.length === 0} - -{:else} { contextMenuOpen = open; @@ -7496,6 +7531,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; class={cn( "app-scroll relative overflow-auto bg-panel select-none outline-none [scrollbar-gutter:stable] [contain:layout] [overflow-anchor:none]", embedded ? "max-h-80" : "min-h-0 flex-1", + embedded && loading && columns.length === 0 && "min-h-40", resizingColName && "cursor-col-resize", )} oncontextmenu={(e) => onCanvasContextMenu(e, bitsContextMenu)} @@ -8175,7 +8211,17 @@ import FilterX from "@lucide/svelte/icons/filter-x"; {/if} - {#if visibleColumns.length === 0} + {#if loading && columns.length === 0} + + {#if loadingVisible} +
+ +
+ {/if} + {:else if visibleColumns.length === 0}
@@ -8192,8 +8238,12 @@ import FilterX from "@lucide/svelte/icons/filter-x";
{#if loading} - -

Loading rows…

+ {#if loadingVisible} +
+ +

Loading rows…

+
+ {/if} {:else}

No rows in this table

@@ -8754,7 +8804,6 @@ import FilterX from "@lucide/svelte/icons/filter-x"; {/if} -{/if} @@ -8914,7 +8963,8 @@ import FilterX from "@lucide/svelte/icons/filter-x"; onSelect={async () => { const m = draftMenu if (!m) return - try { setNewRowDraft(m.row, m.col, await navigator.clipboard.readText()) } catch { /* no clipboard */ } + const text = await readClipboardText() + if (text) setNewRowDraft(m.row, m.col, text) }} > diff --git a/src/lib/components/ErdCanvas.svelte b/src/lib/components/ErdCanvas.svelte index 727b8aa8..71022cd5 100644 --- a/src/lib/components/ErdCanvas.svelte +++ b/src/lib/components/ErdCanvas.svelte @@ -1137,12 +1137,27 @@ const hit = hitNode(e.clientX - r.left, e.clientY - r.top) if (hit) onopen(hit.id) } - /** @param {WheelEvent} e */ + /** + * Ctrl/Cmd + wheel zooms at the cursor (a trackpad pinch arrives the same + * way, as ctrl+wheel); a plain wheel scrolls the diagram, and Shift turns a + * vertical wheel sideways. Every wheel used to zoom, so there was no way to + * scroll a large schema, and Ctrl+wheel never arrived at all: the global + * zoom blocker swallows it outside a [data-zoom-surface]. + * @param {WheelEvent} e + */ function onWheel(e) { e.preventDefault() if (!canvas) return - const r = canvas.getBoundingClientRect() - zoomBy(Math.exp(-e.deltaY * 0.0015), e.clientX - r.left, e.clientY - r.top) + // Line and page deltas (a notched wheel on some engines) to pixels. + const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? cssH : 1 + const dx = e.deltaX * unit, dy = e.deltaY * unit + if (e.ctrlKey || e.metaKey) { + const r = canvas.getBoundingClientRect() + zoomBy(Math.exp(-dy * 0.0015), e.clientX - r.left, e.clientY - r.top) + return + } + if (e.shiftKey && dx === 0) { cam.panX -= dy } else { cam.panX -= dx; cam.panY -= dy } + markDirty() } // Click the minimap to recenter there. @@ -1205,6 +1220,7 @@ onpointercancel={onPointerUp} ondblclick={onDblClick} onwheel={onWheel} + data-zoom-surface > diff --git a/src/lib/components/FkSubviewPanel.svelte b/src/lib/components/FkSubviewPanel.svelte index 3be730da..89930cc2 100644 --- a/src/lib/components/FkSubviewPanel.svelte +++ b/src/lib/components/FkSubviewPanel.svelte @@ -38,6 +38,32 @@ onfullview = () => {}, } = $props() + /** + * What the panel draws. Every lookup replaces `data` with an empty + * `{ loading: true, rows: [] }` first, so drawing `data` directly blanked the + * table - headers and all - for the frames between clicking another row's key + * and its result landing. Holding the last settled result until the next one + * arrives turns that into a single swap. It is only reused for the same + * relationship: another relationship's columns would be the wrong table. + */ + let settled = $state.raw(/** @type {any} */ (null)) + let settledLabel = $state('') + $effect(() => { + if (data && !data.loading) { + settled = data + settledLabel = fkLabel + } + }) + const view = $derived(data?.loading && settled && settledLabel === fkLabel ? settled : data) + + /** `data.loading`, held back ~220ms (the grid's SPAN_SHOW_AFTER) so it never flashes. */ + let loadingVisible = $state(false) + $effect(() => { + if (!data?.loading) { loadingVisible = false; return } + const t = setTimeout(() => (loadingVisible = true), 220) + return () => clearTimeout(t) + }) + /** @param {unknown} v */ function fmt(v) { if (v === null || v === undefined) return 'NULL' @@ -45,8 +71,8 @@ return String(v) } - const rowCount = $derived(data?.rows?.length ?? 0) - const colNames = $derived((data?.columns ?? []).map((c) => c.name ?? c)) + const rowCount = $derived(view?.rows?.length ?? 0) + const colNames = $derived((view?.columns ?? []).map((c) => c.name ?? c)) /** * Column widths and alignment, from the same two helpers the grid above sizes @@ -57,7 +83,7 @@ * the grid's. */ const cols = $derived( - (data?.columns ?? []).map((c) => { + (view?.columns ?? []).map((c) => { const type = c.dataType ?? c.data_type ?? '' return { name: c.name ?? c, @@ -85,13 +111,13 @@ // A new result set invalidates any cell coordinate held from the previous one. $effect(() => { - void data + void view sel = null }) /** @param {number} i row index @param {number} j column index */ function cellAt(i, j) { - const row = data?.rows?.[i] + const row = view?.rows?.[i] if (row === undefined) return undefined return Array.isArray(row) ? row[j] : row[colNames[j]] } @@ -107,7 +133,7 @@ /** Tab-separated, which is what spreadsheets and editors paste as columns. */ function allTsv() { const header = colNames.join('\t') - const body = data.rows.map((_, i) => colNames.map((_, j) => fmt(cellAt(i, j))).join('\t')) + const body = view.rows.map((_, i) => colNames.map((_, j) => fmt(cellAt(i, j))).join('\t')) return [header, ...body].join('\n') } @@ -172,7 +198,7 @@ {#if sourceHint} ({sourceHint}) {/if} - {#if !data?.loading && !data?.error} + {#if !view?.loading && !view?.error} {rowCount}{rowCount >= 50 ? '+' : ''} row{rowCount !== 1 ? 's' : ''} @@ -211,18 +237,24 @@
- {#if data?.loading} + {#if view?.loading} +
- - Loading related rows… + {#if loadingVisible} + + + Loading related rows… + + {/if}
- {:else if data?.error} + {:else if view?.error}
Couldn't load related rows
-
{data.error}
+
{view.error}
@@ -235,7 +267,7 @@ {:else} -
+
{#snippet child({ props })} @@ -289,7 +321,7 @@ - {#each data.rows as row, i (i)} + {#each view.rows as row, i (i)} {i + 1} @@ -321,7 +353,7 @@ aria-selected={isSel} class={cn( 'cursor-default overflow-hidden align-middle text-ellipsis whitespace-nowrap outline-none', - metrics.rowRules && i < data.rows.length - 1 && 'border-b border-border/15', + metrics.rowRules && i < view.rows.length - 1 && 'border-b border-border/15', metrics.colRules && 'border-r border-r-border/15', c.alignRight && 'text-right tabular-nums', isNullVal && 'italic text-muted-foreground/70', @@ -361,7 +393,7 @@ sel && copy(data.rows.map((_, i) => fmt(cellAt(i, sel.c))).join('\n'), `Column ${colNames[sel.c]}`)} + onSelect={() => sel && copy(view.rows.map((_, i) => fmt(cellAt(i, sel.c))).join('\n'), `Column ${colNames[sel.c]}`)} > Copy column @@ -371,7 +403,7 @@ Copy all rows - copy(JSON.stringify(data.rows.map((_, i) => rowObject(i)), null, 2), 'All rows JSON')}> + copy(JSON.stringify(view.rows.map((_, i) => rowObject(i)), null, 2), 'All rows JSON')}> Copy all as JSON diff --git a/src/lib/components/LicenseActivation.svelte b/src/lib/components/LicenseActivation.svelte index 1d0c7f7e..582c68a5 100644 --- a/src/lib/components/LicenseActivation.svelte +++ b/src/lib/components/LicenseActivation.svelte @@ -1,24 +1,63 @@ {#snippet keyField(extraClass)} +
- - + { error = ''; void loadConfetti() }} + aria-label="License key" + aria-invalid={error ? 'true' : undefined} + aria-describedby={error ? 'license-key-error' : undefined} placeholder="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" spellcheck="false" autocomplete="off" - disabled={loading || success} - class="h-11 w-full rounded-lg border-2 border-border bg-muted/20 pl-10 pr-3 font-mono text-ui-sm tracking-wider text-foreground outline-none transition-[color,border-color] placeholder:font-sans placeholder:tracking-normal placeholder:text-muted-foreground hover:border-foreground/25 focus:border-ring focus-visible:ring-0 disabled:opacity-50" + readonly={loading} + disabled={success} + class="bg-muted/20 pl-8 font-mono tracking-wide placeholder:text-muted-foreground/45" />
{/snippet} {#snippet submitBtn(fullWidth, label)} - + {/snippet} -
+
{#if !compact && !naked && !inline}

Enter your license key

@@ -111,23 +190,30 @@ {@render keyField('')} {/if} - - {#if error} -
- - {error} -
- {/if} - - - {#if success} -
- - License activated, welcome aboard! + +
+
+

+ + {shownError} +

- {/if} +
{#if !inline} {@render submitBtn(true, 'Activate License')} {/if}
+ +{#if success} + +{/if} diff --git a/src/lib/components/LicenseGate.svelte b/src/lib/components/LicenseGate.svelte index e6ae9bc7..3d7ca68d 100644 --- a/src/lib/components/LicenseGate.svelte +++ b/src/lib/components/LicenseGate.svelte @@ -11,11 +11,21 @@ await refreshLicenseStatus() runLicenseCheck() }) + + // Once the gate has shown, it stays up until the screen says it is done. + // activateLicense() refreshes the status before it resolves, so $isBlocked + // goes false mid-activation - and dropping the gate right then mounted the + // whole app in the same tick the confetti started, which is what made the + // celebration stutter (and hid the "Activated" state it was celebrating). + let holdGate = $state(false) + $effect(() => { + if ($isBlocked) holdGate = true + }) -{#if $isBlocked} +{#if $isBlocked || holdGate} - + (holdGate = false)} /> {:else} {@render children()} {/if} diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte index 912dcc78..8fdd57bd 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -1153,6 +1153,9 @@ const isFilterKey = (e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && e.key === 'f' const isEscClear = e.key === 'Escape' && selectedItems.size > 0 if (!isFilterKey && !isEscClear) return + // A code editor handles its own Mod-F (find and replace) and marks the key + // handled; stealing it here sent focus to the sidebar filter instead. + if (e.defaultPrevented || (e.target instanceof Element && e.target.closest('.cm-editor'))) return // Guard: filterEl.offsetParent is null when sidebar is hidden via display:none if (!filterEl || !filterEl.offsetParent) return if (isFilterKey) { @@ -1609,11 +1612,20 @@ // a list you cannot tab out of is a focus trap - and Shift+Tab off // the first returns to the filter the list was narrowed from. // Shift+Enter (above) is the deliberate way into the grid. - if (e.key === 'Tab') { + // `e.code` as well as `e.key`: on WebKitGTK, Shift+Tab reaches the + // page as GTK's ISO_Left_Tab keysym rather than "Tab", so a key-name + // check skipped this branch and the browser's own Shift+Tab moved + // focus to the previous tab stop - the filter - instead of the + // previous row. The physical key is Tab on every engine. + if (e.key === 'Tab' || e.code === 'Tab') { const onRow = e.target instanceof Element && e.target.closest('[data-sidebar-row]') if (!onRow) return const rows = listRowButtons() - const i = rows.indexOf(/** @type {HTMLElement} */ (document.activeElement)) + // The ROW, not document.activeElement: the row's checkbox span is + // tabindex="-1", so a click near the icon focuses the span, which + // is in no row list - every Shift+Tab from there fell through to + // the filter instead of going to the previous table. + const i = rows.indexOf(/** @type {HTMLElement} */ (onRow)) const next = i === -1 ? null : rows[i + (e.shiftKey ? -1 : 1)] if (next) { e.preventDefault(); kbdNav = true; next.focus() @@ -1632,7 +1644,8 @@ // box - the field the list was narrowed from. if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp' && e.key !== 'Home' && e.key !== 'End') return const rows = listRowButtons() - const i = rows.indexOf(/** @type {HTMLElement} */ (document.activeElement)) + const focusedRow = e.target instanceof Element ? e.target.closest('[data-sidebar-row]') : null + const i = rows.indexOf(/** @type {HTMLElement} */ (focusedRow)) if (i === -1) return e.preventDefault() kbdNav = true diff --git a/src/lib/components/StatusBar.svelte b/src/lib/components/StatusBar.svelte index 65f7cf93..46f154aa 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -154,7 +154,7 @@ e.preventDefault(); e.stopPropagation() dbHl = (dbHl + 1) % n scrollDbHlIntoView() - } else if (e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey)) { + } else if (e.key === 'ArrowUp' || ((e.key === 'Tab' || e.code === 'Tab') && e.shiftKey)) { e.preventDefault(); e.stopPropagation() dbHl = (dbHl - 1 + n) % n scrollDbHlIntoView() diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index ae7da98d..423df387 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -1,6 +1,7 @@ -
+ +
+
+ {#if !allColumnsHidden} + + {/if} + {#if hiddenCount > 0} + + {/if} +
{/snippet} {#snippet item(it)} diff --git a/src/lib/components/TrialExpiredScreen.svelte b/src/lib/components/TrialExpiredScreen.svelte index 91289221..7382a002 100644 --- a/src/lib/components/TrialExpiredScreen.svelte +++ b/src/lib/components/TrialExpiredScreen.svelte @@ -3,11 +3,25 @@ // expired and no valid license is present - there is no dismiss path, the // only way through is activating a license. import { onMount } from 'svelte' + import { revealApp } from '$lib/app-reveal.js' import Logo from './Logo.svelte' import Icon from './Icon.svelte' import LicenseActivation from './LicenseActivation.svelte' import WindowControls from './WindowControls.svelte' import { detectOs } from '$lib/platform.js' + import { cn } from '$lib/utils.js' + + /** Called once activation has finished celebrating; LicenseGate drops the gate on it. */ + let { onactivated = () => {} } = $props() + + /** The key was accepted: the copy turns into a welcome while the confetti runs. */ + let activated = $state(false) + /** The celebration is over: fade out, then let the gate hand over to the app. */ + let leaving = $state(false) + function finish() { + leaving = true + setTimeout(onactivated, 220) + } const isMac = typeof navigator !== 'undefined' && detectOs() === 'macos' @@ -18,6 +32,7 @@ let isTauri = $state(false) onMount(() => { isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window + revealApp() }) /** @param {MouseEvent} e */ @@ -36,7 +51,7 @@ } -
+
-
- +
+
-
-

Your free trial has ended

-

- Activate a license to keep using Stroke. Your saved connections and settings are untouched, they'll be right here. -

+
+ + {#if activated} +

Welcome to Stroke

+

Your license is active.

+

Opening your workspace.

+ {:else} +

Your free trial has ended

+

Activate a license to keep using Stroke.

+

Your connections and settings are saved and waiting.

+ {/if}
- + (activated = true)} onactivated={finish} />
-
+
- Get a license + Get a license ·