diff --git a/.changeset/README.md b/.changeset/README.md index edee078c..b4192432 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -1,7 +1,7 @@ # Changesets Add a `.md` file here in any PR that has user-visible changes. -Name it anything — `my-feature.md`, `fix-datatable.md`, etc. +Name it anything - `my-feature.md`, `fix-datatable.md`, etc. When the PR is merged with a `release:*` label, `auto-release.yml` folds all changeset files into `CHANGELOG.md` under the new version @@ -15,10 +15,10 @@ Use the same headings as CHANGELOG.md: ### New Features #### Canvas Table -- **Feature name** — What it does and why it's useful +- **Feature name** - What it does and why it's useful #### SQL Editor -- **Another feature** — Description +- **Another feature** - Description ### Bug Fixes - Fixed something that was broken @@ -28,9 +28,9 @@ Use the same headings as CHANGELOG.md: ``` Supported top-level headings (case-insensitive): -- `### New Features` — shows as ✨ New Features in the app -- `### Bug Fixes` — shows as 🐛 Bug Fixes -- `### Changes` — shows as 🔧 Improvements +- `### New Features` - shows as ✨ New Features in the app +- `### Bug Fixes` - shows as 🐛 Bug Fixes +- `### Changes` - shows as 🔧 Improvements Sub-sections (`####`) are optional but group items nicely in the release notes page. diff --git a/.changeset/advisor-windowing-and-scrolling.md b/.changeset/advisor-windowing-and-scrolling.md new file mode 100644 index 00000000..ce7485b8 --- /dev/null +++ b/.changeset/advisor-windowing-and-scrolling.md @@ -0,0 +1,47 @@ +### Changes + +- **The Advisor tells you what is wrong with your database.** A new tab runs a read-only lint pass over the connected database and reports what it finds, grouped by check and ranked worst-first: tables without row-level security, foreign keys with no index, indexes nothing has scanned, tables bloated with dead rows, tables whose rows are so wide that reading a page of them moves megabytes, and tables with no primary key. Every check is a catalog query - it never reads a row of your data, never writes, and depends on no extension being installed, so it is safe to point at production. Findings carry the SQL that would fix them, to copy and read before you run it; the Advisor never executes DDL. Severity is decided per finding rather than per check, so an unused 8 GB index and an unused 2 MB one do not shout equally loudly, and a check that cannot run - a role without permission on a catalog - says so instead of quietly counting as a pass. Export the report as CSV, JSON or Markdown. Postgres for now; other engines get an honest "no checks yet" rather than a clean bill of health they have not earned. + +- **Large tables no longer arrive in one enormous response.** Opening a table on a large page size used to be a single request for every row in it - a million-row table meant roughly 130 MB of JSON decoded on the main thread, per tab, which froze the whole app for as long as it took. A large fetch now reads a 200-row probe, measures how heavy the rows actually are, and either loads the rest normally or streams the table in windows sized to keep each request near 2 MB. A 143,000-row table opens in **185 ms** where it used to sit there for tens of seconds. This applies to any large limit, not just "All rows" - page size 1M is an ordinary option in the toolbar and taking it literally was the whole problem. + +- **Window size is measured in bytes, not rows.** What stalls the main thread is payload, and rows differ by three orders of magnitude: five thousand narrow rows are half a megabyte, five thousand rows of a table with an embedding column are ninety. Notably, `pg_stats` cannot see this - a TOASTed embedding is reported as the 18-byte pointer stored in the main tuple - so the measurement uses table size over row count and catches exactly the tables that hurt. + +- **A windowed table keeps its rows when you switch away and back.** It used to re-run the fetch and the count every time, so returning to a big table meant waiting again. Its window bookkeeping and its resident rows are preserved, and the scroll position with them. + +- **Every window of a view slices one ordering.** A windowed view is assembled from many separate `LIMIT/OFFSET` queries, and SQL guarantees nothing about the order of rows between two queries that do not ask for one - Postgres ships with `synchronize_seqscans = on`, so a second scan of the same table can legitimately begin at a different page. Assembled from unordered windows, one view could show a row twice, miss others, and change which row sat at a given position between fetches. Windowed views are now ordered by primary key (a user's sort keeps the lead, with the key appended as a tiebreaker), and a table with no key is never windowed - a single query is internally consistent, many unordered ones are not. + +- **A far window is an index seek, not a walk over everything before it.** `LIMIT n OFFSET 995000` makes Postgres walk 995,000 index entries before it can return a row - **345 ms** on a million-row table, where the same page fetched by key (`WHERE id > $last ORDER BY id LIMIT n`) takes **1.5 ms**. Every window slices one total order, so the last row of the window before it is exactly the anchor that page needs, and a window next to one already resident is now read that way. The conditions are deliberately narrow, because being wrong here means missing rows rather than slow ones: Postgres, a single ordering column that is the whole primary key (a non-unique key would skip every row sharing the anchor's value), and a type the backend will accept. Anything else, or the first error on the seek path, and the view stays on offsets. The check the backend runs before each keyset page is memoised for ten seconds, so the fast path does not put a catalog round-trip in front of every window it just saved a scan on. + +- **The grid says which rows it is loading.** Skeleton bars only say that rows are coming, never which ones - on a million-row table a jump can leave a screenful of them up for a second with nothing to read. A pill now names the row span being fetched, appearing only after a window has been in flight long enough to be worth mentioning, so a window that lands quickly does not flash it on the way past. When a window has exhausted its retries the pill says so and offers Retry. + +- **Smooth scrolling, and a switch to turn it off.** The grid and the sidebar ease each wheel tick to its destination, which reads as smoother on mice that scroll in big jumps. Settings → Appearance → **Native scrolling** hands both back to the OS, momentum and all, with nothing of ours in front of the wheel. Off by default. Someone who has asked their system for reduced motion gets the jump, not the sweep. + +- **Row spacing, and alternating row colours.** Settings → Appearance offers Compact, Standard and Relaxed row heights - compact fits about a third more rows on screen - and a shading toggle for every other row, independent of the grid-style preset. + +- **Auto-refresh, per tab.** The caret beside Refresh sets an interval of 5 seconds to 5 minutes for the table you are looking at, so a table you are watching re-polls while one you are editing stays still. It skips a tick while a load is in flight or a cell editor is open, and refreshes in place rather than jumping you back to the top. + +- **SQL formatting is configurable.** Settings → Database → SQL formatting exposes keyword, data type, function and identifier casing, tab width, tabs versus spaces, whether `AND`/`OR` lead or trail the line break, expression width and blank lines between statements. Identifier casing defaults to preserve, because a quoted identifier is case-sensitive and rewriting it can change what a statement means. + +- **Executed queries can file themselves under Saved Queries.** Off by default; when on, a successful run is saved as well as logged, deduplicated by its SQL so re-running the statement you are iterating on adds one entry rather than fifty. + +### Bug Fixes + +#### The data grid +- **A row fetch now belongs to the tab that started it.** Loads wrote into shell-level state guarded by a single global token, so switching tabs mid-fetch dropped a million-row result into whichever table happened to be on screen, and coming back to the tab you left restarted its work. Every load is owned by its tab: results land in that tab's state, the visible grid is only touched while that tab is in front, and a load left running finishes into the tab that asked for it. +- **Opening several large tables at once no longer locks the app.** A background tab was prefetching its full page size, so three tables opened on a large limit fired three enormous requests at the same time. A limit past the windowing bar waits for the tab to be activated. +- **The skeleton no longer looks like the layout shifted when rows arrive.** Placeholder bars sat hard left at a flat half-width in every column, so the moment real rows landed every right-aligned value jumped to the other side of its cell. Bars now take the alignment their column will use, vary in width like text of differing length, sit on the band the glyphs will occupy, and shimmer while the window is in flight. +- **Column headers line up with each other and with their own values.** The red `NOT NULL` asterisk led the column name, which indented the name by its own width on required columns only - so those headers sat a few pixels right of every other header. It now trails the name. +- **An expanded JSON row no longer paints over the column header.** The panel has to sit above the canvas, which paints an opaque background, so scrolling its row up behind the sticky header drew the panel over the header. It is clipped to the band below it. +- **Alternating row shading is visible.** It was painted at 7% of a muted tone, which against the panel background in a dark theme is nothing at all - the Striped and Dots presets looked like they did nothing either. +- **A window whose fetch failed no longer shimmers forever.** A failed window was only retried by the next visible-range emit, so stopping the scroll over it left those rows as skeletons with nothing coming. Each window now retries on its own up to three times before it gives up, and giving up is visible and retryable rather than silent. +- **One slow page no longer turns read-ahead off for the rest of the view.** A single measurement past the slow threshold latched the fetcher down to one window in flight - and one deep offset page on a big table crosses that threshold on its own merits, so the tables that need read-ahead most were the ones that lost it. The decision reads a rolling mean now. +- **A reload that keeps your scroll position refills the rows under it.** Reloading in place hands the grid a fresh rows array holding nothing but its probe, but the viewport had not moved, so the range it re-emitted matched the one the shell had already heard and was dropped as a duplicate - leaving every row under the viewport a skeleton until the user happened to scroll. +- **The skeleton shimmer stopped blinking once a period.** The sweep restarted with its highlight band still half on screen, so the gradient vanished mid-grid on the beat. It now travels a full band clear of both edges, and the whole frame's bars share one path and one gradient instead of a fill each - per-bar fills were the most expensive thing on screen in exactly the state that has to feel smooth. +- **Deep exports chunk by weight.** Fifty thousand rows of a table with an embedding column is most of a gigabyte in a single response. + +#### SQL editor +- **Two editor tabs can run at once, and Stop hits the right query.** Cancellation was keyed by a single slot, so the second run overwrote the first's handle and Stop cancelled whichever query registered last rather than the one whose button was pressed. Each run carries its own id. +- **A query that finishes while you are on another tab no longer leaves the editor looking busy.** The shared loading flag was only cleared when the finishing run's own tab was still in front, so the next snapshot of any editor tab could capture that stale state as its own. + +#### Settings +- **The Settings pane stopped being slow to open.** The SQL formatting section mounted nine popover selects - each a floating layer with a focus trap and a search box - merely to show the Database tab. Those are inline choice controls now, and the section starts collapsed; search still expands it. Formatter options are also read through a validator, so settings written by an older version cannot throw the whole pane into the error boundary. diff --git a/.changeset/app-pin-lock-and-password-reveal.md b/.changeset/app-pin-lock-and-password-reveal.md new file mode 100644 index 00000000..0b3f0433 --- /dev/null +++ b/.changeset/app-pin-lock-and-password-reveal.md @@ -0,0 +1,39 @@ +### New Features + +#### Security +- **Lock Stroke behind a PIN.** Settings → General → Security sets a four-digit PIN. With one set, Stroke opens onto a lock screen and the app is not mounted behind it: no connection, no license check, no auto-reconnect until the PIN is right. The PIN is never stored, only a PBKDF2-HMAC-SHA256 digest over a random salt, kept in the OS keychain next to the saved credentials, so the switch that turns the lock on cannot be flipped from a devtools console the way a localStorage flag can. Wrong entries earn a growing delay that tops out at three seconds, which is nothing after a typo and fatal to a script. There is no recovery code by design; the lock screen says exactly which keychain entry to delete if you are locked out. +- **The PIN also guards connecting.** "Ask when connecting" confirms the PIN before opening or reconnecting to a database, whether that comes from the connection dialog or from switching connections in the status bar. The reconnect right after you unlock the app is exempt, and the silent background heal that repairs a dropped pool never prompts. Auto-lock puts the screen back up after 1, 5, 15 or 60 minutes of inactivity, over a session that is kept intact: open tabs, queries and scroll position are all still there when you come back, and the keys typed at the lock screen never reach the app underneath it. `Lock Stroke` in the command palette locks on demand. + +#### Connections +- **Every password field has an eye.** Database passwords, the libSQL auth token, the Redis password and the Cloudflare D1 API token can all be revealed while you type them. A masked field with no way to check what is in it is how one wrong character survives three failed connects. + +### Changes + +#### Filters +- **The caret lands in the value.** Opening the filter bar, from the toolbar or from ⌘F, now focuses the value of the row it seeds, as does "+ Add filter". Every control in a filter row also shares one border, one surface and one hover: column, condition and value each had their own pairing, so a clause that reads as one sentence was drawn as three unrelated boxes. The value field is capped rather than free-running, which stops it stretching into an empty box the width of the window. + +#### Sidebar +- **A count that has not arrived draws nothing.** The placeholder mark on every uncounted row made a long table list read as a column of dashes, which says "empty" much louder than "counting". Counts also sit a size down from the table names now, so the names lead. + +#### ER diagram +- **One line per pair of tables, not one per foreign key column.** A table carrying `created_by` and `updated_by`, both pointing at `users.id`, drew two relationships that leave the same card, cross the same corridors and land on the same row. They were drawn twice, routed twice, and told the reader one thing. Parallel foreign keys in the same direction now collapse into a single line carrying a count chip, so nothing is hidden and every column still shows its own `fk` badge in its row. Direction stays part of the identity: `a` referencing `b` and `b` referencing `a` are two facts and stay two lines. Cardinality merges the way a reader reads it, many if any of the keys can repeat, optional only if every one of them is nullable. Merging runs before the layout, so Dagre and the router each have fewer lines to work through, which is the difference between a large schema finishing its routing budget and running out of it. +- **A schema is no longer one tall strip.** A ranked layout puts every node of a rank in one column, and a schema with a hub puts most of the schema in one rank: a `users` table ninety others carry a foreign key to gave a second rank ninety cards tall, and a diagram around 1:10 that was unreadable at any zoom with every line in it running the full height. A rank taller than the page is folded into as many sub-columns as it needs now, in the order the layout chose, with the ranks after it shifted right - so the crossing work the layout did is kept and only the column it did it in changes. A diagram that is already a reasonable shape is left exactly as the layout left it, since folding one costs its vertical alignment for nothing. Folding does cost the layout's own edge corridors, because the space they were reserved in has moved, so a folded diagram routes its lines around the cards instead of following them - which it can now afford, being compact. +- **Every line is drawn as right angles.** The layout hands back a polyline per relationship, and joining its points with straight segments was the obvious reading and the wrong one: in a left-to-right layout those points sit at different heights, so every relationship came out as a long diagonal, and a canvas of crossing diagonals is unreadable however it is stroked. The same points are now walked one axis at a time - horizontal runs along the lane the layout reserved, the turn between two lanes taken midway between their points, which in a ranked layout is the gutter between two ranks. So every segment still lies in space the layout kept clear of cards, which is what stops a line vanishing under one, and the diagram reads as the right-angled connector an ER diagram is meant to have. Unrouted lines (direct mode, a graph past the routing cap, a card mid-drag) take the same shape on their own turn column, so there is one geometry on screen and in the SVG and PNG exports. Lines are a touch heavier and hold more of their contrast when zoomed out, since they are traceable now rather than noise. +- **Relationships share their corridors instead of each taking a lane.** The router charged a route for reusing a segment another one had taken, on the theory that two lines drawn on top of each other cannot be told apart. Measured on a 135-card schema with 164 relationships, that penalty was the worst thing in the diagram: every line detoured into a free lane rather than run alongside its neighbours, which took the average line from 3.7 corners to 10.6, put 247k pixels of line on a canvas that needs 70k, and made routing take 1.67s instead of 0.21s. What it produced was not separable lines, it was a mesh. Lines going the same way now run together as one trunk and split at the ends where their ports differ, which is what every tool that draws a whole schema does, and tracing one relationship is what selecting its table is for. The cliff is sharp - a penalty of 1 already doubles the ink - so the constant and its per-segment counters are gone rather than tuned. Detours got shorter too, 1.17x average down to 1.03x, and nothing crosses a card. +- **Cards are not empty rectangles any more.** Below the zoom where column text is legible the rows were skipped entirely, which is most of a schema-wide view: the diagram was a field of blank boxes joined by lines. The rows are drawn as bars at those zooms instead, in the same PK and FK colours and on the same rhythm, so a card still reads as a table. Lines other than the selected relationship also recede at that zoom, so a few hundred of them stop burying the cards they connect. +- **Tighter gutters.** Every spacing preset came down by roughly a fifth. The corridors were sized for an orthogonal router that no longer draws the lines. + +### Bug Fixes + +#### ER diagram +- **A line connects to the near edge of a card, not always to its side.** A port on a card's left or right sits on the row the relationship is about, which is why it is the one worth having, so it is still what you get whenever two cards are separated horizontally at all. When they overlap in x there is no side to leave from, and picking one anyway sent the line out of the card, back across its own width and in again. A quarter of the relationships in a folded schema are in exactly that position. Those take the near horizontal edge instead, bottom to top or top to bottom. Measured on a stacked pair: 748px over four corners becomes 240px over two. +- **Lines that reference one column land on it, as one line.** Lines converging on the same column were fanned apart so they would not all arrive on the same pixel. Spread across the card that read as a lie - ten tables referencing `users.id` arrived beside `password`, `role` and `cash_balance` - and confining the spread to the row's own band only shrank the lie into a comb of elbows on the card's edge. It is the same idea as the anti-overlap penalty, and wrong for the same reason: ten lines that mean one thing should look like one thing. Ports are not spread at all now. Every line lands on the column it references, the router brings them in along a shared trunk, and one line arrives. Which table you are looking at is what selecting it answers. +- **Dropping a card no longer rebuilds every line in front of you.** Committing a drag changed the layout signature, which threw the whole route cache away and played the routing run out again from nothing, in slices, while you watched. Only the cards that actually moved invalidate anything now: a line between two cards that stayed put is still the line it was, unless a mover is now lying across it. That last test is per segment rather than per bounding box, which matters more than it sounds - a line routed around a card has a box covering that card, so answering from the box threw away every route near anything that moved. Moving one card of 135 re-routes 8 lines instead of 132. +- **Panning and zooming a large diagram is smooth again.** Three things were on the per-frame path that had no business being there. The nodes, the edges, the routing hints and the table metadata were all deep-reactive state, so every `n.position.x` and every `columns[i].name` the canvas read went through a proxy trap - and the canvas reads those for every card and every row of every frame, with a proxy allocated per column object the first time it is touched. They are raw now; nothing was ever mutated in place, so no reactivity is lost. The viewport cull recomputed each line's bounding box from all of its points every frame, for a number that only changes when the line is re-routed; it is memoised on the polyline itself. And the port geometry for every edge was recalculated on every frame even when the route was already cached and the ports were about to be thrown away. Curves are also traced as straight segments below the row LOD, where a corner radius is two or three screen pixels. + +#### Sidebar +- **Row counts fill in on a large schema.** Every count in a schema went out as one request and was applied only once all of them came back, so a 135-table production schema had to finish every `COUNT(*)` before a single number appeared - and a connection swapped or dropped part-way through threw the finished counts away with the rest. That is why the sidebar sat on blanks with one lone number in it. Counts go out in chunks of twelve in list order now, landing in waves down the list, and a chunk that fails costs only its own tables. + +#### Data grid +- **Clearing a filter loads the rows again.** On "All", the fetch limit is the row count, and the count left over from the filtered view was being used to size the fetch that cleared it: filter a 752-row table down to one row, clear the filter, and the next query ran `LIMIT 1`. The grid drew a single row under a footer reading "1-752 of 752". A count now carries the search and filters it was taken under, and is only used as a limit for the view it actually describes; otherwise the fetch asks for the ceiling and the existing probe sizes the view from a fresh count. +- **An applied cell edit shows the new value.** Cell text is cached per row so the canvas is not re-formatting the same strings on every scroll frame, and the cache was dropped only when the whole `rows` array was replaced. Saving a cell does not do that: it swaps that one row and bumps a repaint counter, so the repaint ran and drew the cached pre-edit text straight back. The cache now checks the row it was built from, so an edit appears the moment it is applied instead of after a refresh. Reported in #82. diff --git a/.changeset/erd-table-filter-and-large-schemas.md b/.changeset/erd-table-filter-and-large-schemas.md new file mode 100644 index 00000000..cee502ed --- /dev/null +++ b/.changeset/erd-table-filter-and-large-schemas.md @@ -0,0 +1,23 @@ +### New Features + +#### ER diagram +- **Pick the tables the diagram shows.** A filter beside the search box lists every table in the schema with its foreign key count; tick the ones worth seeing and the diagram redraws to exactly those, with every relationship among them. The first tick means "just this one" rather than starting from all 900 selected and asking for 899 clicks. `Linked` narrows to the tables that have a foreign key at all, `+ Related` grows the selection by one hop, `All` clears it, and `only` on any row jumps straight to that table and what it links to. An explicit selection beats the scope buttons and the linked-only toggle: it is the one signal that came from someone naming tables, so nothing quietly adds to it. The filter belongs to the schema and the session, not to your saved settings. +- **Isolate or hide a table from its own card.** Selecting a card opens the inspector; the crosshair there scopes the diagram to that table and its neighbours, the eye takes it off the diagram. Hiding is disabled for the table a per-table diagram belongs to, which would otherwise come straight back. + +### Changes + +#### ER diagram +- **A schema too wide to draw waits to be asked.** Past 240 tables the diagram no longer lays itself out on arrival - it says how many tables it found and offers the table filter or an explicit "Draw all". Laying out and drawing that many cards is seconds of work, and the result is a wall nobody reads. +- **A wide schema opens keys-only.** Above 120 tables, cards show their primary and foreign keys instead of every column, unless you have chosen otherwise. Card height is what a layout pass pays for, twice over: taller cards mean taller ranks mean a wider graph. + +### Bug Fixes + +#### ER diagram +- **The lines follow the layout instead of fighting it.** Every relationship was routed by A* on a grid built after the cards were placed, which is the wrong order: by then the space between them is already taken, so on a wide schema most lines ran out of budget and fell back to a plain elbow drawn straight across whatever was in the way. The result was a circuit board of overlapping horizontal runs with lines through cards. Dagre already routes every edge while it places the cards, through dummy nodes it reserved space for, so those points are the one line guaranteed to miss every card - and they cost nothing, because the layout has already paid for them. They are what gets drawn now, as smooth curves rather than right angles, because a curve keeps its identity across a busy canvas where dozens of parallel elbows do not. Measured on a 239-table schema: **zero lines crossing a card**, layout and lines together in **131 ms**. The SVG and PNG exports draw the same curves. +- **Two foreign keys between the same pair of tables are two lines again.** Dagre folds unnamed parallel edges into one, so they shared a corridor and a set of crow's feet. Every edge is named now, on a multigraph, and `edgesep` keeps their corridors apart. +- **A hub is no longer a starburst.** Every foreign key points at the same primary key, so a table referenced by twenty others had twenty lines converging on one pixel and nothing traceable out of it. Ends that share a port now fan out around it, ordered by where each line came from so the fan never crosses itself, and never spread wider than the card can hold. +- **Disconnected clusters are packed, not spread.** A schema is rarely one graph: it is a few clusters plus a long tail of small pairs. Laying them out together put every cluster in the same rank columns, so the diagram came out mostly empty space with the cards too small to read at fit-zoom. Each connected component is laid out on its own now and packed onto shelves of columns aimed at a landscape page, which is what Graphviz calls `pack`. Small components also lay out faster than one big one, Dagre being superlinear. +- **The rank gutter is no longer wider than a card.** `ranksep` was 320px at the default spacing, sized for an orthogonal router that needed a corridor of stacked lanes between every pair of ranks. Curves need far less: 190px, and the compact and spacious presets in proportion. +- **Panning stopped allocating.** Resolving which column a line attaches to built a template string per column per edge on every frame - tens of thousands of throwaway strings a frame on a schema-sized diagram. Handles resolve through a map built once per layout, the drawable lines are built once per layout rather than per frame, the minimap paints its cards into an offscreen canvas and only the viewport rectangle is redrawn as you pan, and the font and fill a card's rows share are set once instead of three times a row. +- **The per-table diagram's routing no longer freezes the window.** A table and its neighbours are packed by hand rather than ranked, so there are no corridors to follow and those lines are still routed by A*. That ran in one synchronous call inside a single animation frame: 400 tables measured at **5.4 seconds** with nothing painted and no input handled, which on a large schema is indistinguishable from a hang, and long enough for the webview to be killed as unresponsive. Routing now runs in slices of 8 ms per frame - lines appear immediately as plain elbows and sharpen into routed ones over the next few frames, with a pill saying how far along it is. A run that overruns 2.5 seconds in total stops and leaves the rest as elbows rather than burning frames on a graph with no clear corridors left. The last few completed runs are kept, so crossing the zoom threshold that changes where lines attach no longer re-routes from scratch. +- **Card separation is a sweep, not every pair.** The pass that guarantees no two cards overlap compared all of them against each other, sixty times over: **349 ms** on 1200 cards, on top of everything else a large diagram already pays. Cards are visited left to right now and each one only looks at its horizontal neighbours - the same 1200 cards take **86 ms**, and the invariant is unchanged. diff --git a/.changeset/manage-databases-from-the-sidebar.md b/.changeset/manage-databases-from-the-sidebar.md new file mode 100644 index 00000000..e1ede888 --- /dev/null +++ b/.changeset/manage-databases-from-the-sidebar.md @@ -0,0 +1,8 @@ +### New Features + +#### Sidebar +- **Manage databases, not just switch between them.** Right-click any row in the sidebar's Databases section for the server-level operations: rename, duplicate, drop, close other sessions, copy the name, and a Database info panel with owner, encoding, collation, size on disk and how many sessions are connected. A `+` in the section header creates one. PostgreSQL and MySQL, with each item enabled only where the engine can actually do it: MySQL has no `RENAME DATABASE` and no single-statement copy, and Postgres cannot rename, copy or drop the database the session is attached to, so those items say why rather than failing at the server. Every one of them shows the exact statement before it runs, dropping asks for the name typed back, and the drop dialog offers `WITH (FORCE)` since a single idle connection is enough to make Postgres refuse. + +### Changes +- **The relationship column's chip stops changing shape under the cursor.** It was a fully rounded capsule with no border at rest, so hovering conjured a lozenge out of what read as plain text, and a long table name inside a full-radius pill read as a balloon. The chip now keeps one shape and one radius that matches the rest of the app, and hover moves its surface and border rather than its geometry. +- **No more em dashes anywhere in the app.** Every dash in the interface, in the settings copy and in the messages the backend sends up is now ordinary punctuation. diff --git a/.changeset/plugin-api-and-catalog-cache.md b/.changeset/plugin-api-and-catalog-cache.md new file mode 100644 index 00000000..b5306d41 --- /dev/null +++ b/.changeset/plugin-api-and-catalog-cache.md @@ -0,0 +1,9 @@ +### New Features + +#### Extensions +- **Plugins can now come from a folder.** Stroke loads third-party formatters from `/plugins//`: a `manifest.json` and one JavaScript file. Each one runs inside its own Web Worker with no DOM, no app state, no Tauri bridge, and with `fetch`, `XMLHttpRequest`, `WebSocket`, `EventSource`, `Worker`, `importScripts`, `indexedDB`, `caches`, `BroadcastChannel` and both storages removed before its code is evaluated, so a generation 1 plugin has no route to the network at all. Everything it returns passes a whitelist on the way to the grid: only the render-directive fields the app draws survive, colours have to be hex or `rgb()`, links have to be `http`, `https`, `mailto` or `tel`, strings are capped and stripped of control characters. Install from the Extensions panel with `+`, turn it on, and Reload re-reads the file from disk, which is the whole development loop: no build step, no bundler, no restart. A plugin that times out or throws three times is switched off with the reason shown under its row, because a formatter must never be able to take the grid down. Manifests are validated in Rust before any code is read: the id has to be a folder-safe slug matching its folder, the entry has to be a bare `.js` filename, and a folder that fails is listed as Broken with the reason rather than silently ignored. `docs/PLUGIN_API.md` has the contract, `types/stroke-plugin.d.ts` the types, and `examples/plugins/traffic-light/` a working plugin in forty lines. + +### Changes +- **The whole sidebar is windowed, not just the tables list.** Views, materialized views and the databases list rendered a capped 500 rows, which both instantiated 500 context menus and hid whatever came after row 500 without saying so. All four lists now render only what fits the viewport plus a buffer, off one shared piece of window maths measured from the real row stride. +- **Revisiting a schema no longer re-reads its catalog.** Indexes, enums, triggers and sequences were reloaded on the strength of a single "which schema was loaded last" string, so switching schema A to B and back cost all four round trips every time. Every catalog read (table lists, schema catalogs, incoming foreign keys) now shares one cache keyed connection-first, with a freshness window per kind and a single invalidation point that every refresh and every DDL path already goes through. +- **A JSONPath suggestion says whether its sample is representative.** The value preview beside a key under a wildcard was read from the first element alone, so it never changed, which read as "this value is the same in every row" without having checked. Keys are now measured across up to 200 elements: the type comes from the first non-null value (a null first row used to mislabel the whole key), the type reads `string | null` when the key is genuinely both, and the row says `same in all 1,204` or `37 distinct` next to the sample. diff --git a/docs/PLUGIN_API.md b/docs/PLUGIN_API.md new file mode 100644 index 00000000..38767fec --- /dev/null +++ b/docs/PLUGIN_API.md @@ -0,0 +1,170 @@ +# Plugin API + +Stroke loads plugins from a folder. A plugin is a `manifest.json` and one +JavaScript file, it runs inside a Worker with the network taken away, and it +gets one job in generation 1: turn a cell value into a render directive. + +This is the same contract the twenty built-in extensions use. The difference is +where the code comes from and what it is allowed to reach. + +## The shortest plugin that does something + +``` +my-plugin/ + manifest.json + main.js +``` + +`manifest.json`: + +```json +{ + "id": "my-plugin", + "name": "My Plugin", + "version": "1.0.0", + "apiVersion": 1, + "kind": "formatter", + "entry": "main.js", + "description": "What it does, in one line.", + "author": "You", + "homepage": "https://example.com", + "permissions": ["cells:read"] +} +``` + +`main.js`: + +```js +module.exports = { + appliesTo(type, name) { + return /(^|_)email($|_)/i.test(name) + }, + format(value) { + if (typeof value !== 'string' || !value.includes('@')) return null + return { link: `mailto:${value}`, fg: '#60a5fa' } + }, +} +``` + +Install it: **Extensions** panel, `+`, pick the folder. Turn it on. Open a table +with an `email` column. + +A working example lives in `examples/plugins/traffic-light/`, and the types are +in `types/stroke-plugin.d.ts`. + +## The manifest + +| Field | Required | Rules | +|---|---|---| +| `id` | yes | 2 to 48 characters, lowercase letters, digits, single hyphens. It **is** the folder name, and the two must match. | +| `name` | yes | Shown in the Extensions panel. | +| `version` | yes | Any string. Shown as `v1.0.0`. | +| `apiVersion` | yes | `1`. A host refuses a generation it does not implement. | +| `kind` | yes | `formatter`. The only hook generation 1 runs. | +| `entry` | yes | A bare `.js` or `.mjs` filename in the plugin folder. No paths. | +| `description`, `author`, `homepage` | no | Shown in the panel. | +| `permissions` | no | Only `cells:read`. See below. | + +A folder that fails any of these still appears in the panel, marked **Broken** +with the reason. It is never executed. + +## The plugin object + +Assign to `module.exports`. The entry file is evaluated as a CommonJS module, +not an ES module, so there is no build step and no bundler between you and the +app. + +### `appliesTo(type, name, config) => boolean` + +Whether this plugin has an opinion about a column. Optional; omit it and you are +asked about every column. + +Asked **once per column**, and a `false` is remembered: `format` is never called +for that column's cells again. This is the single most important function in +your plugin for performance. Match on the column name and type, narrowly. + +### `format(value, ctx) => directive | null` + +Turn one value into a directive, or return `null` to leave the cell as it was. + +- `value` is a `string`, `number`, `boolean` or `null`. JSON columns and arrays + are not offered in generation 1. +- `ctx` is `{ type, name, config }`. +- It must be **pure**. Results are cached per distinct value per column, so the + same value must always produce the same directive. A `format` that reads the + clock or a counter will look broken. +- Budget: 2 seconds per batch. Three timeouts or throws and the plugin is + switched off with a reason in the panel. + +## Directive fields + +| Field | Type | Effect | +|---|---|---| +| `display` | string | Text drawn instead of the value | +| `title` | string | Tooltip | +| `badge` | `{bg, fg}` | Pill behind the text | +| `swatch` | colour | Square before the text | +| `dot` | colour | Dot before the text | +| `fg` | colour | Text colour | +| `bgTint` | colour | Cell background | +| `mask` | string | Text shown until the cell is revealed | +| `reveal` | boolean | Whether a masked cell starts revealed | +| `link` | string | Makes the cell a link | +| `warn` | string | Warning against the cell | + +Colours must be `#rgb`, `#rrggbb`, `#rrggbbaa`, `rgb(...)` or `rgba(...)`. +Links must be `http:`, `https:`, `mailto:` or `tel:`. Anything else in either +position is dropped, along with every field not in this table. Strings are +capped at 512 characters (2048 for `link`) and stripped of control characters. + +Built-in extensions win any field they also set: a plugin adds to the picture +rather than overriding what ships in the app. + +## What a plugin can and cannot reach + +It runs in a Worker, so there is no DOM, no `window`, no app state, and no +Tauri bridge - a plugin cannot query the database, read a connection, or draw +anything itself. Before your code is evaluated, the host also removes `fetch`, +`XMLHttpRequest`, `WebSocket`, `EventSource`, `Worker`, `importScripts`, +`indexedDB`, `caches`, `BroadcastChannel`, `localStorage` and `sessionStorage`. +A generation 1 plugin has no way out to the network. + +`permissions` therefore accepts exactly one entry, `cells:read`: the values of +the columns your `appliesTo` accepted. Declaring anything else marks the plugin +broken, on purpose. A permission the host cannot police is a promise it cannot +keep, so the list grows only as enforcement does. + +Installing copies your folder into the app's data directory, taking only `.js`, +`.mjs`, `.json`, `.md`, `.txt` and `.svg` files, from the top level, up to 4 MB +in total with a 1 MB cap on the entry file. + +## How it performs + +Formatters are consulted per visible cell per repaint, and a Worker answers +asynchronously, so the host does not wait for you: + +1. The grid asks for a cell's directive. On a cache hit it draws it. +2. On a miss it draws the cell plain and notes the value. +3. On the next tick, the distinct missed values of each column go out in one + message, up to 400 at a time. +4. When answers land, the grid repaints with them. + +So your first paint of a new value is plain, and the next frame has your +directive. Low-cardinality columns (a status, a role, a boolean) collapse to a +handful of calls no matter how many rows are on screen. + +## Development loop + +Edit `main.js`, press **Reload** on the plugin in the Extensions panel. That +re-reads the file from disk and restarts its Worker. No build, no restart. + +Errors show under the plugin's row in the panel. A plugin that throws on load +never runs; one that throws while formatting has that cell drawn plain. + +## Not in generation 1 + +Context-menu items, export formats, SQL generators, AI tools, connection +providers, plugin-owned tabs, network access through a host proxy, per-plugin +storage, and a registry to install from. The hook contract and the manifest were +built with these in mind: each is a new `kind` and, where it reaches something, +a new permission the host enforces in Rust. diff --git a/examples/plugins/traffic-light/README.md b/examples/plugins/traffic-light/README.md new file mode 100644 index 00000000..897db0bb --- /dev/null +++ b/examples/plugins/traffic-light/README.md @@ -0,0 +1,12 @@ +# Traffic Light + +An example Stroke plugin. Colours numeric latency, score and percentage columns +green, amber or red. + +Install it from Stroke: Extensions panel, `+`, pick this folder. Then turn it on +and open a table with a `latency_ms` or `score` column. + +Editing `main.js` and pressing Reload in the panel is the whole development +loop: no build step, no bundler, no restart. + +See `docs/PLUGIN_API.md` in the Stroke repository for the full contract. diff --git a/examples/plugins/traffic-light/main.js b/examples/plugins/traffic-light/main.js new file mode 100644 index 00000000..cf916113 --- /dev/null +++ b/examples/plugins/traffic-light/main.js @@ -0,0 +1,46 @@ +// A complete Stroke plugin, and the shortest useful one I could write. +// +// It runs inside a Worker with no DOM, no network and no access to the app, and +// its whole job is to turn a value into a render directive. The entry file is +// evaluated as a CommonJS module, so assign the plugin to `module.exports`. + +// Columns worth colouring. A formatter that applies to everything is a +// formatter that slows down every table, so be specific here: `appliesTo` is +// asked once per column, and a "no" means this plugin is never consulted for +// that column's cells again. +const COLUMN = /(^|_)(latency|latency_ms|duration_ms|score|health|uptime|percent|pct|cpu|memory)($|_)/i + +/** Green under `warn`, amber under `bad`, red above. */ +const THRESHOLDS = { warn: 70, bad: 90 } + +const COLORS = { + good: { bg: 'rgba(34,197,94,0.16)', fg: 'rgb(74,222,128)' }, + warn: { bg: 'rgba(245,158,11,0.17)', fg: 'rgb(251,191,36)' }, + bad: { bg: 'rgba(239,68,68,0.16)', fg: 'rgb(248,113,113)' }, +} + +module.exports = { + /** + * @param {string} type column type as the engine reports it + * @param {string} name column name + * @returns {boolean} whether format() should be called for this column + */ + appliesTo(type, name) { + return COLUMN.test(name) && /int|float|double|numeric|decimal|real|number/i.test(type) + }, + + /** + * @param {unknown} value the cell value + * @param {{ type: string, name: string, config: Record }} ctx + * @returns {object | null} a render directive, or null to leave the cell alone + */ + format(value, ctx) { + const n = Number(value) + if (!Number.isFinite(n)) return null + const band = n >= THRESHOLDS.bad ? 'bad' : n >= THRESHOLDS.warn ? 'warn' : 'good' + return { + badge: COLORS[band], + title: `${ctx.name} = ${n} (${band})`, + } + }, +} diff --git a/examples/plugins/traffic-light/manifest.json b/examples/plugins/traffic-light/manifest.json new file mode 100644 index 00000000..dc9fb4e3 --- /dev/null +++ b/examples/plugins/traffic-light/manifest.json @@ -0,0 +1,12 @@ +{ + "id": "traffic-light", + "name": "Traffic Light", + "version": "1.0.0", + "apiVersion": 1, + "kind": "formatter", + "entry": "main.js", + "description": "Colours latency, score and percentage columns green, amber or red.", + "author": "Stroke", + "homepage": "https://github.com/broisnischal/stroke-app", + "permissions": ["cells:read"] +} diff --git a/index.html b/index.html index e2d0702f..22712fa5 100644 --- a/index.html +++ b/index.html @@ -13,7 +13,7 @@ const lightThemes = ['light', 'parchment', 'ice', 'github-light', 'light-high-contrast'] let theme = parsed?.theme if (!valid.includes(theme)) { - // No saved preference — follow the OS + // No saved preference - follow the OS const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches !== false theme = prefersDark ? 'dark' : 'light' } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ba172330..058d22eb 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -118,6 +118,7 @@ dependencies = [ "futures", "getrandom 0.2.17", "hex", + "hmac", "keyring", "libc", "log", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4014aa8c..9d8f3a11 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -42,6 +42,7 @@ uuid = { version = "1", features = ["serde"] } urlencoding = "2" ed25519-dalek = "2" sha2 = "0.10" +hmac = "0.12" base64 = "0.22" hex = "0.4" getrandom = "0.2" diff --git a/src-tauri/Daily AI Usage.png b/src-tauri/Daily AI Usage.png new file mode 100644 index 00000000..eb2a0de7 Binary files /dev/null and b/src-tauri/Daily AI Usage.png differ diff --git a/src-tauri/src/app_lock.rs b/src-tauri/src/app_lock.rs new file mode 100644 index 00000000..bcc1b95f --- /dev/null +++ b/src-tauri/src/app_lock.rs @@ -0,0 +1,289 @@ +//! App PIN lock: an optional local PIN that gates opening the app and +//! reconnecting to a database. +//! +//! The PIN itself is never stored - only a PBKDF2-HMAC-SHA256 digest over a +//! random 16-byte salt, kept in the same OS-keychain vault as every other +//! secret (see `secrets.rs`). Keeping the *enabled* flag there too is what +//! makes the lock worth having: a flag in localStorage can be flipped from +//! devtools, a keychain entry cannot. + +use hmac::{Hmac, Mac}; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::time::Duration; + +type HmacSha256 = Hmac; + +/// Key the lock config lives under inside the shared secrets vault. +const VAULT_KEY: &str = "__app_lock__"; +/// PBKDF2 rounds. Roughly 100ms on a current desktop; sha2/hmac are compiled at +/// opt-level 3 even in the dev profile (see Cargo.toml), so this is not a +/// debug-build trap. +const ITERATIONS: u32 = 200_000; +/// Fixed PIN length. The UI renders exactly this many boxes. +pub const PIN_LEN: usize = 4; + +/// Failed unlock attempts this run. Drives a growing delay so a stolen laptop +/// can't be walked through all 10^6 PINs at IPC speed. +static FAILURES: AtomicU32 = AtomicU32::new(0); + +#[derive(Serialize, Deserialize, Clone, Default)] +struct LockConfig { + #[serde(default)] + enabled: bool, + /// Hex, 16 random bytes. + #[serde(default)] + salt: String, + /// Hex, PBKDF2-HMAC-SHA256 of the PIN. + #[serde(default)] + hash: String, + #[serde(default)] + iterations: u32, + /// Ask for the PIN again before opening or reconnecting to a database. + #[serde(default)] + require_on_connect: bool, + /// Re-lock after this many idle minutes. 0 = never. + #[serde(default)] + auto_lock_minutes: u32, +} + +/// What the frontend is allowed to see. Never carries the salt or the digest. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LockStatus { + enabled: bool, + require_on_connect: bool, + auto_lock_minutes: u32, + pin_length: usize, +} + +impl From<&LockConfig> for LockStatus { + fn from(c: &LockConfig) -> Self { + LockStatus { + enabled: c.enabled && !c.hash.is_empty(), + require_on_connect: c.require_on_connect, + auto_lock_minutes: c.auto_lock_minutes, + pin_length: PIN_LEN, + } + } +} + +/// PBKDF2-HMAC-SHA256 with dkLen == hLen, so exactly one output block. +fn derive(pin: &str, salt: &[u8], iterations: u32) -> [u8; 32] { + let key = pin.as_bytes(); + let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length"); + mac.update(salt); + mac.update(&1u32.to_be_bytes()); + let mut block = mac.finalize().into_bytes(); + let mut out = block; + for _ in 1..iterations.max(1) { + let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length"); + mac.update(&block); + block = mac.finalize().into_bytes(); + for (o, b) in out.iter_mut().zip(block.iter()) { + *o ^= b; + } + } + out.into() +} + +/// Compare in constant time: an early return would leak how much of the digest +/// matched. +fn digests_match(a: &[u8], b: &[u8]) -> bool { + a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 +} + +fn check(cfg: &LockConfig, pin: &str) -> bool { + if cfg.hash.is_empty() { + return false; + } + let (Ok(salt), Ok(expected)) = (hex::decode(&cfg.salt), hex::decode(&cfg.hash)) else { + return false; + }; + digests_match(&derive(pin, &salt, cfg.iterations), &expected) +} + +fn validate(pin: &str) -> Result<(), String> { + if pin.chars().count() != PIN_LEN || !pin.chars().all(|c| c.is_ascii_digit()) { + return Err(format!("PIN must be {PIN_LEN} digits.")); + } + Ok(()) +} + +// ── Vault access ───────────────────────────────────────────────────────────── +// Every keychain call blocks for an unbounded time (it can raise the OS consent +// prompt), so it goes to the blocking pool - never the caller's thread. See the +// long note in secrets.rs. + +async fn off_thread(f: F) -> Result +where + F: FnOnce() -> T + Send + 'static, + T: Send + 'static, +{ + tauri::async_runtime::spawn_blocking(f) + .await + .map_err(|e| e.to_string()) +} + +fn read_config(app: &tauri::AppHandle) -> LockConfig { + crate::secrets::read_all(app) + .get(VAULT_KEY) + .and_then(|json| serde_json::from_str::(json).ok()) + .unwrap_or_default() +} + +fn write_config(app: &tauri::AppHandle, cfg: &LockConfig) -> Result<(), String> { + let mut map = crate::secrets::read_all(app); + let json = serde_json::to_string(cfg).map_err(|e| e.to_string())?; + map.insert(VAULT_KEY.to_string(), json); + crate::secrets::write_all(app, &map) +} + +/// Read, mutate, write in a single hop so the pair shares one keychain unlock. +async fn edit(app: tauri::AppHandle, f: F) -> Result +where + F: FnOnce(&mut LockConfig) -> Result<(), String> + Send + 'static, +{ + off_thread(move || { + let mut cfg = read_config(&app); + f(&mut cfg)?; + write_config(&app, &cfg)?; + Ok(LockStatus::from(&cfg)) + }) + .await? +} + +// ── Commands ───────────────────────────────────────────────────────────────── + +#[tauri::command] +pub async fn app_lock_status(app: tauri::AppHandle) -> Result { + off_thread(move || LockStatus::from(&read_config(&app))).await +} + +/// Set the PIN, or change it when one already exists (which requires the +/// current PIN - otherwise anyone at an unlocked screen could silently rekey it). +#[tauri::command] +pub async fn app_lock_set_pin( + app: tauri::AppHandle, + pin: String, + current_pin: Option, +) -> Result { + validate(&pin)?; + let mut salt = [0u8; 16]; + getrandom::getrandom(&mut salt).map_err(|e| e.to_string())?; + edit(app, move |cfg| { + if cfg.enabled && !cfg.hash.is_empty() { + let current = current_pin.unwrap_or_default(); + if !check(cfg, ¤t) { + return Err("Current PIN is incorrect.".into()); + } + } + cfg.hash = hex::encode(derive(&pin, &salt, ITERATIONS)); + cfg.salt = hex::encode(salt); + cfg.iterations = ITERATIONS; + // Turning the lock on for the first time also turns on the connect + // prompt - guarding the app but not the databases is the surprising half. + if !cfg.enabled { + cfg.require_on_connect = true; + } + cfg.enabled = true; + Ok(()) + }) + .await +} + +#[tauri::command] +pub async fn app_lock_verify(app: tauri::AppHandle, pin: String) -> Result { + let ok = off_thread(move || { + let cfg = read_config(&app); + if !cfg.enabled || cfg.hash.is_empty() { + return true; // nothing to unlock + } + check(&cfg, &pin) + }) + .await?; + + if ok { + FAILURES.store(0, Ordering::Relaxed); + } else { + // Grows to a 3s ceiling: still invisible after a typo, brutal for a script. + let n = FAILURES.fetch_add(1, Ordering::Relaxed) + 1; + tokio::time::sleep(Duration::from_millis((n as u64 * 300).min(3_000))).await; + } + Ok(ok) +} + +/// Remove the PIN entirely. Requires the current PIN. +#[tauri::command] +pub async fn app_lock_disable(app: tauri::AppHandle, pin: String) -> Result { + edit(app, move |cfg| { + if cfg.enabled && !check(cfg, &pin) { + return Err("PIN is incorrect.".into()); + } + *cfg = LockConfig::default(); + Ok(()) + }) + .await +} + +#[tauri::command] +pub async fn app_lock_set_prefs( + app: tauri::AppHandle, + require_on_connect: Option, + auto_lock_minutes: Option, +) -> Result { + edit(app, move |cfg| { + if let Some(v) = require_on_connect { + cfg.require_on_connect = v; + } + if let Some(v) = auto_lock_minutes { + cfg.auto_lock_minutes = v.min(24 * 60); + } + Ok(()) + }) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + + /// RFC 6070 vector, adapted to HMAC-SHA256 via the widely published values + /// for P="password", S="salt", c=1 and c=2, dkLen=32. + #[test] + fn pbkdf2_matches_known_vectors() { + assert_eq!( + hex::encode(derive("password", b"salt", 1)), + "120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b" + ); + assert_eq!( + hex::encode(derive("password", b"salt", 2)), + "ae4d0c95af6b46d32d0adff928f06dd02a303f8ef3c251dfd6e2d85a95474c43" + ); + } + + #[test] + fn wrong_pin_is_rejected() { + let salt = [7u8; 16]; + let cfg = LockConfig { + enabled: true, + salt: hex::encode(salt), + hash: hex::encode(derive("1234", &salt, 1_000)), + iterations: 1_000, + require_on_connect: true, + auto_lock_minutes: 0, + }; + assert!(check(&cfg, "1234")); + assert!(!check(&cfg, "1235")); + assert!(!check(&cfg, "")); + } + + #[test] + fn only_four_digit_pins_validate() { + assert!(validate("1234").is_ok()); + assert!(validate("123").is_err()); + assert!(validate("12345").is_err()); + assert!(validate("123a").is_err()); + } +} diff --git a/src-tauri/src/cloudflare.rs b/src-tauri/src/cloudflare.rs index d67e6055..0e9b6906 100644 --- a/src-tauri/src/cloudflare.rs +++ b/src-tauri/src/cloudflare.rs @@ -1,8 +1,8 @@ /*! * Cloudflare OAuth 2.0 + PKCE flow for Stroke. * - * Uses the same public client_id as the Wrangler CLI — Cloudflare's official - * developer tool — which accepts localhost redirect URIs for desktop apps. + * Uses the same public client_id as the Wrangler CLI - Cloudflare's official + * developer tool - which accepts localhost redirect URIs for desktop apps. * * Flow: * 1. Generate PKCE verifier/challenge + random state @@ -32,7 +32,7 @@ const CF_SCOPES: &str = "account:read user:read d1:write offline_access"; // How long to wait for the user to authorize before giving up const AUTH_TIMEOUT_SECS: u64 = 300; // Cloudflare has pre-registered http://localhost:{PORT}/oauth/callback for this -// client ID — the exact same ports Wrangler CLI uses. A random port is rejected. +// client ID - the exact same ports Wrangler CLI uses. A random port is rejected. const CF_CALLBACK_PORTS: &[u16] = &[8976, 8977, 8978, 8979, 8980]; // ── Keychain keys ───────────────────────────────────────────────────────────── @@ -121,7 +121,7 @@ async fn await_oauth_callback( ) -> Result { let success_html = r#" -Stroke — authorized +Stroke - authorized @@ -132,7 +132,7 @@ h2{color:#22c55e;margin-bottom:12px}p{color:#888;margin:0} let error_html = r#" -Stroke — error +Stroke - error @@ -211,7 +211,7 @@ h2{color:#ef4444;margin-bottom:12px}p{color:#888;margin:0} let _ = stream .write_all(send_html(error_html).as_bytes()) .await; - return Err("OAuth state mismatch — possible CSRF".to_string()); + return Err("OAuth state mismatch - possible CSRF".to_string()); } let _ = stream @@ -332,7 +332,7 @@ async fn fetch_user_email(access_token: &str) -> Option { // ── Token storage helpers ───────────────────────────────────────────────────── -// `async` because the keychain is only ever touched off the caller's thread — +// `async` because the keychain is only ever touched off the caller's thread - // see the module comment in secrets.rs. async fn store_tokens( app: &tauri::AppHandle, @@ -404,7 +404,7 @@ pub async fn cloudflare_start_oauth(app: tauri::AppHandle) -> Result CfOAuthStatus { /// Process-wide handle, set once at startup. /// /// The D1 driver needs to reach the token store when a request comes back 401, -/// but it is handed only a `D1Config` — the whole `db` layer is deliberately +/// but it is handed only a `D1Config` - the whole `db` layer is deliberately /// free of Tauri types. Rather than thread an `AppHandle` through every driver /// signature for this one case, the handle is parked here. static APP: std::sync::OnceLock = std::sync::OnceLock::new(); @@ -463,7 +463,7 @@ pub fn set_app_handle(app: tauri::AppHandle) { /// bookkeeping says. /// /// Returns None rather than an error because the caller's job is to report the -/// *original* failure when no refresh is possible — "session expired" would be +/// *original* failure when no refresh is possible - "session expired" would be /// a misleading thing to show someone using a manual API token. pub async fn refreshed_token() -> Option { let app = APP.get()?.clone(); @@ -493,7 +493,7 @@ pub async fn cloudflare_get_valid_token(app: tauri::AppHandle) -> Result = OnceLock::new(); fn ai_http_client() -> &'static reqwest::Client { @@ -50,9 +50,9 @@ pub fn ai_fetch_cancel(request_id: String) { /// bypassing WebView CORS restrictions for local AI models (Ollama, LM Studio, etc.). /// /// For streaming requests, response chunks are emitted as Tauri events: -/// `ai-stream-{request_id}` — text chunk payload -/// `ai-stream-done-{request_id}` — stream finished -/// `ai-stream-error-{request_id}` — error message payload +/// `ai-stream-{request_id}` - text chunk payload +/// `ai-stream-done-{request_id}` - stream finished +/// `ai-stream-error-{request_id}` - error message payload #[tauri::command] pub async fn ai_fetch( app: tauri::AppHandle, @@ -140,7 +140,7 @@ pub fn ai_device_id() -> String { /// The models ollama.com currently offers, name and size. /// /// Fetched here rather than from the webview because ollama.com sends no -/// `Access-Control-Allow-Origin`, so a browser fetch fails outright — and the +/// `Access-Control-Allow-Origin`, so a browser fetch fails outright - and the /// packaged app's origin differs per platform (`tauri://localhost` on macOS, /// `http://tauri.localhost` on Windows and Linux), which would make a CORS /// dependency fail differently on each. Going through Rust removes the question @@ -166,7 +166,7 @@ pub async fn ollama_registry() -> Result { response.json().await.map_err(|e| e.to_string()) } -/// The models a server actually has, so the picker never guesses a tag — +/// The models a server actually has, so the picker never guesses a tag - /// a hardcoded preset like `llama3.1` fails against an install that has /// `llama3.1:8b`. Goes through Rust for the same reason `ai_fetch` does: the /// WebView can't reach localhost cross-origin. @@ -186,7 +186,7 @@ pub async fn ai_list_models(url: String, api_key: Option) -> Result Result<(), String> { /// produce bytes (PNG diagrams) come through here instead. /// /// Payload is base64 rather than `Vec`: the IPC bridge has no binary channel, -/// so raw bytes would cross it as a JSON array — one number per byte, roughly 4x +/// so raw bytes would cross it as a JSON array - one number per byte, roughly 4x /// the payload for a multi-megabyte image. #[tauri::command] pub async fn save_file_bytes(path: String, base64: String) -> Result<(), String> { @@ -253,13 +253,13 @@ pub async fn ai_fetch_page(url: String) -> Result { crate::web_search::fetch_page(url).await } -/// Read a text file from disk — used by the notebook open flow. +/// Read a text file from disk - used by the notebook open flow. #[tauri::command] pub async fn read_file(path: String) -> Result { tokio::fs::read_to_string(&path).await.map_err(|e| e.to_string()) } -/// Restart the application — called after an update is installed. +/// Restart the application - called after an update is installed. #[tauri::command] pub fn restart_app(app: tauri::AppHandle) { app.restart(); @@ -481,8 +481,8 @@ fn explain_rows_to_lines(res: &SqlResult) -> Vec { .collect() } -/// Turn a SQL Server SHOWPLAN_XML document into indented plan lines — one line -/// per `` operator, indented by its RelOp-nesting depth — so the shared +/// Turn a SQL Server SHOWPLAN_XML document into indented plan lines - one line +/// per `` operator, indented by its RelOp-nesting depth - so the shared /// `explain_from_text_lines` builder produces the same `{Node Type, Plans}` tree /// the renderer already draws for other engines. Uses a lightweight tag scan /// (no XML crate is available) that only tracks `` open/close: the @@ -510,7 +510,7 @@ fn mssql_plan_lines(xml: &str) -> Vec { depth = depth.saturating_sub(1); } else if let Some(after) = tag.strip_prefix("RelOp") { // Opening RelOp tag (the name is followed by whitespace before its - // attributes, or nothing at all) — not `RelOpXyz` or a comment. + // attributes, or nothing at all) - not `RelOpXyz` or a comment. if after.is_empty() || after.starts_with(char::is_whitespace) { let phys = attr(tag, "PhysicalOp"); let logi = attr(tag, "LogicalOp"); @@ -549,7 +549,7 @@ pub async fn pg_explain_sql( Ok(explain_from_text_lines(explain_rows_to_lines(&res), "clickhouse")) } ActiveConnection::D1(cfg) => { - // D1 is SQLite over HTTP — EXPLAIN QUERY PLAN returns the same + // D1 is SQLite over HTTP - EXPLAIN QUERY PLAN returns the same // (id, parent, notused, detail) rows the SQLite path builds its tree from. let res = crate::db::d1::query(&cfg, &format!("EXPLAIN QUERY PLAN {sql}"), vec![]).await?; Ok(explain_from_sqlite_plan(&res, "d1")) @@ -557,7 +557,7 @@ pub async fn pg_explain_sql( ActiveConnection::Mssql(h) => { // SHOWPLAN_XML is session state: turning it ON makes the *next* query // return its plan as an XML column instead of executing it. All three - // statements must therefore run on the same tiberius session — and it + // statements must therefore run on the same tiberius session - and it // is a single persistent connection (no pool), so holding the guard // across the sequence keeps them on that session with no interleaving. // `SET SHOWPLAN_XML ON` must also be alone in its batch, which each @@ -571,7 +571,7 @@ pub async fn pg_explain_sql( }; let mut client = h.lock().await; - // Bail before running the query if plan capture can't be enabled — + // Bail before running the query if plan capture can't be enabled - // otherwise the query would execute for real (e.g. a DML statement). match client.simple_query("SET SHOWPLAN_XML ON").await { Ok(stream) => { @@ -584,7 +584,7 @@ pub async fn pg_explain_sql( Ok(stream) => stream.into_results().await, Err(e) => Err(e), }; - // Restore session state no matter what — this connection is reused + // Restore session state no matter what - this connection is reused // for every later query, so it must not stay in plan-only mode. if let Ok(off) = client.simple_query("SET SHOWPLAN_XML OFF").await { let _ = off.into_results().await; @@ -655,7 +655,7 @@ pub async fn pg_get_table_column_structure( get_table_column_structure(state, schema, table).await } -/// Column structure for every table in one schema — one call instead of one per +/// Column structure for every table in one schema - one call instead of one per /// table. Used by the ER diagram, which needs the whole schema up front. #[tauri::command] pub async fn pg_get_schema_column_structure( @@ -757,14 +757,14 @@ pub async fn pg_get_table_rows( offset: i64, search: Option, search_is_regex: Option, - // Optional — defaults to false. Case-sensitive substring search. + // Optional - defaults to false. Case-sensitive substring search. search_case_sensitive: Option, sort_column: Option, sort_direction: Option, filters: Option>, - // Optional — defaults to true (full metadata). Repeat fetches pass false. + // Optional - defaults to true (full metadata). Repeat fetches pass false. include_meta: Option, - // Optional — defaults to true. When false, the count is skipped (total = -1) + // Optional - defaults to true. When false, the count is skipped (total = -1) // and fetched separately via `pg_count_table_rows` so rows paint immediately. include_count: Option, // Multi-column sort keys (Postgres); overrides sort_column when non-empty. @@ -903,6 +903,16 @@ pub async fn instance_set_config( crate::db::instance_set_config(state, name, value).await } +/// Read-only lint pass over the connected database (Advisor tab). Catalog queries +/// only - it never reads user rows and never writes. +#[tauri::command] +pub async fn advisor_scan( + state: State<'_, DbState>, +) -> Result { + let conn = crate::db::connection::require_conn(&state)?; + crate::db::advisor::scan(&conn).await +} + #[tauri::command] pub async fn instance_replication( state: State<'_, DbState>, @@ -910,14 +920,24 @@ pub async fn instance_replication( crate::db::instance_replication(state).await } +/// `queryId` (optional) names this run so `cancel_query` can target it. Omitting +/// it keeps the single-slot behaviour older callers relied on. #[tauri::command] -pub async fn pg_execute_sql(state: State<'_, DbState>, sql: String) -> Result { - execute_sql(state, sql).await +pub async fn pg_execute_sql( + state: State<'_, DbState>, + sql: String, + query_id: Option, +) -> Result { + execute_sql(state, sql, query_id).await } #[tauri::command] -pub async fn pg_execute_sql_multi(state: State<'_, DbState>, sql: String) -> Result, String> { - execute_sql_multi(state, sql).await +pub async fn pg_execute_sql_multi( + state: State<'_, DbState>, + sql: String, + query_id: Option, +) -> Result, String> { + execute_sql_multi(state, sql, query_id).await } /// Execute SQL against an arbitrary saved connection without switching the @@ -997,11 +1017,25 @@ pub async fn pg_insert_table_row( // ── Cancel running query ────────────────────────────────────────────────────── +/// Cancel one running query by the `queryId` it was started with. Without an id, +/// cancels every query in flight (the old whole-app "stop" behaviour). #[tauri::command] -pub async fn cancel_query(state: State<'_, DbState>) -> Result<(), String> { - if let Ok(mut guard) = state.cancel_tx.lock() { - if let Some(tx) = guard.take() { - let _ = tx.send(()); +pub async fn cancel_query( + state: State<'_, DbState>, + query_id: Option, +) -> Result<(), String> { + if let Ok(mut map) = state.cancels.lock() { + match query_id { + Some(id) => { + if let Some(tx) = map.remove(&id) { + let _ = tx.send(()); + } + } + None => { + for (_, tx) in map.drain() { + let _ = tx.send(()); + } + } } } Ok(()) @@ -1031,9 +1065,9 @@ pub async fn activate_license( let device_id = crate::license::device_id(); // 2. Register with server. Blocks on seat_limit_exceeded / revoked. - // On network failure api_activate returns Ok(None) — we allow offline activation. + // On network failure api_activate returns Ok(None) - we allow offline activation. match crate::license::api_activate(&key, &device_id).await { - Ok(None) => {} // offline — proceed with local-only activation + Ok(None) => {} // offline - proceed with local-only activation Ok(Some(_)) => {} // server accepted Err(e) => { // Convert server error codes to user-friendly messages @@ -1110,11 +1144,11 @@ pub async fn run_license_check( let _ = crate::license::save_license(&dir, &lic); } Some(false) => { - // Server says invalid/revoked — remove local license + // Server says invalid/revoked - remove local license let _ = crate::license::delete_license(&dir); } None => { - // Network unreachable — use local signature as offline grace + // Network unreachable - use local signature as offline grace } } } @@ -1132,7 +1166,7 @@ pub async fn run_license_check( // ── License debug helpers (debug builds only) ───────────────────────────────── /// Backdate the trial file so the UI shows it as expired (or N days elapsed). -/// Only compiled in debug mode — stripped from release builds entirely. +/// Only compiled in debug mode - stripped from release builds entirely. #[cfg(debug_assertions)] #[tauri::command] pub fn debug_set_trial_days_ago(app: tauri::AppHandle, days_ago: u64) -> Result<(), String> { @@ -1270,11 +1304,11 @@ async fn seed_sample_database(pool: &sqlx::SqlitePool) -> Result<(), String> { (1, 'Portable SSD 1TB', 79.99, 30, 'Fast NVMe external storage'), (2, 'Classic T-Shirt', 19.99, 200, 'Comfortable everyday cotton tee'), (2, 'Slim-Fit Jeans', 49.99, 75, 'Modern cut, stretch denim'), - (2, 'Hoodie – Charcoal', 39.99, 60, 'Warm fleece-lined pullover'), + (2, 'Hoodie - Charcoal', 39.99, 60, 'Warm fleece-lined pullover'), (2, 'Running Shorts', 24.99, 110, 'Lightweight moisture-wicking'), - (3, 'Clean Code', 35.00, 40, 'Robert C. Martin — software craftsmanship'), - (3, 'The Pragmatic Programmer',33.00, 38, 'Hunt & Thomas — timeless dev advice'), - (3, 'Designing Data-Intensive', 55.00, 25, 'Martin Kleppmann — distributed systems'), + (3, 'Clean Code', 35.00, 40, 'Robert C. Martin - software craftsmanship'), + (3, 'The Pragmatic Programmer',33.00, 38, 'Hunt & Thomas - timeless dev advice'), + (3, 'Designing Data-Intensive', 55.00, 25, 'Martin Kleppmann - distributed systems'), (4, 'Succulent Plant Set', 22.00, 90, 'Set of 4 low-maintenance succulents'), (4, 'Ceramic Pour-Over Kit', 45.00, 35, 'Elegant coffee brewing set'), (4, 'LED Desk Lamp', 38.50, 68, 'Adjustable colour temperature'), diff --git a/src-tauri/src/copilot.rs b/src-tauri/src/copilot.rs index bc0acfe3..7fc021e1 100644 --- a/src-tauri/src/copilot.rs +++ b/src-tauri/src/copilot.rs @@ -156,7 +156,7 @@ pub async fn copilot_get_copilot_token(oauth_token: String) -> Result, + /// SQL that would resolve it. Copyable; never executed by the app. + pub remediation: Option, + /// Sort key within a check (bytes, ratio, row count). Bigger = worse. + pub metric: f64, +} + +/// Per-check outcome, so the UI can say "9 checks ran, 1 skipped" honestly. +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct AdvisorCheckStatus { + pub id: String, + pub title: String, + pub category: String, + /// ok | error + pub status: String, + pub findings: usize, + pub ms: u64, + pub error: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AdvisorReport { + pub engine: String, + pub findings: Vec, + pub checks: Vec, + /// True when the engine has no checks implemented yet, so the UI can say so + /// rather than showing a clean bill of health it hasn't earned. + pub unsupported: bool, +} + +/// Schemas no advice applies to. +const SYSTEM_SCHEMAS: &str = "'pg_catalog','information_schema','pg_toast'"; + +fn bytes_pretty(bytes: f64) -> String { + const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"]; + let mut v = bytes; + let mut u = 0; + while v >= 1024.0 && u < UNITS.len() - 1 { + v /= 1024.0; + u += 1; + } + if u == 0 { + format!("{} {}", v as i64, UNITS[u]) + } else { + format!("{v:.1} {}", UNITS[u]) + } +} + +// ── Checks ──────────────────────────────────────────────────────────────────── + +/// Tables with row-level security switched off. +/// +/// A warning rather than an error on purpose. RLS is the only thing standing +/// between a table and an untrusted client that can reach Postgres directly +/// (PostgREST, Supabase, an edge function with the anon key). Behind a trusted +/// application server it is simply not how access is enforced, and calling that an +/// error would mean every ordinary deployment opens this tab to a wall of red - +/// which teaches people to ignore the tab. +async fn check_rls_disabled(pool: &PgPool) -> Result, String> { + let sql = format!( + "SELECT n.nspname, c.relname + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relkind = 'r' + AND NOT c.relrowsecurity + AND n.nspname NOT IN ({SYSTEM_SCHEMAS}) + AND n.nspname NOT LIKE 'pg\\_temp%' + ORDER BY 1, 2" + ); + let rows = sqlx::query(&sql).fetch_all(pool).await.map_err(|e| e.to_string())?; + Ok(rows + .iter() + .map(|r| { + let schema: String = r.get(0); + let table: String = r.get(1); + AdvisorFinding { + check_id: "rls_disabled".into(), + category: "security".into(), + severity: "warning".into(), + title: "Table without row-level security".into(), + entity: format!("{schema}.{table}"), + entity_kind: "table".into(), + description: "Any role that can reach this table reads every row. That is what you want behind a trusted application server, and a hole if clients connect to Postgres directly.".into(), + detail: None, + remediation: Some(format!( + "ALTER TABLE \"{schema}\".\"{table}\" ENABLE ROW LEVEL SECURITY;\n-- then add at least one policy, or the table denies everything:\n-- CREATE POLICY \"{table}_select\" ON \"{schema}\".\"{table}\" FOR SELECT USING (true);" + )), + metric: 0.0, + } + }) + .collect()) +} + +/// Foreign keys whose columns aren't the leading columns of any index. +/// +/// Postgres indexes the referenced side automatically and the referencing side not +/// at all, so every join and every `ON DELETE` check on this constraint is a +/// sequential scan of the child table. +async fn check_unindexed_fk(pool: &PgPool) -> Result, String> { + let sql = format!( + "SELECT n.nspname, c.relname, con.conname, pg_get_constraintdef(con.oid), + (SELECT string_agg(quote_ident(a.attname), ', ' ORDER BY x.ord) + FROM unnest(con.conkey) WITH ORDINALITY AS x(attnum, ord) + JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = x.attnum) AS cols, + c.reltuples::bigint + FROM pg_constraint con + JOIN pg_class c ON c.oid = con.conrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE con.contype = 'f' + AND n.nspname NOT IN ({SYSTEM_SCHEMAS}) + AND NOT EXISTS ( + SELECT 1 FROM pg_index i + WHERE i.indrelid = con.conrelid + AND (i.indkey::int2[])[1:cardinality(con.conkey::int2[])] @> con.conkey::int2[] + ) + ORDER BY c.reltuples DESC NULLS LAST, 1, 2" + ); + let rows = sqlx::query(&sql).fetch_all(pool).await.map_err(|e| e.to_string())?; + Ok(rows + .iter() + .map(|r| { + let schema: String = r.get(0); + let table: String = r.get(1); + let name: String = r.get(2); + let def: String = r.get(3); + let cols: String = r.get(4); + let tuples: i64 = r.get(5); + // The bigger the child table, the more each unindexed join costs. + let severity = if tuples > 100_000 { "error" } else { "warning" }; + AdvisorFinding { + check_id: "unindexed_fk".into(), + category: "performance".into(), + severity: severity.into(), + title: "Foreign key without an index".into(), + entity: format!("{schema}.{table} ({name})"), + entity_kind: "constraint".into(), + description: "Postgres indexes the referenced side of a foreign key, never the referencing side. Joins across it, and the check behind every parent DELETE or UPDATE, scan this table end to end.".into(), + detail: Some(format!("{def} · ~{tuples} rows")), + remediation: Some(format!( + "CREATE INDEX CONCURRENTLY ON \"{schema}\".\"{table}\" ({cols});" + )), + metric: tuples as f64, + } + }) + .collect()) +} + +/// Indexes the planner has never used since statistics were last reset. +async fn check_unused_index(pool: &PgPool) -> Result, String> { + // Unique and primary-key indexes are excluded even at zero scans: they enforce a + // constraint, so "unused" says nothing about whether they can go. + let sql = "SELECT s.schemaname, s.relname, s.indexrelname, + pg_relation_size(s.indexrelid)::float8, + (SELECT COALESCE(EXTRACT(EPOCH FROM (now() - stats_reset)), 0) + FROM pg_stat_database WHERE datname = current_database()) + FROM pg_stat_user_indexes s + JOIN pg_index i ON i.indexrelid = s.indexrelid + WHERE s.idx_scan = 0 + AND NOT i.indisunique + AND NOT i.indisprimary + ORDER BY 4 DESC"; + let rows = sqlx::query(sql).fetch_all(pool).await.map_err(|e| e.to_string())?; + Ok(rows + .iter() + .map(|r| { + let schema: String = r.get(0); + let table: String = r.get(1); + let index: String = r.get(2); + let bytes: f64 = r.get(3); + let since_reset: f64 = r.try_get(4).unwrap_or(0.0); + // Weighted by what it costs to keep: every write maintains it, and it sits + // in the buffer cache competing with data. + let severity = if bytes > 512.0 * 1024.0 * 1024.0 { + "error" + } else if bytes > 16.0 * 1024.0 * 1024.0 { + "warning" + } else { + "info" + }; + let window = if since_reset > 3600.0 { + format!("{:.0}h of statistics", since_reset / 3600.0) + } else { + "a short statistics window - reset recently, so treat with care".into() + }; + AdvisorFinding { + check_id: "unused_index".into(), + category: "performance".into(), + severity: severity.into(), + title: "Index never used".into(), + entity: format!("{schema}.{index}"), + entity_kind: "index".into(), + description: "No scan has touched this index, yet every insert and update to the table maintains it and it competes for cache. Confirm the window covers your real workload - a nightly job that has not run yet also looks like this.".into(), + detail: Some(format!("{} on {schema}.{table} · {window}", bytes_pretty(bytes))), + remediation: Some(format!("DROP INDEX CONCURRENTLY \"{schema}\".\"{index}\";")), + metric: bytes, + } + }) + .collect()) +} + +/// Ordinary tables with no primary key. +async fn check_no_primary_key(pool: &PgPool) -> Result, String> { + let sql = format!( + "SELECT n.nspname, c.relname, c.reltuples::bigint + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relkind = 'r' + AND n.nspname NOT IN ({SYSTEM_SCHEMAS}) + AND n.nspname NOT LIKE 'pg\\_temp%' + AND NOT EXISTS (SELECT 1 FROM pg_index i WHERE i.indrelid = c.oid AND i.indisprimary) + ORDER BY c.reltuples DESC NULLS LAST" + ); + let rows = sqlx::query(&sql).fetch_all(pool).await.map_err(|e| e.to_string())?; + Ok(rows + .iter() + .map(|r| { + let schema: String = r.get(0); + let table: String = r.get(1); + let tuples: i64 = r.get(2); + AdvisorFinding { + check_id: "no_primary_key".into(), + category: "schema".into(), + severity: (if tuples > 10_000 { "warning" } else { "info" }).into(), + title: "Table without a primary key".into(), + entity: format!("{schema}.{table}"), + entity_kind: "table".into(), + description: "Nothing identifies a row here: duplicates can accumulate unnoticed, logical replication cannot carry updates, and tools that page by key - including this one - fall back to less stable ordering.".into(), + detail: Some(format!("~{tuples} rows")), + remediation: Some(format!( + "-- pick the column(s) that already identify a row:\nALTER TABLE \"{schema}\".\"{table}\" ADD PRIMARY KEY (id);\n-- or add a surrogate key:\n-- ALTER TABLE \"{schema}\".\"{table}\" ADD COLUMN id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY;" + )), + metric: tuples as f64, + } + }) + .collect()) +} + +/// Tables carrying a lot of dead tuples relative to live ones. +async fn check_dead_tuples(pool: &PgPool) -> Result, String> { + let sql = "SELECT schemaname, relname, n_live_tup, n_dead_tup, + (n_dead_tup::float8 / GREATEST(n_live_tup, 1)::float8) AS ratio, + COALESCE(to_char(GREATEST(last_vacuum, last_autovacuum), 'YYYY-MM-DD HH24:MI'), 'never') AS vac + FROM pg_stat_user_tables + WHERE n_dead_tup > 1000 + AND (n_dead_tup::float8 / GREATEST(n_live_tup, 1)::float8) > 0.2 + ORDER BY 5 DESC"; + let rows = sqlx::query(sql).fetch_all(pool).await.map_err(|e| e.to_string())?; + Ok(rows + .iter() + .map(|r| { + let schema: String = r.get(0); + let table: String = r.get(1); + let live: i64 = r.get(2); + let dead: i64 = r.get(3); + let ratio: f64 = r.get(4); + let vac: String = r.get(5); + AdvisorFinding { + check_id: "dead_tuples".into(), + category: "performance".into(), + severity: (if ratio > 0.5 { "error" } else { "warning" }).into(), + title: "Table bloated with dead rows".into(), + entity: format!("{schema}.{table}"), + entity_kind: "table".into(), + description: "Updated and deleted rows still occupy pages until vacuum reclaims them. Every sequential scan reads them, so both scan cost and disk grow with the bloat.".into(), + detail: Some(format!( + "{dead} dead vs {live} live ({:.0}%) · last vacuum: {vac}", + ratio * 100.0 + )), + remediation: Some(format!( + "VACUUM (ANALYZE) \"{schema}\".\"{table}\";\n-- if this recurs, autovacuum is not keeping up with the write rate:\n-- ALTER TABLE \"{schema}\".\"{table}\" SET (autovacuum_vacuum_scale_factor = 0.05);" + )), + metric: ratio, + } + }) + .collect()) +} + +/// Tables whose average row is very large once TOAST is counted. +/// +/// The metric is table size (main + TOAST, indexes excluded) over estimated rows, +/// NOT `pg_stats.avg_width`. A TOASTed value - the embedding column that makes this +/// check worth having - shows up in `pg_stats` as the ~18-byte pointer stored in the +/// main tuple, so a stats-based check misses precisely the tables that hurt most. +async fn check_wide_rows(pool: &PgPool) -> Result, String> { + let sql = format!( + "SELECT n.nspname, c.relname, c.reltuples::bigint, + pg_table_size(c.oid)::float8, + (pg_table_size(c.oid) / GREATEST(c.reltuples, 1))::float8 AS bpr, + (SELECT string_agg(t.attname || ' (' || t.typ || ')', ', ') + FROM (SELECT a.attname, format_type(a.atttypid, a.atttypmod) AS typ + FROM pg_attribute a + WHERE a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped + AND a.attstorage IN ('x','e') + ORDER BY a.attnum LIMIT 4) t) AS toastable + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relkind = 'r' + AND n.nspname NOT IN ({SYSTEM_SCHEMAS}) + AND c.reltuples > 100 + AND (pg_table_size(c.oid) / GREATEST(c.reltuples, 1)) > 1024 + ORDER BY 5 DESC" + ); + let rows = sqlx::query(&sql).fetch_all(pool).await.map_err(|e| e.to_string())?; + Ok(rows + .iter() + .map(|r| { + let schema: String = r.get(0); + let table: String = r.get(1); + let tuples: i64 = r.get(2); + let size: f64 = r.get(3); + let bpr: f64 = r.get(4); + let toastable: Option = r.try_get(5).ok().flatten(); + let severity = if bpr > 8192.0 { + "warning" + } else { + "info" + }; + AdvisorFinding { + check_id: "wide_rows".into(), + category: "performance".into(), + severity: severity.into(), + title: "Very wide rows".into(), + entity: format!("{schema}.{table}"), + entity_kind: "table".into(), + description: "Every tool that fetches rows pays this per row - this client included. Selecting a page of a table this wide moves megabytes, so prefer an explicit column list over SELECT * and keep large values (embeddings, documents, blobs) out of browse queries.".into(), + detail: Some(format!( + "{} per row · ~{tuples} rows · {} total{}", + bytes_pretty(bpr), + bytes_pretty(size), + toastable.map(|t| format!(" · large columns: {t}")).unwrap_or_default() + )), + remediation: None, + metric: bpr, + } + }) + .collect()) +} + +// ── Driver ──────────────────────────────────────────────────────────────────── + +/// Run every check for this engine and collect one report. +/// +/// Sequential on purpose: these are catalog reads measured in single-digit +/// milliseconds, and firing them concurrently would take several connections out of +/// a pool the user's own queries are sharing, to save a few ms on a scan they +/// asked for and are waiting on anyway. +pub async fn scan(conn: &super::connection::ActiveConnection) -> Result { + let pool = match conn { + super::connection::ActiveConnection::Postgres(pool) => pool, + other => { + // Every other engine: an honest empty report rather than a clean bill of + // health. Checks are Postgres-specific catalog queries; MySQL and SQLite + // need their own, and pretending otherwise would be worse than silence. + return Ok(AdvisorReport { + engine: other.driver().to_string(), + findings: vec![], + checks: vec![], + unsupported: true, + }); + } + }; + + let mut findings: Vec = Vec::new(); + let mut checks: Vec = Vec::new(); + + macro_rules! run_check { + ($id:literal, $title:literal, $cat:literal, $f:expr) => {{ + let started = std::time::Instant::now(); + let result = $f(pool).await; + let ms = started.elapsed().as_millis() as u64; + match result { + Ok(found) => { + checks.push(AdvisorCheckStatus { + id: $id.into(), + title: $title.into(), + category: $cat.into(), + status: "ok".into(), + findings: found.len(), + ms, + error: None, + }); + findings.extend(found); + } + Err(e) => checks.push(AdvisorCheckStatus { + id: $id.into(), + title: $title.into(), + category: $cat.into(), + status: "error".into(), + findings: 0, + ms, + error: Some(e), + }), + } + }}; + } + + run_check!("rls_disabled", "Tables without row-level security", "security", check_rls_disabled); + run_check!("unindexed_fk", "Foreign keys without an index", "performance", check_unindexed_fk); + run_check!("unused_index", "Indexes never used", "performance", check_unused_index); + run_check!("dead_tuples", "Tables bloated with dead rows", "performance", check_dead_tuples); + run_check!("wide_rows", "Very wide rows", "performance", check_wide_rows); + run_check!("no_primary_key", "Tables without a primary key", "schema", check_no_primary_key); + + // Worst first, so the report opens on what matters. Severity, then the check's + // own metric (bytes, ratio, rows) - which is why findings carry one. + let rank = |s: &str| match s { + "error" => 0, + "warning" => 1, + _ => 2, + }; + findings.sort_by(|a, b| { + rank(&a.severity) + .cmp(&rank(&b.severity)) + .then(b.metric.partial_cmp(&a.metric).unwrap_or(std::cmp::Ordering::Equal)) + .then(a.entity.cmp(&b.entity)) + }); + + Ok(AdvisorReport { + engine: "postgres".into(), + findings, + checks, + unsupported: false, + }) +} + +#[cfg(test)] +mod tests { + use super::bytes_pretty; + + #[test] + fn formats_sizes_the_way_a_dba_reads_them() { + assert_eq!(bytes_pretty(512.0), "512 B"); + assert_eq!(bytes_pretty(2048.0), "2.0 KB"); + assert_eq!(bytes_pretty(17_064.0), "16.7 KB"); + assert_eq!(bytes_pretty(203_603_968.0), "194.2 MB"); + } +} diff --git a/src-tauri/src/db/backup.rs b/src-tauri/src/db/backup.rs index 232fba5a..18e64838 100644 --- a/src-tauri/src/db/backup.rs +++ b/src-tauri/src/db/backup.rs @@ -168,7 +168,7 @@ fn at_line_start(chars: &[char], i: usize) -> bool { /// emit, so bodies containing embedded semicolons survive a round-trip: /// - single-quoted strings (`'…''…'`) /// - double-quoted (`"…"`) and backtick (`` `…` ``) identifiers -/// - PostgreSQL dollar-quoted strings (`$$ … $$`, `$tag$ … $tag$`) — used by +/// - PostgreSQL dollar-quoted strings (`$$ … $$`, `$tag$ … $tag$`) - used by /// `pg_get_functiondef`, trigger defs, and enum `DO $$ … $$` blocks /// - line comments (`-- …`, stripped) /// - MySQL `DELIMITER` directives (change the active terminator, e.g. `//`), @@ -277,7 +277,7 @@ fn split_statements(sql: &str) -> Vec { current.push(ch); i += 1; } } - // Line comment — stripped (replaced by a newline separator). + // Line comment - stripped (replaced by a newline separator). '-' if chars.get(i + 1) == Some(&'-') => { while i < n && chars[i] != '\n' { i += 1; } current.push('\n'); @@ -356,7 +356,7 @@ async fn export_sqlite( if is_cancelled() { break; } emit_log(app, "backup-log", "info", format!(" → {table}")); let q = format!("SELECT * FROM \"{}\"", table.replace('"', "\"\"")); - // Stream rows one at a time — avoids loading the entire table into memory. + // Stream rows one at a time - avoids loading the entire table into memory. let mut stream = sqlx::query(&q).fetch(pool); let mut n = 0usize; let mut col_list = String::new(); @@ -377,7 +377,7 @@ async fn export_sqlite( )); } total_rows += n; - emit_log(app, "backup-log", "ok", format!(" ✓ {table} — {n} rows")); + emit_log(app, "backup-log", "ok", format!(" ✓ {table} - {n} rows")); } out.push_str("\nCOMMIT;\nPRAGMA foreign_keys=ON;\n"); emit_log(app, "backup-log", "ok", format!("Export complete: {} tables, {} rows", tables_to_dump.len(), total_rows)); @@ -421,7 +421,7 @@ async fn import_sqlite(app: &AppHandle, pool: &sqlx::SqlitePool, sql: &str) -> R if low.starts_with("pragma foreign_keys") { ok += 1; continue; } match sqlx::query(stmt).execute(pool).await { Ok(_) => { ok += 1; } - Err(e) => errors.push(format!("{e} — near: {}…", truncate_chars(stmt, 60))), + Err(e) => errors.push(format!("{e} - near: {}…", truncate_chars(stmt, 60))), } } @@ -438,8 +438,8 @@ async fn import_sqlite(app: &AppHandle, pool: &sqlx::SqlitePool, sql: &str) -> R /// Unwrap a secondary-object catalog query for the Postgres export. On failure /// (permission error, catalog incompatibility) the export continues without -/// those objects, but says so — both in the log and as a WARNING line in the -/// dump — instead of silently producing an incomplete backup. +/// those objects, but says so - both in the log and as a WARNING line in the +/// dump - instead of silently producing an incomplete backup. fn catalog_or_warn( res: Result, sqlx::Error>, app: &AppHandle, @@ -506,7 +506,7 @@ async fn export_postgres( if !enums.is_empty() { emit_log(app, "backup-log", "info", format!(" Exporting {} enum(s) from {schema}…", enums.len())); - out.push_str(&format!("-- Custom enum types — {schema}\n")); + out.push_str(&format!("-- Custom enum types - {schema}\n")); for (name, labels) in &enums { let quoted: Vec = labels.split(',').map(|l| format!("'{}'", l.replace('\'', "''"))).collect(); out.push_str(&format!( @@ -535,7 +535,7 @@ async fn export_postgres( if !seqs.is_empty() { emit_log(app, "backup-log", "info", format!(" Exporting {} sequence(s) from {schema}…", seqs.len())); - out.push_str(&format!("-- Sequences — {schema}\n")); + out.push_str(&format!("-- Sequences - {schema}\n")); for (name, dtype, start, min, max, inc, cycle) in &seqs { let cycle_str = if *cycle { "CYCLE" } else { "NO CYCLE" }; out.push_str(&format!( @@ -571,7 +571,7 @@ async fn export_postgres( out.push_str(&ddl); total_rows += rows; total_tables += 1; - emit_log(app, "backup-log", "ok", format!(" ✓ {table} — {rows} rows")); + emit_log(app, "backup-log", "ok", format!(" ✓ {table} - {rows} rows")); } Err(e) => { out.push_str(&format!("-- ERROR exporting {schema}.{table}: {e}\n\n")); @@ -594,7 +594,7 @@ async fn export_postgres( let fk_defs = catalog_or_warn(fk_res, app, &mut out, "foreign keys", schema); if !fk_defs.is_empty() { - out.push_str(&format!("-- Foreign keys — {schema}\n")); + out.push_str(&format!("-- Foreign keys - {schema}\n")); for fk in &fk_defs { out.push_str(fk); out.push_str(";\n"); } out.push('\n'); } @@ -615,7 +615,7 @@ async fn export_postgres( if !views.is_empty() { emit_log(app, "backup-log", "info", format!(" Exporting {} view(s) from {schema}…", views.len())); - out.push_str(&format!("-- Views — {schema}\n")); + out.push_str(&format!("-- Views - {schema}\n")); for (name, def) in &views { out.push_str(&format!( "CREATE OR REPLACE VIEW \"{schema}\".\"{name}\" AS\n{def};\n" @@ -639,7 +639,7 @@ async fn export_postgres( if !funcs.is_empty() { emit_log(app, "backup-log", "info", format!(" Exporting {} function(s)/procedure(s) from {schema}…", funcs.len())); - out.push_str(&format!("-- Functions & Procedures — {schema}\n")); + out.push_str(&format!("-- Functions & Procedures - {schema}\n")); for (_, def) in &funcs { // pg_get_functiondef already includes CREATE OR REPLACE FUNCTION out.push_str(def.trim()); @@ -664,7 +664,7 @@ async fn export_postgres( if !triggers.is_empty() { emit_log(app, "backup-log", "info", format!(" Exporting {} trigger(s) from {schema}…", triggers.len())); - out.push_str(&format!("-- Triggers — {schema}\n")); + out.push_str(&format!("-- Triggers - {schema}\n")); for trig in &triggers { out.push_str(trig.trim()); out.push_str(";\n"); @@ -899,7 +899,7 @@ async fn import_postgres(app: &AppHandle, pool: &sqlx::PgPool, sql: &str) -> Res } Err(e) => { sqlx::query("ROLLBACK TO SAVEPOINT stroke_sp").execute(&mut *tx).await.ok(); - errors.push(format!("{e} — near: {}…", truncate_chars(stmt, 80))); + errors.push(format!("{e} - near: {}…", truncate_chars(stmt, 80))); } } } @@ -989,7 +989,7 @@ async fn export_mysql( } if n > 0 { out.push('\n'); } total_rows += n; - emit_log(app, "backup-log", "ok", format!(" ✓ {table} — {n} rows")); + emit_log(app, "backup-log", "ok", format!(" ✓ {table} - {n} rows")); } else { emit_log(app, "backup-log", "ok", format!(" ✓ {table} (schema only)")); } @@ -1007,7 +1007,7 @@ async fn export_mysql( if !view_names.is_empty() { emit_log(app, "backup-log", "info", format!(" Exporting {} view(s) from {schema}…", view_names.len())); - out.push_str(&format!("-- Views — {schema}\n")); + out.push_str(&format!("-- Views - {schema}\n")); for view in &view_names { if let Ok(row) = sqlx::query(&format!("SHOW CREATE VIEW `{schema}`.`{view}`")).fetch_one(pool).await { let create: String = row.try_get(1).unwrap_or_default(); @@ -1030,7 +1030,7 @@ async fn export_mysql( if !routines.is_empty() { emit_log(app, "backup-log", "info", format!(" Exporting {} function(s)/procedure(s) from {schema}…", routines.len())); - out.push_str(&format!("-- Functions & Procedures — {schema}\nDELIMITER //\n")); + out.push_str(&format!("-- Functions & Procedures - {schema}\nDELIMITER //\n")); for (name, rtype) in &routines { let keyword = if rtype == "FUNCTION" { "FUNCTION" } else { "PROCEDURE" }; if let Ok(row) = sqlx::query(&format!("SHOW CREATE {keyword} `{schema}`.`{name}`")).fetch_one(pool).await { @@ -1054,7 +1054,7 @@ async fn export_mysql( if !trigger_names.is_empty() { emit_log(app, "backup-log", "info", format!(" Exporting {} trigger(s) from {schema}…", trigger_names.len())); - out.push_str(&format!("-- Triggers — {schema}\nDELIMITER //\n")); + out.push_str(&format!("-- Triggers - {schema}\nDELIMITER //\n")); for trig in &trigger_names { if let Ok(row) = sqlx::query(&format!("SHOW CREATE TRIGGER `{schema}`.`{trig}`")).fetch_one(pool).await { let create: String = row.try_get(2).unwrap_or_default(); @@ -1076,7 +1076,7 @@ fn mysql_val(row: &sqlx::mysql::MySqlRow, idx: usize) -> String { // DECIMAL/NEWDECIMAL must decode as an exact Decimal *first*: the generic // integer arm below would otherwise consume the value and keep only its // integer part (9.99 → 9), silently corrupting the dump (same warning as - // mysql.rs cell_to_json). DATETIME/DATE/TIME need their own arms too — + // mysql.rs cell_to_json). DATETIME/DATE/TIME need their own arms too - // they match none of the numeric/bytes decoders and used to export as NULL. let type_name = row.column(idx).type_info().name(); if type_name.eq_ignore_ascii_case("DECIMAL") || type_name.eq_ignore_ascii_case("NEWDECIMAL") { @@ -1087,7 +1087,7 @@ fn mysql_val(row: &sqlx::mysql::MySqlRow, idx: usize) -> String { if let Ok(v) = row.try_get::, _>(idx) { return v.map_or_else(|| "NULL".into(), |n| n.to_string()); } - // BIGINT UNSIGNED — the signed arm rejects the UNSIGNED flag. + // BIGINT UNSIGNED - the signed arm rejects the UNSIGNED flag. if let Ok(v) = row.try_get::, _>(idx) { return v.map_or_else(|| "NULL".into(), |n| n.to_string()); } @@ -1129,7 +1129,7 @@ async fn import_mysql(app: &AppHandle, pool: &sqlx::MySqlPool, sql: &str) -> Res let mut errors: Vec = Vec::new(); // One connection for the whole restore: the dump carries per-schema `USE …;` - // directives, which are connection-scoped — on a pool, the USE and the + // directives, which are connection-scoped - on a pool, the USE and the // unqualified statements that depend on it could land on different // connections, restoring tables into the wrong database. let mut conn = pool @@ -1146,7 +1146,7 @@ async fn import_mysql(app: &AppHandle, pool: &sqlx::MySqlPool, sql: &str) -> Res emit_log(app, "restore-log", "info", format!(" {}/{} statements…", i + 1, total)); } } - Err(e) => errors.push(format!("{e} — near: {}…", truncate_chars(stmt, 80))), + Err(e) => errors.push(format!("{e} - near: {}…", truncate_chars(stmt, 80))), } } @@ -1258,7 +1258,7 @@ async fn export_d1( } } total_rows += n; - emit_log(app, "backup-log", "ok", format!(" ✓ {table} — {n} rows")); + emit_log(app, "backup-log", "ok", format!(" ✓ {table} - {n} rows")); } } @@ -1309,7 +1309,7 @@ async fn import_d1(app: &AppHandle, cfg: &super::connection::D1Config, sql: &str emit_log(app, "restore-log", "info", format!(" {}/{} statements…", i + 1, total)); } } - Err(e) => errors.push(format!("{e} — near: {}…", truncate_chars(stmt, 60))), + Err(e) => errors.push(format!("{e} - near: {}…", truncate_chars(stmt, 60))), } } @@ -1328,7 +1328,7 @@ async fn import_d1(app: &AppHandle, cfg: &super::connection::D1Config, sql: &str /// (schema.sql + load.sql + one file per table), not a single SQL script. The /// engine is embedded, so that directory lives on this client's filesystem. /// -/// The whole database is exported — DuckDB has no per-table/schema `EXPORT`, so +/// The whole database is exported - DuckDB has no per-table/schema `EXPORT`, so /// the schema/table selection used by the SQL-scripting engines doesn't apply. /// The export directory is derived next to the database file (a `:memory:` /// database falls back to the system temp dir). The returned "script" records @@ -1427,7 +1427,7 @@ async fn import_duckdb( if low.starts_with("pragma foreign_keys") { ok += 1; continue; } match super::duckdb::execute_sql(handle, stmt).await { Ok(_) => { ok += 1; } - Err(e) => errors.push(format!("{e} — near: {}…", truncate_chars(stmt, 60))), + Err(e) => errors.push(format!("{e} - near: {}…", truncate_chars(stmt, 60))), } } @@ -1452,7 +1452,7 @@ async fn export_mssql( ) -> Result { emit_log(app, "backup-log", "info", "Starting MS SQL Server export…"); - // The database name comes from the live connection — the active-connection + // The database name comes from the live connection - the active-connection // handle carries the client, not the MssqlConfig. let db = mssql_current_db(handle).await?; let bak_file = format!("{db}_stroke_backup.bak"); @@ -1516,7 +1516,7 @@ async fn import_mssql( if is_cancelled() { break; } match super::mssql::execute_sql(handle, stmt).await { Ok(_) => { ok += 1; } - Err(e) => errors.push(format!("{e} — near: {}…", truncate_chars(stmt, 80))), + Err(e) => errors.push(format!("{e} - near: {}…", truncate_chars(stmt, 80))), } } diff --git a/src-tauri/src/db/clickhouse.rs b/src-tauri/src/db/clickhouse.rs index 28d7d938..f1bc1c5e 100644 --- a/src-tauri/src/db/clickhouse.rs +++ b/src-tauri/src/db/clickhouse.rs @@ -87,7 +87,7 @@ pub async fn query(config: &ClickhouseConfig, sql: &str) -> Result Result> = parsed .data @@ -257,7 +257,7 @@ pub async fn get_table_rows( _ => String::new(), }; - // Count (respecting filters) and page are independent — run the two HTTP + // Count (respecting filters) and page are independent - run the two HTTP // round-trips concurrently, matching the pg and D1 paths. let count_sql = format!("SELECT count() FROM {tq}{where_clause}"); let data_sql = format!("SELECT * FROM {tq}{where_clause}{order} LIMIT {limit} OFFSET {offset}"); diff --git a/src-tauri/src/db/connection.rs b/src-tauri/src/db/connection.rs index 9cdeaaf4..03e07ad5 100644 --- a/src-tauri/src/db/connection.rs +++ b/src-tauri/src/db/connection.rs @@ -150,7 +150,7 @@ pub struct RedisConfig { pub host: String, pub port: u16, pub password: Option, - /// Logical database index (0–15 by default). + /// Logical database index (0-15 by default). #[serde(default)] pub db: u8, /// Use TLS (rediss://) instead of plain TCP (redis://). @@ -258,18 +258,38 @@ impl ActiveConnection { pub struct DbState { pub conn: Arc>>, - pub cancel_tx: Arc>>>, + /// Cancel handles for the queries currently in flight, keyed by the id the + /// caller sent. Keyed rather than a single slot because the UI runs several + /// queries at once (one per editor tab) - with one slot, the second run + /// overwrote the first's handle and "Cancel" hit whichever query registered + /// last instead of the one whose button was pressed. + pub cancels: Arc>>>, } impl Default for DbState { fn default() -> Self { Self { conn: Arc::new(Mutex::new(None)), - cancel_tx: Arc::new(Mutex::new(None)), + cancels: Arc::new(Mutex::new(std::collections::HashMap::new())), } } } +/// Register a cancel handle for `id`, replacing (and dropping) any handle already +/// stored under it. +pub fn register_cancel(state: &State<'_, DbState>, id: &str, tx: oneshot::Sender<()>) { + if let Ok(mut map) = state.cancels.lock() { + map.insert(id.to_string(), tx); + } +} + +/// Drop the cancel handle for `id` (query finished, cancelling it is meaningless). +pub fn unregister_cancel(state: &State<'_, DbState>, id: &str) { + if let Ok(mut map) = state.cancels.lock() { + map.remove(id); + } +} + /// Returns a clone of the active connection, or an error if disconnected. pub fn require_conn(state: &State<'_, DbState>) -> Result { state @@ -322,10 +342,10 @@ async fn close_existing(state: &State<'_, DbState>) { let old = state.conn.lock().ok().and_then(|mut g| g.take()); let Some(conn) = old else { return }; // Drain the old pool in the background. Pool::close() waits for every - // connection to be handed back — and against a dead peer (sleep/wake, + // connection to be handed back - and against a dead peer (sleep/wake, // network change: exactly the state a reconnect follows) that stalls for - // seconds. Nothing downstream needs the drain to finish — the state slot - // is already empty — so never make connect/switch wait on it. The timeout + // seconds. Nothing downstream needs the drain to finish - the state slot + // is already empty - so never make connect/switch wait on it. The timeout // caps the drain; after that the OS cleans up the sockets. tokio::spawn(async move { let timeout = std::time::Duration::from_secs(3); @@ -370,7 +390,7 @@ enum Preflight { Unreachable(String), /// Indeterminate: candidates were still in flight when the budget ran out. /// A slow VPN/satellite link looks exactly like this, so this must NEVER - /// veto the real connect — it only supplies the message if that stalls too. + /// veto the real connect - it only supplies the message if that stalls too. Inconclusive(String), } @@ -428,7 +448,7 @@ async fn preflight(host: &str, port: u16) -> Preflight { if stalled { // Some address never answered. Could be a slow link, so stay advisory. Preflight::Inconclusive(format!( - "Can't reach {target} — no response within {}s. Check the host/port, and whether a firewall or VPN is blocking it.", + "Can't reach {target} - no response within {}s. Check the host/port, and whether a firewall or VPN is blocking it.", TCP_BUDGET.as_secs() )) } else { @@ -459,7 +479,7 @@ async fn preflight(host: &str, port: u16) -> Preflight { /// host still connects. /// /// Phase timings are logged at info level. "Connecting is slow" is otherwise -/// unattributable from the outside — name lookup, TCP, and TLS+auth stall for +/// unattributable from the outside - name lookup, TCP, and TLS+auth stall for /// completely different reasons, and the machines where this reproduces (Windows /// behind a VPN, corporate DNS) are rarely the machine doing the debugging. async fn connect_racing_probe( @@ -535,7 +555,7 @@ async fn connect_racing_probe( /// connections standing by so a burst never pays a handshake, and /// `test_before_acquire(false)` skips the ping that would prove they're alive. /// Together they mean that after a laptop sleep, a VPN flip or a wifi change -/// every standing connection is dead and the next query gets one of them — which +/// every standing connection is dead and the next query gets one of them - which /// is the slow, error-then-reconnect path the user actually feels. /// /// Pinging every acquire costs a full round trip six times over on one table @@ -560,7 +580,7 @@ fn pg_pool_builder() -> PgPoolOptions { // BACKGROUND so a burst finds them ready instead of opening its own // mid-flight. Idle connections coming back dead after sleep/wake is // handled by the idle-gated ping below, so this only has to cover the - // burst — and on a host where one handshake costs seconds (a proxied + // burst - and on a host where one handshake costs seconds (a proxied // serverless Postgres measured at ~5.7s) four background opens raced the // first table open's six queries for the same ten slots. The row counts // lost that race, which is exactly what "pool timed out while waiting for @@ -568,13 +588,13 @@ fn pg_pool_builder() -> PgPoolOptions { .min_connections(2) // 20s, not 10: a single handshake to a proxied serverless host measured // 5.7s, so a query queued behind two of them blew a 10s ceiling and failed - // as "pool timed out" — reporting a timeout for a connection that was + // as "pool timed out" - reporting a timeout for a connection that was // simply still being made. The connect path has its own CONNECT_DEADLINE; // this only bounds how long a query waits for a slot. .acquire_timeout(Duration::from_secs(20)) // Keep connections warm for the whole active session. A short idle_timeout // (was 30 s) meant any pause longer than that forced a full TCP+TLS+auth - // re-handshake on the next query — on a remote/SSL host that's seconds of + // re-handshake on the next query - on a remote/SSL host that's seconds of // latency per connection, and a single table open opens several. 10 min // keeps the pool warm between interactions so repeat fetches stay fast; // test_before_acquire (sqlx default) still drops connections killed by @@ -591,7 +611,7 @@ fn pg_pool_builder() -> PgPoolOptions { // Staleness is bounded by max_lifetime, and anything idle long enough to // have been killed by a sleep/wake is checked by `before_acquire` below. .test_before_acquire(false) - // Ping only what might be dead — see STALE_AFTER. A failure here makes the + // Ping only what might be dead - see STALE_AFTER. A failure here makes the // pool drop this connection and hand over another (or open one), so a // stale pool repairs itself during acquire rather than after a failed query. .before_acquire(|conn, meta| { @@ -609,7 +629,7 @@ fn pg_pool_builder() -> PgPoolOptions { // (`warm_pool`), from a time when `min_connections` was 0 and the pool started // with exactly the one connection the handshake produced. `min_connections(4)` // now has the pool's own maintenance task doing that in the background, so the -// warm added nothing but three more acquires per connect — and because it HELD +// warm added nothing but three more acquires per connect - and because it HELD // each one until the last landed, it was also what made "pool timed out while // waiting for an open connection" reachable on a slow link. Removed rather than // tuned: the pool already does this, and one mechanism is easier to reason about @@ -618,7 +638,7 @@ fn pg_pool_builder() -> PgPoolOptions { /// Turn sqlx's `PoolTimedOut` into the error that actually caused it. /// /// A pool `acquire()` retries internally and reports only "pool timed out while -/// waiting for an open connection" — which says nothing about *why* no +/// waiting for an open connection" - which says nothing about *why* no /// connection could be established (bad password, server at max_connections, TLS /// refused). One direct connection attempt surfaces the real message. Runs only /// on the failure path, so a successful connect pays nothing. @@ -683,7 +703,7 @@ where // A timed-out attempt drops its half-built pool, which closes whatever // connections it had opened, so retrying does not pile connections onto the // server. - // The retry only ever earned its place against a LOST SYN — a packet dropped + // The retry only ever earned its place against a LOST SYN - a packet dropped // before the socket exists, where the kernel then sits on its ~1s retransmit. // It cannot help a handshake that is merely slow, because restarting one pays // the TLS and auth round trips again from zero. @@ -699,7 +719,7 @@ where // connected in 11067ms // // 5.3 seconds of progress binned, and four half-built pools left for the - // server to clean up — which is also how "pool timed out" showed up on the + // server to clean up - which is also how "pool timed out" showed up on the // first table open. // // So the probe decides. It opens its own TCP connection to the same host, and @@ -736,7 +756,7 @@ pub(crate) async fn open_pg(config: &PgConfig) -> Result { // still bounding accidental full-table scans that would pin a connection. // // Preferred path: ride statement_timeout in the startup packet (`options=`), - // which saves one round trip per pooled connection vs an after_connect SET — + // which saves one round trip per pooled connection vs an after_connect SET - // on a remote host that's a full RTT for every connection the pool opens. // Optional session time zone (Settings → Database). Ride it in the startup // packet next to statement_timeout so the whole pool inherits it with no @@ -1132,7 +1152,7 @@ mod tests { } /// While TCP is still unproven a stall really might be a lost SYN, and a fresh - /// SYN is the only thing that helps — so there the ladder still fires. + /// SYN is the only thing that helps - so there the ladder still fires. #[tokio::test(start_paused = true)] async fn an_unproven_tcp_path_still_gets_a_fresh_syn() { let tries = AtomicUsize::new(0); diff --git a/src-tauri/src/db/d1.rs b/src-tauri/src/db/d1.rs index 5b7b7cc4..8799f0a1 100644 --- a/src-tauri/src/db/d1.rs +++ b/src-tauri/src/db/d1.rs @@ -8,7 +8,7 @@ use std::time::Instant; // ── Shared HTTP client ───────────────────────────────────────────────────────── // One client for the whole process lifetime. reqwest maintains an internal // connection pool, so subsequent requests to the same host reuse the TLS -// session and avoid the ~300–700 ms TLS handshake on every call. +// session and avoid the ~300-700 ms TLS handshake on every call. static D1_CLIENT: OnceLock = OnceLock::new(); @@ -20,7 +20,7 @@ fn client() -> &'static reqwest::Client { .pool_idle_timeout(std::time::Duration::from_secs(90)) // Without these, a REST call that never answers (captive portal, dropped // route, Cloudflare edge stall) leaves the UI spinning on "Connecting…" - // forever — the command simply never returns. + // forever - the command simply never returns. .connect_timeout(std::time::Duration::from_secs(10)) .timeout(std::time::Duration::from_secs(60)) .build() @@ -78,7 +78,7 @@ fn result_to_sql(result: D1QueryResult, elapsed: u64) -> Result) -> Result) -> Value { ValueRef::Double(f) => serde_json::Number::from_f64(f).map(Value::Number).unwrap_or(Value::Null), ValueRef::Decimal(d) => Value::String(d.to_string()), ValueRef::Text(bytes) => { - // Cap oversized text/JSON — a multi-MB cell shipped whole freezes + // Cap oversized text/JSON - a multi-MB cell shipped whole freezes // the webview (see sql_util::CELL_VALUE_CAP), same guard as the // other engines. let s = String::from_utf8_lossy(bytes); @@ -387,7 +387,7 @@ pub async fn get_table_rows( sort_column: Option, sort_direction: Option, filters: Option>, - // When false, skip the primary-key lookup — the frontend keeps it across + // When false, skip the primary-key lookup - the frontend keeps it across // repeat fetches (pagination/sort/filter). The column list is still fetched // because sort-column validation depends on it. include_meta: bool, @@ -642,7 +642,7 @@ mod tests { let date_cell = &rows.rows[0][2]; assert!(date_cell.as_str().map(|s| s.starts_with("18")).unwrap_or(false), "date: {date_cell:?}"); - // indexes (PK shows up as an ART index in duckdb_indexes or not at all — must not error) + // indexes (PK shows up as an ART index in duckdb_indexes or not at all - must not error) let _ = list_indexes(&h).await.unwrap(); // editing diff --git a/src-tauri/src/db/explain.rs b/src-tauri/src/db/explain.rs index 708491ee..c71d0042 100644 --- a/src-tauri/src/db/explain.rs +++ b/src-tauri/src/db/explain.rs @@ -10,7 +10,7 @@ pub struct ExplainResult { pub planning_time: f64, /// Milliseconds spent executing (PostgreSQL only; 0 for others). pub execution_time: f64, - /// Which driver produced this result — helps the frontend pick the renderer. + /// Which driver produced this result - helps the frontend pick the renderer. pub driver: String, } @@ -25,7 +25,7 @@ pub fn explain_from_text_lines(lines: Vec, driver: &str) -> ExplainResul } let mut arena: Vec = Vec::new(); let mut roots: Vec = Vec::new(); - // Stack of (indent, node index) — a node attaches under the nearest + // Stack of (indent, node index) - a node attaches under the nearest // strictly-shallower ancestor still on the stack. let mut stack: Vec<(usize, usize)> = Vec::new(); @@ -98,7 +98,7 @@ fn strip_sql(sql: &str) -> &str { } else if upper_rest.starts_with("QUERY PLAN") { rest[10..].trim_start() } else if upper_rest.starts_with('(') { - // EXPLAIN (ANALYZE, ...) — skip to closing paren + // EXPLAIN (ANALYZE, ...) - skip to closing paren rest.find(')').map(|i| rest[i + 1..].trim_start()).unwrap_or(rest) } else { rest @@ -117,7 +117,7 @@ pub async fn explain_pg(pool: &sqlx::PgPool, sql: &str) -> Result row.0, // CockroachDB speaks the PG wire protocol but rejects PG's EXPLAIN option // syntax (`at or near "analyze"`). Fall back to its indented text plan; if - // that also fails, surface the original error — it's the meaningful one for + // that also fails, surface the original error - it's the meaningful one for // a genuine query fault on real PostgreSQL. Err(e) => { return match explain_cockroach(pool, sql).await { diff --git a/src-tauri/src/db/geo.rs b/src-tauri/src/db/geo.rs index 71cb3236..a5571fa0 100644 --- a/src-tauri/src/db/geo.rs +++ b/src-tauri/src/db/geo.rs @@ -1,7 +1,7 @@ -//! Geo view — PostGIS layer discovery and map feature fetching. +//! Geo view - PostGIS layer discovery and map feature fetching. //! //! The map is fed by two commands. [`geo_overview`] answers "is this a spatial -//! database, and what can be mapped?" — cheap enough to run on connect. +//! database, and what can be mapped?" - cheap enough to run on connect. //! [`geo_features`] fetches what belongs in the current viewport. //! //! Two constraints shape everything here: @@ -35,7 +35,7 @@ pub struct GeoLayer { pub schema: String, pub table: String, pub column: String, - /// "geometry" or "geography" — they need different SQL, so the UI keeps it. + /// "geometry" or "geography" - they need different SQL, so the UI keeps it. pub kind: String, /// 0 when the column is untyped (`geometry` with no typmod), which PostGIS /// treats as "unknown CRS" and this module reads as already-WGS84. @@ -67,9 +67,9 @@ pub struct GeoBbox { #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct GeoFeatures { - /// "features" — real geometries, one per row; each carries its whole row in + /// "features" - real geometries, one per row; each carries its whole row in /// `properties`, so clicking one can show the record. - /// "clusters" — grid-collapsed points carrying a `count`. The viewport held + /// "clusters" - grid-collapsed points carrying a `count`. The viewport held /// more rows than could be drawn. pub mode: String, pub features: Vec, @@ -80,7 +80,7 @@ pub struct GeoFeatures { /// "zoom to fit". Only computed when asked for. pub extent: Option<[f64; 4]>, /// The table's column names, for the map's filter bar. Sent alongside the - /// extent — i.e. once per layer selection, not per pan. Cluster rows carry + /// extent - i.e. once per layer selection, not per pan. Cluster rows carry /// only a count, so without this a table large enough to *always* cluster /// could never offer a filter at all. pub columns: Vec, @@ -198,7 +198,7 @@ fn wgs84_expr(col: &str, kind: &str, srid: i32) -> String { /// The GeoJSON-representable form of a geometry expression. /// /// A column with a declared type can only ever hold that type, so it needs -/// nothing but dropping Z/M — GeoJSON has no third ordinate, and PostGIS emits +/// nothing but dropping Z/M - GeoJSON has no third ordinate, and PostGIS emits /// one if it isn't asked otherwise. /// /// An *untyped* `geometry` column can hold anything PostGIS can parse, including @@ -221,7 +221,7 @@ fn geojson_expr(g: &str, geom_type: &str) -> String { /// A bounding-box predicate against the *stored* column, so the GiST index can /// answer it. The viewport arrives in WGS84 and is pushed into the column's own -/// SRID — the opposite direction to [`wgs84_expr`], and the reason a map over a +/// SRID - the opposite direction to [`wgs84_expr`], and the reason a map over a /// SRID-3857 table is still index-driven. fn bbox_predicate(col: &str, kind: &str, srid: i32, p: usize) -> String { let env = format!("ST_MakeEnvelope(${p}, ${}, ${}, ${}, 4326)", p + 1, p + 2, p + 3); @@ -297,7 +297,7 @@ async fn geom_column_names( /// The layer's full WGS84 extent, for "zoom to fit". /// -/// Unfiltered, this reads the GiST index's own statistics — instant, and the +/// Unfiltered, this reads the GiST index's own statistics - instant, and the /// only affordable answer on a large table. It is an estimate and can be missing /// (never analyzed, no index), in which case, and whenever a filter is active, /// the exact extent is measured. `ST_EstimatedExtent` returns `box2d`, a type @@ -366,7 +366,7 @@ async fn layer_extent( /// Features for the current viewport. /// -/// `simplify` is a tolerance in degrees, normally "one screen pixel" — dropping +/// `simplify` is a tolerance in degrees, normally "one screen pixel" - dropping /// vertices the viewer could not resolve anyway is what keeps a 30k-row road /// layer interactive. `cluster_cell` is the grid size used when the viewport /// holds more than `limit` rows. @@ -378,7 +378,7 @@ pub async fn geo_features( column: String, kind: String, srid: i32, - // The column's declared geometry type, straight from `GeoLayer` — it decides + // The column's declared geometry type, straight from `GeoLayer` - it decides // whether the rows need the defensive GeoJSON conversion. geom_type: String, bbox: Option, @@ -459,7 +459,7 @@ pub async fn geo_features( // The caller's cell size is "so many screen pixels wide", expressed in // degrees of LONGITUDE. Snapping latitude by the same number of degrees // makes every cell taller than it is wide once the map is drawn, because - // Mercator stretches latitude by 1/cos(φ) — 1.6x across Europe, 2x by + // Mercator stretches latitude by 1/cos(φ) - 1.6x across Europe, 2x by // 60°N. The visible result is each place breaking into a vertical stack // of separate blobs with gaps the marks can't bridge. // @@ -485,7 +485,7 @@ pub async fn geo_features( } else { // `preserveCollapsed` keeps a degenerate stand-in for shapes smaller than // the tolerance. Without it ST_Simplify returns NULL for them and small - // features silently vanish as you zoom out — the worst possible failure + // features silently vanish as you zoom out - the worst possible failure // for a map, because nothing looks wrong. let geom = if simplify.is_finite() && simplify > 0.0 { format!("ST_Simplify({safe}, {simplify}, true)") diff --git a/src-tauri/src/db/insights.rs b/src-tauri/src/db/insights.rs index 17aff279..11ac2f49 100644 --- a/src-tauri/src/db/insights.rs +++ b/src-tauri/src/db/insights.rs @@ -1,4 +1,4 @@ -//! Instance Insights — read-only database-monitoring snapshots. +//! Instance Insights - read-only database-monitoring snapshots. //! //! Powers the frontend "Instance Insights" dashboard (Activity / State / Config / //! Replication tabs plus a cheap version header). Supported on PostgreSQL, MySQL, @@ -7,7 +7,7 @@ //! everything else at sensible defaults (0 / empty vec) rather than erroring. //! //! Design notes: -//! - All queries are parameterless catalog reads — no user input, no injection surface. +//! - All queries are parameterless catalog reads - no user input, no injection surface. //! - Every section degrades gracefully: a catalog view that is missing or //! permission-denied yields empty rows / zeroed counters for that section rather //! than failing the whole command. The dashboard must never hard-fail because, @@ -74,7 +74,7 @@ async fn mysql_query_objects(pool: &MySqlPool, sql: &str) -> Vec { // (columns + `Vec>` rows) from their own driver modules, so these // helpers read cells positionally by column name off that shape. -/// A JSON cell rendered as a plain string — unwrap the `String` variant (no +/// A JSON cell rendered as a plain string - unwrap the `String` variant (no /// surrounding quotes) and map null to empty. fn json_string(v: &Value) -> String { match v { @@ -104,7 +104,7 @@ fn sqlresult_scalar(r: &SqlResult) -> String { } /// Turn a `SqlResult` (ClickHouse / DuckDB shape) into one JSON object per row, -/// keyed by column name — the generic row shape the State tab renders. +/// keyed by column name - the generic row shape the State tab renders. fn sqlresult_to_objects(r: &SqlResult) -> Vec { r.rows .iter() @@ -147,7 +147,7 @@ fn settings_to_config( .collect() } -/// One synthetic config row — surfaces size/storage snapshots that have no +/// One synthetic config row - surfaces size/storage snapshots that have no /// dedicated struct field (SQLite DB size, ClickHouse parts, DuckDB DB size). fn cfg_row(name: &str, value: impl Into, unit: &str, category: &str) -> ConfigSetting { ConfigSetting { @@ -328,7 +328,7 @@ pub struct SessionCounts { } /// Cumulative counters. The frontend diffs successive snapshots to derive -/// per-second rates, so these are raw monotonic totals — do not reset them here. +/// per-second rates, so these are raw monotonic totals - do not reset them here. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ActivityCounters { @@ -382,7 +382,7 @@ pub async fn instance_activity(state: State<'_, DbState>) -> Result InstanceActivity { - // Sessions — count total / active / idle from pg_stat_activity. + // Sessions - count total / active / idle from pg_stat_activity. let (total, active, idle) = match sqlx::query( "SELECT count(*) AS total, count(*) FILTER (WHERE state = 'active') AS active, @@ -446,7 +446,7 @@ async fn pg_activity(pool: &PgPool) -> InstanceActivity { } async fn mysql_activity(pool: &MySqlPool) -> InstanceActivity { - // Sessions — 'Sleep' command == idle connection. + // Sessions - 'Sleep' command == idle connection. let (total, active, idle) = match sqlx::query( "SELECT COUNT(*) AS total, CAST(SUM(COMMAND <> 'Sleep') AS SIGNED) AS active, @@ -683,7 +683,7 @@ pub struct ConfigSetting { pub enum_vals: Vec, pub min_val: String, pub max_val: String, - /// Compiled-in default — what a reset returns to. + /// Compiled-in default - what a reset returns to. pub boot_val: String, /// Value a session reset would see (config file value, not the session override). pub reset_val: String, @@ -917,11 +917,11 @@ pub async fn instance_set_config( let message = if value.is_none() { format!("{name} reset to its default") } else if requires_restart { - format!("{name} written to postgresql.auto.conf — restart the server to apply it") + format!("{name} written to postgresql.auto.conf - restart the server to apply it") } else if reloaded { format!("{name} is now {current}") } else { - format!("{name} written, but the config reload failed — reload manually") + format!("{name} written, but the config reload failed - reload manually") }; Ok(SetConfigResult { name, value: current, requires_restart, reloaded, message }) @@ -961,7 +961,7 @@ pub async fn instance_set_config( } else if persisted { format!("{name} is now {current} (persisted)") } else { - format!("{name} is now {current} — not persisted, it resets on restart") + format!("{name} is now {current} - not persisted, it resets on restart") }; Ok(SetConfigResult { @@ -1022,7 +1022,7 @@ pub async fn instance_replication(state: State<'_, DbState>) -> Result { Ok(InstanceReplication { engine: "sqlite".into(), stats: Vec::new(), slots: Vec::new() }) } diff --git a/src-tauri/src/db/libsql.rs b/src-tauri/src/db/libsql.rs index ccaea043..6e34d7e4 100644 --- a/src-tauri/src/db/libsql.rs +++ b/src-tauri/src/db/libsql.rs @@ -91,7 +91,7 @@ impl TypedValue { .and_then(|s| s.parse::().ok()) .map(|f| Value::from(f)) .unwrap_or(Value::Null), - // Capped — a multi-MB text cell shipped whole freezes the webview + // Capped - a multi-MB text cell shipped whole freezes the webview // (see sql_util::CELL_VALUE_CAP). "text" => super::sql_util::cap_json_value( "text", diff --git a/src-tauri/src/db/live.rs b/src-tauri/src/db/live.rs index 8cefbaec..de5f4c5c 100644 --- a/src-tauri/src/db/live.rs +++ b/src-tauri/src/db/live.rs @@ -1,15 +1,15 @@ -//! Live mode — push table changes to the frontend so a viewed table refreshes +//! Live mode - push table changes to the frontend so a viewed table refreshes //! itself when the underlying data changes. //! //! Both engines use cheap **polling** (a background task per watched table) so //! live mode needs no special privileges and works through connection poolers //! (pgbouncer, Supabase, etc.) that break trigger DDL / `LISTEN`: //! -//! - **SQLite**: `PRAGMA data_version` — an in-memory counter that bumps whenever +//! - **SQLite**: `PRAGMA data_version` - an in-memory counter that bumps whenever //! *another* connection/process commits a change to the file. //! - **Postgres**: the `n_tup_ins + n_tup_upd + n_tup_del` counters from -//! `pg_stat_user_tables` — increments on every insert/update/delete, readable -//! by any role, no table scan. (A ~1–2s stats-flush lag is fine for live view.) +//! `pg_stat_user_tables` - increments on every insert/update/delete, readable +//! by any role, no table scan. (A ~1-2s stats-flush lag is fine for live view.) //! //! Only one table is watched at a time (the active tab). Starting a new watch //! tears the previous one down first. @@ -85,7 +85,7 @@ pub fn stop(live: &LiveState) { } /// A watcher whose pool keeps failing is polling a connection that no longer -/// exists (the app connected elsewhere without stopping live mode) — after this +/// exists (the app connected elsewhere without stopping live mode) - after this /// many straight failures the task self-terminates instead of holding the /// closed pool alive and erroring every tick forever. const MAX_CONSECUTIVE_ERRORS: u32 = 10; @@ -118,7 +118,7 @@ fn spawn_sqlite( } last = Some(v); } - // Transient (pool busy) — retry next tick. But a run of straight + // Transient (pool busy) - retry next tick. But a run of straight // failures means the pool is gone (connection switched away // without live::stop): a closed pool fails instantly and // permanently, so stop instead of erroring forever. @@ -162,10 +162,10 @@ fn spawn_pg( } last = Some(v); } - // No stats row yet (table never modified, or track_counts off) — + // No stats row yet (table never modified, or track_counts off) - // keep polling; the row appears once activity is recorded. Ok(None) => { consecutive_errors = 0; } - // See MAX_CONSECUTIVE_ERRORS — a permanently failing pool means + // See MAX_CONSECUTIVE_ERRORS - a permanently failing pool means // the connection was switched away; stop polling it. Err(_) => { consecutive_errors += 1; diff --git a/src-tauri/src/db/local_scan.rs b/src-tauri/src/db/local_scan.rs index 8995ee47..b76c3e06 100644 --- a/src-tauri/src/db/local_scan.rs +++ b/src-tauri/src/db/local_scan.rs @@ -1,5 +1,5 @@ /*! -Zero-config discovery of the databases already running on this machine — the ORM +Zero-config discovery of the databases already running on this machine - the ORM studios pointed at one, and the database servers installed natively. `prisma studio` and `drizzle-kit studio` are already pointed at a database: the @@ -10,7 +10,7 @@ the project's own config, and hand the frontend a ready-to-use connection. Discovery is by *process*, not by HTTP. Both studios speak private, unversioned HTTP APIs that would break on any upgrade, whereas a command line, a cwd and an -environment are stable — and they yield the real database URL, so a click opens a +environment are stable - and they yield the real database URL, so a click opens a native Stroke session (editing, SQL, AI, exports) instead of proxying every query through someone else's dev server. @@ -36,7 +36,7 @@ const PORT_PROBE_SPAN: u16 = 6; pub struct DetectedStudio { /// Stable across scans, so the UI can key rows without them flickering. pub id: String, - /// `prisma` | `drizzle` — also a `DbIcon` brand id. + /// `prisma` | `drizzle` - also a `DbIcon` brand id. pub tool: String, pub tool_label: String, pub pid: u32, @@ -156,8 +156,8 @@ fn collect_studios() -> Vec { env, }; let studio = describe(found); - // `npx`/`bunx prisma studio` shows up twice — once as the launcher, once - // as the process it spawned — and only the child holds the socket. One + // `npx`/`bunx prisma studio` shows up twice - once as the launcher, once + // as the process it spawned - and only the child holds the socket. One // row per project, preferring the entry that knows a real port and // resolved a database. match out @@ -197,7 +197,7 @@ fn classify(args: &[String]) -> Option<&'static str> { /// The port a studio is really serving on. /// /// Current Prisma Studio takes a *random high port* and prints it, so neither the -/// default nor the command line can be trusted — the process's own listening +/// default nor the command line can be trusted - the process's own listening /// sockets are the only ground truth. Among several (a debugger, a query engine), /// prefer what the CLI asked for, then the tool's default, then the lowest. fn serving_port(pid: u32, arg: Option, tool: &str) -> Option { @@ -432,7 +432,7 @@ fn merge_env_file(text: &str, out: &mut HashMap) { let mut val = v.trim(); let quoted = val.starts_with('"') || val.starts_with('\''); if !quoted { - // `KEY=value # note` — a trailing comment is not part of the value. + // `KEY=value # note` - a trailing comment is not part of the value. if let Some(i) = val.find(" #") { val = val[..i].trim_end(); } @@ -474,7 +474,7 @@ fn resolve_prisma(p: &StudioProcess, env: &HashMap) -> Target { let base = schema.parent().unwrap_or(&p.cwd).to_path_buf(); let source = display_rel(&p.cwd, schema); // `url` can be a Prisma Postgres / Accelerate proxy string that no - // driver can open — `directUrl` is the real database in that case. + // driver can open - `directUrl` is the real database in that case. for key in ["url", "directUrl"] { if let Some(url) = assign_value(&block, key, env) { candidates.push(Candidate { url, base: base.clone(), source: source.clone() }); @@ -502,7 +502,7 @@ fn resolve_prisma(p: &StudioProcess, env: &HashMap) -> Target { // 3. Prisma's own default, then the names the hosted providers use. A driver // adapter (Turso, Neon, Vercel, D1) leaves the datasource block with a // provider and nothing else, so the URL only ever exists in the - // environment — which is exactly the remote case. + // environment - which is exactly the remote case. if candidates.is_empty() { for key in [ "DATABASE_URL", @@ -531,7 +531,7 @@ fn resolve_prisma(p: &StudioProcess, env: &HashMap) -> Target { let Some(first) = candidates.first() else { return Target::failed( fallback_source, - "Couldn't work out this project's database URL — no datasource url, and DATABASE_URL isn't set in its .env.", + "Couldn't work out this project's database URL - no datasource url, and DATABASE_URL isn't set in its .env.", ); }; for c in &candidates { @@ -549,10 +549,10 @@ fn resolve_prisma(p: &StudioProcess, env: &HashMap) -> Target { first.source.clone(), if scheme.starts_with("prisma") { // Accelerate/Prisma Postgres URLs are an API endpoint, not a wire - // protocol — but the app can reach the same database another way. + // protocol - but the app can reach the same database another way. "This is a Prisma Postgres URL, which no driver can open directly. Connect it from Hosting providers → Prisma Postgres, or add a `directUrl` to the datasource block.".to_string() } else { - format!("Stroke can't open a `{scheme}` datasource URL directly — add a `directUrl` pointing at the database itself.") + format!("Stroke can't open a `{scheme}` datasource URL directly - add a `directUrl` pointing at the database itself.") }, ) } @@ -570,7 +570,7 @@ fn auth_token_from_env(env: &HashMap) -> Option { .cloned() } -/// `prisma.config.ts` and friends — where Prisma 6 keeps the datasource url. +/// `prisma.config.ts` and friends - where Prisma 6 keeps the datasource url. fn prisma_config_path(p: &StudioProcess) -> Option { if let Some(arg) = flag_value(&p.args, "--config") { let path = p.cwd.join(arg); @@ -646,7 +646,7 @@ fn resolve_drizzle(p: &StudioProcess, env: &HashMap) -> Target { if driver.contains("expo") || declared.contains("pglite") || declared.contains("gel") { return Target::failed( source, - format!("`{}` runs inside the app process — there's no server here to connect to.", if driver.is_empty() { &declared } else { &driver }), + format!("`{}` runs inside the app process - there's no server here to connect to.", if driver.is_empty() { &declared } else { &driver }), ); } @@ -667,7 +667,7 @@ fn resolve_drizzle(p: &StudioProcess, env: &HashMap) -> Target { } return Target::failed( source, - "This D1 config is missing an accountId, databaseId or token that Stroke can resolve — set them in the project's .env.", + "This D1 config is missing an accountId, databaseId or token that Stroke can resolve - set them in the project's .env.", ); } @@ -691,13 +691,13 @@ fn resolve_drizzle(p: &StudioProcess, env: &HashMap) -> Target { if text.contains("miniflare-D1DatabaseObject") || text.contains(".wrangler") { return Target::failed( source, - "This is a Cloudflare D1 local database, but miniflare hasn't created its SQLite file yet — start your dev server (or run `wrangler d1 migrations apply --local`), then refresh.", + "This is a Cloudflare D1 local database, but miniflare hasn't created its SQLite file yet - start your dev server (or run `wrangler d1 migrations apply --local`), then refresh.", ); } } return Target::failed( source, - "Couldn't resolve dbCredentials — the env var this config points at isn't set in the project's .env.", + "Couldn't resolve dbCredentials - the env var this config points at isn't set in the project's .env.", ); }; @@ -718,13 +718,13 @@ fn resolve_drizzle(p: &StudioProcess, env: &HashMap) -> Target { } /// The SQLite file `wrangler ... --local` (miniflare) creates for a D1 binding. -/// Configs that read this at runtime — a helper returning the path — can't be +/// Configs that read this at runtime - a helper returning the path - can't be /// evaluated statically, but the location is fixed relative to the project, so /// resolve it directly. /// /// The directory holds the user's database as `.sqlite` alongside /// miniflare's own `metadata.sqlite` (internal `_cf_*` bookkeeping). Selecting -/// by name — the hash-stemmed file — is what avoids opening the metadata store +/// by name - the hash-stemmed file - is what avoids opening the metadata store /// and showing empty `_cf_ALARM` tables instead of the real data. fn resolve_local_d1_sqlite(cwd: &Path) -> Option { let dir = cwd.join(".wrangler/state/v3/d1/miniflare-D1DatabaseObject"); @@ -742,7 +742,7 @@ fn resolve_local_d1_sqlite(cwd: &Path) -> Option { // One per binding in practice; if miniflare kept several, the one holding // the most data is the one being used. A just-written database can have a // near-empty main file with every row still in its `-wal`, so weigh the - // sidecar too — otherwise an idle binding outranks the live one. SQLite + // sidecar too - otherwise an idle binding outranks the live one. SQLite // merges the WAL on open, so the main file is still the path to hand back. files.sort_by_key(|f| d1_stored_bytes(f)); files.pop().map(|f| f.to_string_lossy().to_string()) @@ -760,7 +760,7 @@ fn is_d1_database_name(stem: &str) -> bool { stem.len() >= 16 && stem.bytes().all(|b| b.is_ascii_hexdigit()) } -/// `dbCredentials: { host, port, user, password, database }` — the field form. +/// `dbCredentials: { host, port, user, password, database }` - the field form. fn drizzle_url_from_parts( text: &str, dialect: &str, @@ -815,7 +815,7 @@ fn drizzle_config_path(p: &StudioProcess) -> Option { // ── URL → Stroke driver ─────────────────────────────────────────────────────── /// Maps a resolved URL (plus whatever the config called its dialect) onto a -/// Stroke driver id. Returns None when no driver can open it — a Prisma Postgres +/// Stroke driver id. Returns None when no driver can open it - a Prisma Postgres /// proxy string, an Expo/React-Native SQLite binding, and so on. fn target_from_url(url: &str, dialect: &str, base: &Path) -> Option { let raw = url.trim(); @@ -826,7 +826,7 @@ fn target_from_url(url: &str, dialect: &str, base: &Path) -> Option { || (!lower.contains("://") && (d.contains("sqlite") || d.contains("better-sqlite"))); if file_backed { let path = raw.trim_start_matches("file:"); - // `:memory:` is the studio's own process memory — nothing to attach to. + // `:memory:` is the studio's own process memory - nothing to attach to. if path.is_empty() || path.contains(":memory:") { return None; } @@ -960,7 +960,7 @@ fn interpolate(text: &str, env: &HashMap) -> String { out.push_str(&rest[start..]); return out; }; - // `${DATABASE_URL}` — inside an interpolation a bare name is a variable + // `${DATABASE_URL}` - inside an interpolation a bare name is a variable // reference, which is what dotenv-expand means by it too. let inner = tail[..end].trim(); let value = resolve_expr(inner, env).or_else(|| env.get(inner).cloned()); @@ -1009,7 +1009,7 @@ fn env_var_name(expr: &str) -> Option { } let start = start?; let tail = &after[start..]; - // `env.get("X")` — step over the `get` and keep looking. + // `env.get("X")` - step over the `get` and keep looking. if tail.starts_with("get") && !tail[3..].starts_with(|c: char| c.is_ascii_alphanumeric() || c == '_') { return env_var_name(&format!("env{}", &tail[3..])); } @@ -1024,7 +1024,7 @@ fn env_var_name(expr: &str) -> Option { } } -/// Lexically resolves `.` and `..` — `canonicalize` can't be used because the +/// Lexically resolves `.` and `..` - `canonicalize` can't be used because the /// SQLite file may not exist yet (a fresh `drizzle-kit push` hasn't run). fn normalize(path: &Path) -> PathBuf { let mut out = PathBuf::new(); @@ -1046,7 +1046,7 @@ fn is_ident_byte(b: u8) -> bool { b.is_ascii_alphanumeric() || b == b'_' || b == b'$' } -/// True when the byte offset sits on a `//` or `#` comment line — a commented-out +/// True when the byte offset sits on a `//` or `#` comment line - a commented-out /// `url` must not win over the live one below it. fn line_is_comment(text: &str, at: usize) -> bool { let line_start = text[..at].rfind('\n').map(|i| i + 1).unwrap_or(0); @@ -1273,7 +1273,7 @@ mod tests { fn resolves_the_local_d1_sqlite_file_for_a_helper_computed_url() { // The Cloudflare D1 local-dev config from the wild: `url: localD1File()` // computes the miniflare path at runtime. Static parsing can't reach it, - // so the fallback must find the file itself — under whatever name + // so the fallback must find the file itself - under whatever name // miniflare gave it, since the stem is a hash of the user's database. let root = std::env::temp_dir().join(format!("stroke_d1_scan_{}", std::process::id())); let d1_dir = root.join(".wrangler/state/v3/d1/miniflare-D1DatabaseObject"); @@ -1287,7 +1287,7 @@ mod tests { std::fs::write(&db, b"the real database with user tables").unwrap(); std::fs::write(format!("{}-wal", db.display()), vec![0u8; 65536]).unwrap(); // miniflare's own metadata store must NOT be picked, even though it - // outweighs both — its stem isn't a hash. + // outweighs both - its stem isn't a hash. std::fs::write(d1_dir.join("metadata.sqlite"), vec![0u8; 1 << 20]).unwrap(); let found = resolve_local_d1_sqlite(&root); @@ -1338,7 +1338,7 @@ mod tests { // A Postgres from the package manager has no compose file and no schema to read, // so there is nothing to recover a password from. What it does have is a process // and a port, which is enough to offer the connection with the engine's own -// conventional superuser — the case that actually works on a dev box. +// conventional superuser - the case that actually works on a dev box. #[derive(Debug, Serialize, Clone)] #[serde(rename_all = "camelCase")] @@ -1357,7 +1357,7 @@ pub struct MachineDatabase { } /// Engine for a server process, matched on the executable rather than the whole -/// command line — `psql`, `pg_dump` and an editor holding "postgres" in a path +/// command line - `psql`, `pg_dump` and an editor holding "postgres" in a path /// are all clients, not servers. fn engine_for_process(exe: &str, args: &[String]) -> Option<&'static str> { let name = exe.rsplit('/').next().unwrap_or(exe).to_lowercase(); @@ -1393,7 +1393,7 @@ fn machine_defaults(engine: &str) -> (&'static str, &'static str) { /// /// A container's server process is visible in the host process list too, but its /// listening socket lives in the container's own network namespace, so it never -/// matches the host's socket table and drops out here — which is what keeps it +/// matches the host's socket table and drops out here - which is what keeps it /// from being listed twice alongside its Docker row. #[tauri::command] pub async fn scan_machine_databases() -> Result, String> { diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs index 42c4a073..3b6bc523 100644 --- a/src-tauri/src/db/mod.rs +++ b/src-tauri/src/db/mod.rs @@ -5,6 +5,7 @@ pub mod d1; pub mod duckdb; pub mod mssql; pub mod explain; +pub mod advisor; pub mod insights; pub mod libsql; pub mod live; diff --git a/src-tauri/src/db/mssql.rs b/src-tauri/src/db/mssql.rs index 70e1f1c5..7229a7c8 100644 --- a/src-tauri/src/db/mssql.rs +++ b/src-tauri/src/db/mssql.rs @@ -82,7 +82,7 @@ fn cell_to_json(row: &Row, i: usize) -> Value { }; } - // Cap oversized strings (NVARCHAR(MAX) etc.) — a multi-MB cell shipped + // Cap oversized strings (NVARCHAR(MAX) etc.) - a multi-MB cell shipped // whole freezes the webview (see sql_util::CELL_VALUE_CAP). if let Ok(v) = row.try_get::<&str, usize>(i) { return v @@ -535,7 +535,7 @@ pub async fn insert_table_row( let val_list = cols.iter().map(|c| sql_literal(&values[c])).collect::>().join(", "); let tq = format!("{}.{}", quote_ident(schema), quote_ident(table)); // OUTPUT INSERTED.* returns the full new row (including server-generated columns). - // This INSERT yields a result set, so it must go through simple_query — not + // This INSERT yields a result set, so it must go through simple_query - not // execute_sql, whose DML branch uses execute() and discards returned rows // (which left the grid showing an all-NULL row after insert). let sql = format!("INSERT INTO {tq} ({col_list}) OUTPUT INSERTED.* VALUES ({val_list})"); diff --git a/src-tauri/src/db/mysql.rs b/src-tauri/src/db/mysql.rs index 68f32cd3..d9509c75 100644 --- a/src-tauri/src/db/mysql.rs +++ b/src-tauri/src/db/mysql.rs @@ -26,7 +26,7 @@ pub fn cell_to_json(row: &sqlx::mysql::MySqlRow, idx: usize) -> Value { } // Fast path: route the common column types by name so a plain text/date cell // doesn't pay for a cascade of failed try_get attempts (each mismatch makes - // sqlx allocate a boxed "mismatched types" error — up to 9 per text cell). + // sqlx allocate a boxed "mismatched types" error - up to 9 per text cell). // A failed route falls through to the full chain below, so unmatched or // alias types behave exactly as before. match type_name { @@ -109,7 +109,7 @@ pub fn cell_to_json(row: &sqlx::mysql::MySqlRow, idx: usize) -> Value { Value::Null } -/// String cell, capped — a multi-MB cell shipped whole freezes the webview +/// String cell, capped - a multi-MB cell shipped whole freezes the webview /// (see sql_util::CELL_VALUE_CAP). fn text_cell(row: &sqlx::mysql::MySqlRow, idx: usize, v: Option) -> Value { match v { @@ -158,7 +158,7 @@ struct WhereClause { } fn build_where(columns: &[String], search: Option<&str>, search_is_regex: bool, search_case_sensitive: bool, filters: &[RowFilter]) -> Result { - // (conjunct — None for first condition, Some("AND"/"OR") for subsequent) + // (conjunct - None for first condition, Some("AND"/"OR") for subsequent) let mut cond_parts: Vec<(Option<&'static str>, String)> = Vec::new(); let mut binds: Vec = Vec::new(); @@ -220,7 +220,7 @@ fn build_where(columns: &[String], search: Option<&str>, search_is_regex: bool, op => { let v = f.value.as_deref().unwrap_or("").trim(); if v.is_empty() { continue; } - // Comparison operators: NO CAST — MySQL performs implicit type coercion + // Comparison operators: NO CAST - MySQL performs implicit type coercion // from the string parameter to the column's actual type, which means the // database can use indexes on typed columns (INT, DECIMAL, DATETIME, etc.). // Only text-search ops (LIKE) need CAST since LIKE is inherently string-only. @@ -287,7 +287,7 @@ pub async fn get_table_rows( sort_column: Option, sort_direction: Option, filters: Option>, - // When false, skip the primary-key/foreign-key catalog round-trips — the + // When false, skip the primary-key/foreign-key catalog round-trips - the // frontend already holds them for repeat fetches (pagination/sort/filter/live). include_meta: bool, nulls_order: Option, @@ -325,7 +325,7 @@ pub async fn get_table_rows( "desc" => "DESC", _ => "ASC", }; - // Emulate NULLS FIRST/LAST — real MySQL (unlike MariaDB) rejects the + // Emulate NULLS FIRST/LAST - real MySQL (unlike MariaDB) rejects the // `NULLS FIRST/LAST` syntax. `ISNULL(col)` yields 0 for non-NULLs and 1 // for NULLs: ordering it ASC keeps NULLs last, DESC puts NULLs first. let qc = bt(col); @@ -339,7 +339,7 @@ pub async fn get_table_rows( let table_ref = format!("{}.{}", bt(schema), bt(table)); // MySQL types COUNT(*) as BIGINT UNSIGNED, but MariaDB types it as signed - // BIGINT — decoding the wrong signedness is a hard type-mismatch in sqlx. + // BIGINT - decoding the wrong signedness is a hard type-mismatch in sqlx. // CAST(... AS SIGNED) normalizes both to i64, which comfortably holds any // real row count. let count_sql = format!("SELECT CAST(COUNT(*) AS SIGNED) FROM {table_ref}{}", where_clause.sql); @@ -523,7 +523,7 @@ pub async fn execute_sql( if is_select { let mut stream = sqlx::query(sql).fetch(&mut *conn); // Convert each row to JSON as it streams in and drop the driver row - // immediately — retaining the full Vec alongside the JSON rows + // immediately - retaining the full Vec alongside the JSON rows // would double peak memory on a large result. let mut columns: Vec = Vec::new(); let mut data: Vec> = Vec::new(); @@ -560,7 +560,7 @@ pub async fn execute_sql( rows: data, row_count: Some(row_count), message: if capped { - Some(format!("Result capped at {EXECUTE_SQL_MAX_ROWS} rows — add a LIMIT clause to fetch a specific range.")) + Some(format!("Result capped at {EXECUTE_SQL_MAX_ROWS} rows - add a LIMIT clause to fetch a specific range.")) } else { None }, diff --git a/src-tauri/src/db/pg_ext_types.rs b/src-tauri/src/db/pg_ext_types.rs index d50b24df..d3c55eaf 100644 --- a/src-tauri/src/db/pg_ext_types.rs +++ b/src-tauri/src/db/pg_ext_types.rs @@ -4,7 +4,7 @@ Wire decoders for the Postgres extension types the driver doesn't know. sqlx decodes the core types; anything an extension adds arrives as raw bytes in the binary protocol, and the generic "is it UTF-8?" fallback can't help because a vector is packed floats and a geometry is packed doubles. Without these the cells -read `` and `` — the app can see there is data and then refuses +read `` and `` - the app can see there is data and then refuses to show it, which is the least useful thing it could do with a pgvector table. Everything here reads a byte slice and returns a string in the same shape the @@ -55,7 +55,7 @@ impl<'a> Cur<'a> { /// Shortest representation that round-trips an f32, which is what pgvector /// stores and prints. Widening to f64 first is what turns the stored `0.001` -/// into `0.001000000047497451` — the f32 has to be formatted as an f32. +/// into `0.001000000047497451` - the f32 has to be formatted as an f32. fn numf(v: f32) -> String { if v == v.trunc() && v.abs() < 1e15 { return format!("{}", v as i64); @@ -71,7 +71,7 @@ fn numf(v: f32) -> String { } /// Shortest representation that round-trips, the way both pgvector and PostGIS -/// print numbers — `1` not `1.0`, `0.5` not `0.50000000`. +/// print numbers - `1` not `1.0`, `0.5` not `0.50000000`. fn num(v: f64) -> String { if v == v.trunc() && v.abs() < 1e15 { return format!("{}", v as i64); @@ -226,7 +226,7 @@ fn geom(c: &mut Cur) -> Option<(String, Option)> { 5 => "MULTILINESTRING", 6 => "MULTIPOLYGON", 7 => "GEOMETRYCOLLECTION", - _ => return None, // curves, surfaces, TIN — not modelled here + _ => return None, // curves, surfaces, TIN - not modelled here }; let body = match base { diff --git a/src-tauri/src/db/query.rs b/src-tauri/src/db/query.rs index 44b3bade..6d2c7d1f 100644 --- a/src-tauri/src/db/query.rs +++ b/src-tauri/src/db/query.rs @@ -29,7 +29,7 @@ pub struct ColumnInfo { pub has_default: bool, } -/// What the catalog knows about a column beyond its type — the three facts the +/// What the catalog knows about a column beyond its type - the three facts the /// insert row needs to decide between "required", "optional", and "don't ask". #[derive(Debug, Clone, Copy, Default)] pub(crate) struct ColumnFlags { @@ -58,7 +58,7 @@ async fn fetch_table_column_flags( ) -> Result, String> { // pg_attribute is much faster than information_schema.columns for this lookup. // `attidentity` covers GENERATED … AS IDENTITY, `attgenerated` covers stored - // generated columns, and a `nextval(` default is what `serial` actually is — + // generated columns, and a `nextval(` default is what `serial` actually is - // its data_type reads as `bigint`, so the type alone can never identify one. let rows = sqlx::query( r#" @@ -96,6 +96,59 @@ async fn fetch_table_column_flags( Ok(map) } +/// Column flags for the keyset gate only, memoised for a few seconds. +/// +/// The gate asks "is the ordering column NOT NULL?" before every keyset page, +/// and a windowed scroll of a large table issues one page per window - so +/// uncached it puts a catalog round-trip in front of each of them (nothing +/// locally, a full RTT against a remote database, which is most of what the +/// keyset fast path just saved). Deliberately *not* used by the `include_meta` +/// path: what the insert row is told about a column stays exact. The worst a +/// stale entry can do here is pick OFFSET over keyset, or take keyset on a +/// column that became nullable seconds ago. +static COLUMN_FLAGS_CACHE: std::sync::OnceLock< + std::sync::Mutex)>>, +> = std::sync::OnceLock::new(); +const COLUMN_FLAGS_TTL: std::time::Duration = std::time::Duration::from_secs(10); + +async fn cached_column_flags( + pool: &sqlx::PgPool, + schema: &str, + table: &str, +) -> Result, String> { + // Keyed by the database this pool is connected to as well as the table: + // two connections open on the same `public.users` are two different tables. + let opts = pool.connect_options(); + let key = format!( + "{}:{}/{}\u{0}{schema}.{table}", + opts.get_host(), + opts.get_port(), + opts.get_database().unwrap_or_default() + ); + let cache = COLUMN_FLAGS_CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new())); + // Scoped so the guard is dropped before the await below - never hold a lock + // across one. + { + if let Ok(map) = cache.lock() { + if let Some((at, flags)) = map.get(&key) { + if at.elapsed() < COLUMN_FLAGS_TTL { + return Ok(flags.clone()); + } + } + } + } + let flags = fetch_table_column_flags(pool, schema, table).await?; + if let Ok(mut map) = cache.lock() { + // Bounded: a session that browses thousands of tables shouldn't grow this + // forever, and everything dropped is a re-query at worst. + if map.len() > 256 { + map.retain(|_, (at, _)| at.elapsed() < COLUMN_FLAGS_TTL); + } + map.insert(key, (std::time::Instant::now(), flags.clone())); + } + Ok(flags) +} + fn apply_column_flags(columns: &mut [ColumnInfo], flags: &HashMap) { for col in columns.iter_mut() { if let Some(f) = flags.get(&col.name) { @@ -111,7 +164,7 @@ async fn fetch_table_column_enums( schema: &str, table: &str, ) -> Result>, String> { - // pg_attribute is far faster than information_schema.columns here — the view + // pg_attribute is far faster than information_schema.columns here - the view // scans many system tables with multiple joins; pg_attribute is a direct heap scan. let rows = sqlx::query( r#" @@ -206,7 +259,7 @@ fn pg_type_label(type_name: &str) -> String { /// Render a Postgres interval the way Postgres does: "1 year 2 mons 3 days 04:05:06". /// Units are kept separate rather than normalised into seconds because months and -/// days are not fixed-length — collapsing them would change the value's meaning. +/// days are not fixed-length - collapsing them would change the value's meaning. fn format_pg_interval(iv: &sqlx::postgres::types::PgInterval) -> String { let mut parts: Vec = Vec::new(); @@ -267,7 +320,7 @@ pub(crate) fn cell_to_json(row: &sqlx::postgres::PgRow, idx: usize) -> Value { } // Fast path: route the common built-in types by name so a cell doesn't pay - // for a cascade of failed try_get attempts — every mismatch makes sqlx + // for a cascade of failed try_get attempts - every mismatch makes sqlx // allocate a formatted "mismatched types" error, up to 12 per cell on a // text column. A failed route falls through to the full chain below, so // alias/unmatched types behave exactly as before. @@ -332,7 +385,7 @@ pub(crate) fn cell_to_json(row: &sqlx::postgres::PgRow, idx: usize) -> Value { // (dimension/length header + element data) as lossy UTF-8 → garbage □ boxes. // Decode into a Vec and return a JSON array; the frontend renders it {a,b}-style. // sqlx validates the element PgType on each try_get, so only the matching arm - // returns — numeric arms are tried before the String arm so text[] isn't misread. + // returns - numeric arms are tried before the String arm so text[] isn't misread. if type_name.ends_with("[]") { macro_rules! try_arr { ($t:ty) => { @@ -378,12 +431,12 @@ pub(crate) fn cell_to_json(row: &sqlx::postgres::PgRow, idx: usize) -> Value { None => Value::Null, }; } - // Unknown element type (e.g. enum[]) — fall through to the raw branch. + // Unknown element type (e.g. enum[]) - fall through to the raw branch. } // INTERVAL has no text-compatible decode, so without this it reached the raw // branch below and Postgres's 16-byte binary interval (months/days/micros) was - // reinterpreted as UTF-8 — a row of NUL boxes plus whatever byte happened to be + // reinterpreted as UTF-8 - a row of NUL boxes plus whatever byte happened to be // printable ("□□□m"). Rebuild Postgres's own text rendering from the parts. if type_name == "INTERVAL" { if let Ok(v) = row.try_get::, _>(idx) { @@ -397,12 +450,12 @@ pub(crate) fn cell_to_json(row: &sqlx::postgres::PgRow, idx: usize) -> Value { // Use raw wire-protocol bytes for all remaining types (TEXT, VARCHAR, enums, domains…). // Skipping try_get::() avoids sqlx's runtime pg_catalog introspection for // custom/enum types, which would fire a `SELECT enumlabel FROM pg_enum WHERE …` query - // per unique enum OID encountered — each one a full network round-trip. + // per unique enum OID encountered - each one a full network round-trip. if let Ok(raw) = row.try_get_raw(idx) { if raw.is_null() { return Value::Null; } - // Oversize guard before allocating the String — text cells beyond the + // Oversize guard before allocating the String - text cells beyond the // cap ship as a sentinel + preview instead of the whole value. Non-UTF-8 // payloads (bytea) fall through to the byte-count placeholder below. if let Ok(bytes) = raw.as_bytes() { @@ -414,7 +467,7 @@ pub(crate) fn cell_to_json(row: &sqlx::postgres::PgRow, idx: usize) -> Value { // strings. Their binary form is packed numbers, so the UTF-8 attempt // below can never reach them and the cell used to read ``. // Both byte-level checks happen before `String::decode`, which consumes - // `raw` — and neither copies the payload. + // `raw` - and neither copies the payload. if let Ok(bytes) = raw.as_bytes() { if let Some(text) = super::pg_ext_types::decode_ext_type(type_name, bytes) { return super::sql_util::cap_json_value( @@ -628,7 +681,7 @@ impl PgColumnMeta { return Ok(format!(r#""{column}" = $1::{type_ref}"#)); } // Array columns. PostgreSQL names every array type with a leading - // underscore (_varchar, _int4, _text, …) — that's what pg_type.typname + // underscore (_varchar, _int4, _text, …) - that's what pg_type.typname // returns here; information_schema instead reports the literal "ARRAY". // The editor sends a Postgres array literal ({"a","b"}); cast it to the // real array type so PostgreSQL parses it instead of rejecting it as text. @@ -657,7 +710,7 @@ impl PgColumnMeta { let type_ref = pg_cast_type_ref(udt_schema, udt_name)?; return Ok(format!("${bind_idx}::{type_ref}")); } - // Array columns — cast the array-literal string to the real array type. + // Array columns - cast the array-literal string to the real array type. if let Some(arr) = self.array_cast_ref()? { return Ok(format!("${bind_idx}::{arr}")); } @@ -779,7 +832,7 @@ pub(super) struct WhereClause { } struct QueryBuilder { - /// (conjunct, sql_fragment) — conjunct is None for the first condition. + /// (conjunct, sql_fragment) - conjunct is None for the first condition. /// Using &'static str avoids a String allocation per filter for "AND"/"OR". conditions: Vec<(Option<&'static str>, String)>, binds: Vec, @@ -846,7 +899,7 @@ struct TextOnlyColumn { /// True when a Postgres error is the driver asking for a binary value the server /// cannot produce. sqlx always requests binary results, so a single column of a /// type without `typsend` (PostGIS `raster`, `box2d`, `box3d`, `spheroid`, and -/// plenty of other extension types) fails the *whole* `SELECT *` — the table +/// plenty of other extension types) fails the *whole* `SELECT *` - the table /// refuses to open rather than showing the columns it could have decoded. fn is_missing_binary_output(err: &str) -> bool { err.contains("no binary output function available for type") @@ -897,11 +950,11 @@ async fn fetch_text_only_columns( /// How to read a binary-less column as something a cell can hold. /// -/// `::text` is the general answer — it is what psql shows and it round-trips. +/// `::text` is the general answer - it is what psql shows and it round-trips. /// `raster` is the exception: its text form is the entire tile as WKB hex, which /// is megabytes for any real raster and useless in a grid either way, so it gets /// a description of the tile instead. That value is display-only, which costs -/// nothing that wasn't already lost — a type with no binary output cannot be +/// nothing that wasn't already lost - a type with no binary output cannot be /// edited through the grid regardless. fn text_projection(col: &TextOnlyColumn) -> Result { let q = quoted_column(&col.name)?; @@ -959,7 +1012,7 @@ async fn types_without_binary_output(pool: &sqlx::PgPool, names: &[String]) -> V /// text: `SELECT a, b::text AS b FROM () _stroke_text`. This is what lets /// a hand-written `SELECT * FROM tiles` return rows instead of an error. /// -/// `None` when the statement can't be wrapped without changing what it means — +/// `None` when the statement can't be wrapped without changing what it means - /// unnamed columns (`?column?`), duplicate names, or nothing needing a cast. /// The caller then surfaces the original error rather than a rewritten one. async fn text_safe_wrap(pool: &sqlx::PgPool, stmt: &str) -> Option { @@ -1058,7 +1111,7 @@ fn pg_param_cast(data_type: Option<&str>) -> &'static str { /// True for a bare `YYYY-MM-DD` value (no time-of-day). Such a value applied to /// a timestamp column must match the whole calendar day `[date, date+1)` rather -/// than the midnight instant — otherwise `= '2026-07-06'` compiles to +/// than the midnight instant - otherwise `= '2026-07-06'` compiles to /// `= '2026-07-06 00:00:00'` and never matches a real timestamp (the filter bug). fn is_date_only(s: &str) -> bool { let b = s.as_bytes(); @@ -1172,7 +1225,7 @@ fn build_filter_condition( let from = parts.next().unwrap_or("").trim().to_string(); let to = parts.next().unwrap_or("").trim().to_string(); match (from.is_empty(), to.is_empty()) { - (true, true) => { /* both empty — nothing to filter on, skip */ } + (true, true) => { /* both empty - nothing to filter on, skip */ } (false, true) => { // only lower bound set let p1 = builder.push_bind(from); @@ -1411,7 +1464,7 @@ pub async fn get_table_rows( // When false, skip the catalog metadata queries (enums/nullable/pk/fk) and // return only rows + column types. Used for repeat fetches of the same table // (pagination, sort, filter, live refresh) where that metadata is unchanged - // and the frontend already holds it — cutting several round-trips per fetch. + // and the frontend already holds it - cutting several round-trips per fetch. include_meta: bool, // When false, skip the row count entirely and return total = -1 (unknown). // The frontend then fetches the count in the background via `count_table_rows` @@ -1529,7 +1582,7 @@ pub async fn get_table_rows( // fall back to OFFSET, which is always correct. let keyset_ok = match keyset_ok { Some(k) => { - let flags = fetch_table_column_flags(&pool, &schema, &table).await?; + let flags = cached_column_flags(&pool, &schema, &table).await?; // Require the column to be known AND non-nullable. Unknown column => // don't fast-path (stay safe). match flags.get(&k.column).map(|f| f.nullable) { @@ -1598,7 +1651,7 @@ pub async fn get_table_rows( }; // For an unfiltered listing, COUNT(*) on a large table is a full sequential - // scan that can take seconds — that's the "pause" when opening a big table. + // scan that can take seconds - that's the "pause" when opening a big table. // Use the planner's row estimate (pg_class.reltuples) instead, which is // instant, and only fall back to an exact COUNT when the table is small // (estimate < threshold, where an exact count is sub-millisecond) or has @@ -1614,7 +1667,7 @@ pub async fn get_table_rows( rows_res = data_query.fetch_all(&pool).await; total = -1; } else if where_clause.sql.is_empty() { - // Estimate and data fetch are independent — run them together so the + // Estimate and data fetch are independent - run them together so the // planner estimate adds no extra round-trip in series. let estimate_query = sqlx::query_scalar::<_, i64>( "SELECT reltuples::bigint FROM pg_class WHERE oid = $1::regclass", @@ -1632,7 +1685,7 @@ pub async fn get_table_rows( .map_err(|e| format!("Failed to count rows: {e}"))?, }; } else { - // COUNT and data SELECT are independent — run both in parallel. + // COUNT and data SELECT are independent - run both in parallel. let (total_result, page_res) = tokio::join!( count_query.fetch_one(&pool), data_query.fetch_all(&pool), @@ -1645,7 +1698,7 @@ pub async fn get_table_rows( // friends) fails the whole `SELECT *`. Re-read the page with those columns // projected as text so the table opens with every other column intact, // rather than showing an error where the grid should be. Only reached on a - // table that actually has one — the common path never runs these queries. + // table that actually has one - the common path never runs these queries. let mut text_only: Vec = Vec::new(); let rows = match rows_res { Ok(rows) => rows, @@ -1676,7 +1729,7 @@ pub async fn get_table_rows( .map(|c| ColumnInfo::new(c.name(), pg_type_label(c.type_info().name()))) .collect() } else if !include_meta { - // Repeat fetch with an empty result page — frontend keeps its columns. + // Repeat fetch with an empty result page - frontend keeps its columns. Vec::new() } else { let meta = sqlx::query( @@ -1721,14 +1774,14 @@ pub async fn get_table_rows( .iter() .map(|row| (0..row.len()).map(|i| cell_to_json(row, i)).collect()) .collect(); - // Backward keyset page was fetched in reverse order — flip it back to the + // Backward keyset page was fetched in reverse order - flip it back to the // table's display order. if keyset_reverse { data.reverse(); } // Catalog metadata (enums/nullable/pk/fk) is stable per table, so only fetch - // it on the first load — repeat fetches (pagination/sort/filter/live) reuse + // it on the first load - repeat fetches (pagination/sort/filter/live) reuse // what the frontend already holds, saving four round-trips and connections. // Metadata was fired off above (concurrent with the row/count fetch); collect // it now that the columns are built so enum/nullable info can be applied. @@ -1765,7 +1818,7 @@ pub async fn get_table_rows( /// return rows immediately (include_count = false) while the UI fills the total /// in asynchronously. Mirrors the count logic in `get_table_rows`: planner /// estimate for a large *unfiltered* table (instant), exact `COUNT(*)` otherwise -/// (the WHERE clause bounds the scan). Non-Postgres engines return -1 — their +/// (the WHERE clause bounds the scan). Non-Postgres engines return -1 - their /// `get_table_rows` already carries a real total, so the UI keeps that. pub async fn count_table_rows( state: State<'_, DbState>, @@ -1817,7 +1870,7 @@ pub async fn count_table_rows( } // This runs in the background, so it must not inherit the session's - // 10-minute statement_timeout (see open_pg) — a filtered COUNT(*) on a big + // 10-minute statement_timeout (see open_pg) - a filtered COUNT(*) on a big // table would pin a pooled connection for minutes, the exact starvation the // sidebar counts guard against (schema.rs). Budget is larger than the // sidebar's 4s since this count is user-visible in the grid. SET LOCAL @@ -2425,15 +2478,22 @@ pub async fn execute_ddl(state: State<'_, DbState>, sql: String) -> Result<(), S } } -pub async fn execute_sql(state: State<'_, DbState>, sql: String) -> Result { +pub async fn execute_sql( + state: State<'_, DbState>, + sql: String, + query_id: Option, +) -> Result { let sql_str = sql.trim().to_string(); if sql_str.is_empty() { return Err("Query is empty".into()); } let conn = require_conn(&state)?; let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<()>(); - *state.cancel_tx.lock().map_err(|e| e.to_string())? = Some(cancel_tx); - // Kept for the query log — the per-dialect executor runs `sql_str` (which is + // Keyed by the caller's id so several editor tabs can run at once and each + // one's Cancel reaches its own query. + let cancel_key = query_id.unwrap_or_else(|| "default".to_string()); + super::connection::register_cancel(&state, &cancel_key, cancel_tx); + // Kept for the query log - the per-dialect executor runs `sql_str` (which is // moved into the cancellable branch below), so stamp the executed SQL onto // the result after the match. let sql_out = sql_str.clone(); @@ -2462,6 +2522,7 @@ pub async fn execute_sql(state: State<'_, DbState>, sql: String) -> Result Err("Query cancelled".to_string()), }, }; + super::connection::unregister_cancel(&state, &cancel_key); if let Ok(r) = &mut result { r.sql = sql_out; } @@ -2519,7 +2580,7 @@ pub async fn execute_sql_on_conn( } /// Row cap for ad-hoc SQL execution. Deliberately set beyond any realistic -/// result (effectively uncapped — the user asked for these rows); it remains +/// result (effectively uncapped - the user asked for these rows); it remains /// only as a last-resort circuit breaker whose "add a LIMIT" message tells the /// user what happened. const EXECUTE_SQL_MAX_ROWS: usize = 1_000_000_000; @@ -2539,7 +2600,7 @@ async fn execute_sql_pg( let started = std::time::Instant::now(); let query_ms = || started.elapsed().as_millis() as u64; - // Split into individual statements — the extended query protocol rejects multi-statement input. + // Split into individual statements - the extended query protocol rejects multi-statement input. // Use the literal/comment/dollar-quote-aware splitter so `;` inside strings, // `--`/`/* */` comments, and `$$…$$` bodies don't cut a statement in half. let split = split_sql_statements(sql); @@ -2588,11 +2649,11 @@ async fn execute_sql_pg( for (i, stmt) in stmts.iter().enumerate() { if i == last_idx && is_row_returning_sql(stmt) { - // Last statement returns rows — stream and return. `executed` is the + // Last statement returns rows - stream and return. `executed` is the // statement actually run: the user's, or a text-projecting rewrite of // it after a column turned out to have no binary output. Each row is // converted to JSON as it streams in and the driver row dropped - // immediately — retaining the full Vec alongside the JSON rows + // immediately - retaining the full Vec alongside the JSON rows // would double peak memory on a large result. let mut executed = stmt.to_string(); let mut columns: Vec = Vec::new(); @@ -2661,7 +2722,7 @@ async fn execute_sql_pg( row_count: Some(row_count), message: if capped { Some(format!( - "Result capped at {EXECUTE_SQL_MAX_ROWS} rows — add a LIMIT clause to fetch a specific range." + "Result capped at {EXECUTE_SQL_MAX_ROWS} rows - add a LIMIT clause to fetch a specific range." )) } else { None @@ -2801,7 +2862,7 @@ fn split_sql_statements(sql: &str) -> Vec { } async fn execute_sql_multi_pg(pool: &sqlx::PgPool, stmts: &[String]) -> Result, String> { - // Single statement — delegate to existing path (avoids code duplication) + // Single statement - delegate to existing path (avoids code duplication) if stmts.len() == 1 { return execute_sql_pg(pool, &stmts[0], None).await.map(|r| vec![r]); } @@ -2893,16 +2954,22 @@ async fn execute_sql_multi_pg(pool: &sqlx::PgPool, stmts: &[String]) -> Result, sql: String) -> Result, String> { +pub async fn execute_sql_multi( + state: State<'_, DbState>, + sql: String, + query_id: Option, +) -> Result, String> { let sql_str = sql.trim().to_string(); if sql_str.is_empty() { return Err("Query is empty".into()); } let conn = require_conn(&state)?; let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<()>(); - *state.cancel_tx.lock().map_err(|e| e.to_string())? = Some(cancel_tx); + let cancel_key = query_id.unwrap_or_else(|| "default".to_string()); + super::connection::register_cancel(&state, &cancel_key, cancel_tx); let stmts = split_sql_statements(&sql_str); if stmts.is_empty() { + super::connection::unregister_cancel(&state, &cancel_key); return Err("Query is empty".into()); } let result = tokio::select! { @@ -2913,7 +2980,7 @@ pub async fn execute_sql_multi(state: State<'_, DbState>, sql: String) -> Result } // Other engines: execute sequentially, one result set per statement. // Cancellation happens at the outer select! (the future is dropped), - // so per-statement executors get no cancel receiver — same as the + // so per-statement executors get no cancel receiver - same as the // other diff/multi callers. let multi = stmts.len() > 1; let mut results: Vec = Vec::with_capacity(stmts.len()); @@ -2939,6 +3006,7 @@ pub async fn execute_sql_multi(state: State<'_, DbState>, sql: String) -> Result } => r, _ = async { let _ = cancel_rx.await; } => Err("Query cancelled".to_string()), }; + super::connection::unregister_cancel(&state, &cancel_key); result } @@ -2976,7 +3044,7 @@ async fn get_table_rows_remote( sort_direction: Option, filters: Option>, // When false, skip the PRAGMA round-trips (each is a full HTTPS request to - // Cloudflare/Turso) — the frontend already holds PK/FK metadata on repeat + // Cloudflare/Turso) - the frontend already holds PK/FK metadata on repeat // fetches (pagination/sort/filter/live) and keeps its cached values. include_meta: bool, nulls_order: Option, @@ -3052,7 +3120,7 @@ async fn get_table_rows_remote( }; // ── WHERE / ORDER build ─────────────────────────────────────────────────── - // Each entry: (conjunct — None for first, Some("AND"/"OR") for rest, condition SQL) + // Each entry: (conjunct - None for first, Some("AND"/"OR") for rest, condition SQL) let mut cond_parts: Vec<(Option<&'static str>, String)> = vec![]; let mut params: Vec = vec![]; @@ -3125,7 +3193,7 @@ async fn get_table_rows_remote( format!("ORDER BY \"{}\" {dir} {nulls}", col.replace('"', "\"\"")) } else { String::new() }; - // ── Phase 2: COUNT + rows — run concurrently ───────────────────────────── + // ── Phase 2: COUNT + rows - run concurrently ───────────────────────────── let count_sql = format!("SELECT COUNT(*) FROM {tq} {where_clause}"); let rows_sql = format!("SELECT * FROM {tq} {where_clause} {order_clause} LIMIT ? OFFSET ?"); let mut row_params = params.clone(); @@ -3288,7 +3356,7 @@ async fn delete_table_rows_remote( let tq = format!("\"{}\"", table.replace('"', "\"\"")); - // Single-column PK: batch into `IN (…)` chunks — each request here is a full + // Single-column PK: batch into `IN (…)` chunks - each request here is a full // HTTPS round-trip to Cloudflare/Turso, so deleting N selected rows must not // cost N requests. Composite PKs keep the per-row loop. if pk.len() == 1 { @@ -3376,7 +3444,7 @@ pub async fn get_column_stats( let is_numeric = !is_array && ["int","numeric","decimal","real","double","float","money","serial"] .iter().any(|t| data_type.contains(t)); - // For array columns skip min/max/distinct/avg — PostgreSQL would return + // For array columns skip min/max/distinct/avg - PostgreSQL would return // array-literal strings like "{val1,val2}" which are meaningless here. if is_array { let count_sql = format!("SELECT COUNT(*) AS total, COUNT(*) - COUNT({col}) AS null_count FROM {tq}"); @@ -3454,7 +3522,7 @@ async fn column_stats_generic( }; (t, quote_bracket(&column)) } - // SQLite, D1, LibSQL, DuckDB — double-quoted, single-namespace (no + // SQLite, D1, LibSQL, DuckDB - double-quoted, single-namespace (no // PostgreSQL-style schema qualifier). _ => (quote_double(&table), quote_double(&column)), }; @@ -3499,7 +3567,7 @@ async fn column_stats_generic( /// Run a bounded read-only aggregate against a borrowed active connection (any /// engine). Mirrors `execute_sql`'s per-driver dispatch minus the cancellation -/// plumbing — used by `column_stats_generic`, which needs to borrow the +/// plumbing - used by `column_stats_generic`, which needs to borrow the /// connection (so it can retry with a fallback query) rather than move `State`. async fn dispatch_stats_sql(conn: &ActiveConnection, sql: &str) -> Result { match conn { @@ -3515,7 +3583,7 @@ async fn dispatch_stats_sql(conn: &ActiveConnection, sql: &str) -> Result Result<::redis::aio::MultiplexedConnection, .map_err(|e| format!("Redis connection failed: {e}")) } -/// Connectivity/credential check — issues a `PING`. +/// Connectivity/credential check - issues a `PING`. pub async fn ping(cfg: &RedisConfig) -> Result<(), String> { let mut conn = open(cfg).await?; ::redis::cmd("PING") @@ -89,7 +89,7 @@ pub struct ScanReply { pub keys: Vec, } -/// Non-blocking incremental key iteration via `SCAN` — unlike `KEYS *`, which +/// Non-blocking incremental key iteration via `SCAN` - unlike `KEYS *`, which /// blocks the whole server on a large keyspace. Binary/control bytes in key /// names stay legible via `bytes_to_display`. Pass the returned `cursor` back to /// fetch the next page; `"0"` means iteration is complete. diff --git a/src-tauri/src/db/schema.rs b/src-tauri/src/db/schema.rs index 87a6d54f..52d470a9 100644 --- a/src-tauri/src/db/schema.rs +++ b/src-tauri/src/db/schema.rs @@ -53,7 +53,7 @@ pub struct TableColumnStructure { } pub(crate) fn validate_ident(name: &str) -> Result<(), String> { - // Disallow `.` — every caller passes a single schema/table/column component + // Disallow `.` - every caller passes a single schema/table/column component // (never a `schema.table` string), so allowing `.` would let "a.b" collapse // into one literal quoted identifier and target the wrong object. if name.is_empty() @@ -140,7 +140,7 @@ const LIST_INDEXES_SQL: &str = r#" /// `COUNT(*)` on a large production table is a full scan, so a handful of them /// pin most of the pool for minutes. Interactive queries then can't get a /// connection and fail with "pool timed out while waiting for an open -/// connection" — the app starving itself, which reads as the connection being +/// connection" - the app starving itself, which reads as the connection being /// broken. A count that can't finish in this budget is not worth a sidebar /// number; it degrades to "unknown" instead. const COUNT_STATEMENT_TIMEOUT: &str = "4s"; @@ -148,7 +148,7 @@ const COUNT_STATEMENT_TIMEOUT: &str = "4s"; const COUNT_STATEMENT_TIMEOUT_MS: u64 = 4_000; async fn exact_row_count(pool: &PgPool, schema: &str, table: &str) -> Result { - // Names arrive from the frontend in the lazy-count pass — escape embedded + // Names arrive from the frontend in the lazy-count pass - escape embedded // quotes so an unusual (or hostile) identifier can't break out of the quoting. let q = |id: &str| format!("\"{}\"", id.replace('"', "\"\"")); let sql = format!("SELECT COUNT(*)::bigint FROM {}.{}", q(schema), q(table)); @@ -172,7 +172,7 @@ async fn exact_row_count(pool: &PgPool, schema: &str, table: &str) -> Result Result, S }) .collect(); - // Views and foreign tables never get a COUNT(*) — report 0 immediately. + // Views and foreign tables never get a COUNT(*) - report 0 immediately. // Real tables keep -1 (unknown): returning right away lets the sidebar // render instantly, and the UI resolves exact counts in a background // `table_row_counts` pass instead of blocking the list on N COUNT(*)s. @@ -653,7 +653,7 @@ async fn list_tables_mysql(pool: &MySqlPool, schema: &str) -> Result can silently fail on non-null columns. // For InnoDB this is only an estimate and is frequently 0 (or far off) - // until ANALYZE TABLE runs — so a 0 estimate is treated as unknown (-1) + // until ANALYZE TABLE runs - so a 0 estimate is treated as unknown (-1) // and resolved with an exact COUNT(*) below. let est: i64 = r.try_get::(2).unwrap_or(0) as i64; let kind = if ty == "VIEW" { "view" } else { "table" }.to_string(); @@ -663,7 +663,7 @@ async fn list_tables_mysql(pool: &MySqlPool, schema: &str) -> Result, schema: String) -> Result list_sequences_pg(&pool, &schema).await } // Sequences are a PostgreSQL concept. MySQL uses AUTO_INCREMENT and SQLite - // uses sqlite_sequence rowids — neither exposes first-class sequence objects, + // uses sqlite_sequence rowids - neither exposes first-class sequence objects, // so there is nothing to list for those engines (documented N/A per todo P2.12). _ => Ok(vec![]), } @@ -1861,7 +1861,7 @@ fn incoming_fks_from_batched( chunk: &[String], target: &str, ) -> Option> { - // A successful empty result means "no FKs in this chunk" — it must not fall + // A successful empty result means "no FKs in this chunk" - it must not fall // back: D1 derives column names from row keys, so zero rows = zero columns. if res.rows.is_empty() { return Some(Vec::new()); @@ -1916,7 +1916,7 @@ async fn get_incoming_fks_d1(cfg: &super::connection::D1Config, table: &str) -> .and_then(|res| incoming_fks_from_batched(&res, chunk, table)); match batched { Some(mut fks) => result.append(&mut fks), - // Table-valued pragma unavailable — per-table PRAGMA fallback. + // Table-valued pragma unavailable - per-table PRAGMA fallback. None => { for from_table in chunk { let tq = format!("\"{}\"", from_table.replace('"', "\"\"")); @@ -1947,7 +1947,7 @@ async fn get_incoming_fks_libsql(cfg: &super::connection::LibSqlConfig, table: & .and_then(|res| incoming_fks_from_batched(&res, chunk, table)); match batched { Some(mut fks) => result.append(&mut fks), - // Table-valued pragma unavailable — per-table PRAGMA fallback. + // Table-valued pragma unavailable - per-table PRAGMA fallback. None => { for from_table in chunk { let tq = format!("\"{}\"", from_table.replace('"', "\"\"")); diff --git a/src-tauri/src/db/sqlite.rs b/src-tauri/src/db/sqlite.rs index 655390b3..04482332 100644 --- a/src-tauri/src/db/sqlite.rs +++ b/src-tauri/src/db/sqlite.rs @@ -18,7 +18,7 @@ pub fn cell_to_json(row: &sqlx::sqlite::SqliteRow, idx: usize) -> Value { } if let Ok(v) = row.try_get::, _>(idx) { return match v { - // Cap oversized text/JSON — a multi-MB cell shipped whole freezes + // Cap oversized text/JSON - a multi-MB cell shipped whole freezes // the webview (see sql_util::CELL_VALUE_CAP). Some(s) if s.len() > super::sql_util::CELL_VALUE_CAP => { super::sql_util::oversize_cell(&col_type(row, idx), s.len(), s.as_bytes()) @@ -29,7 +29,7 @@ pub fn cell_to_json(row: &sqlx::sqlite::SqliteRow, idx: usize) -> Value { } if let Ok(v) = row.try_get::>, _>(idx) { return match v { - // Hex display doubles the byte count — cap blobs the same way so a + // Hex display doubles the byte count - cap blobs the same way so a // file stored in a blob column can't ship megabytes of hex. Some(b) if b.len() * 2 > super::sql_util::CELL_VALUE_CAP => { let head: String = b @@ -115,7 +115,7 @@ pub async fn execute_sql(pool: &SqlitePool, sql: &str) -> Result alongside the JSON + // immediately - retaining the full Vec alongside the JSON // rows would double peak memory on a large result. let mut columns: Vec = Vec::new(); let mut data: Vec> = Vec::new(); @@ -153,7 +153,7 @@ pub async fn execute_sql(pool: &SqlitePool, sql: &str) -> Result, sort_direction: Option, filters: Option>, - // When false, skip the primary-key/foreign-key PRAGMA round-trips — the + // When false, skip the primary-key/foreign-key PRAGMA round-trips - the // frontend already holds them for repeat fetches (pagination/sort/filter/live). include_meta: bool, // Null placement ("first"/"last"); None keeps the historical NULLS LAST default. @@ -249,7 +249,7 @@ pub async fn get_table_rows( .collect(); // Build WHERE clause (using ? placeholders) - // Each entry: (conjunct — None for first, Some("AND"/"OR") for rest, condition SQL, binds) + // Each entry: (conjunct - None for first, Some("AND"/"OR") for rest, condition SQL, binds) let mut cond_parts: Vec<(Option<&'static str>, String)> = vec![]; let mut binds: Vec = vec![]; @@ -322,7 +322,7 @@ pub async fn get_table_rows( out }; - // ORDER BY — an explicit NULLS clause keeps ordering consistent when a column + // ORDER BY - an explicit NULLS clause keeps ordering consistent when a column // has NULLs; defaults to NULLS LAST when the caller doesn't specify placement. let order_clause = if let Some(col) = sort_column { let dir = sort_direction.as_deref().unwrap_or("asc").to_ascii_uppercase(); @@ -398,7 +398,7 @@ pub async fn get_table_rows( .map(|r| (0..columns.len()).map(|i| cell_to_json(r, i)).collect()) .collect(); - // Two extra PRAGMA statements serialized on the single-connection pool — + // Two extra PRAGMA statements serialized on the single-connection pool - // skip them on metadata-skipping fetches; the frontend keeps its cached values. let (primary_key, foreign_keys) = if include_meta { ( @@ -441,7 +441,7 @@ pub fn build_d1_filter(qcol: &str, op: &str, val: &str) -> (String, Vec (String, Vec) { match op { - // Comparison operators: SQLite is dynamically typed — no cast needed. + // Comparison operators: SQLite is dynamically typed - no cast needed. // Direct comparison works correctly for integers, reals, and text alike. "eq" => (format!("{qcol} = ?"), vec![val.to_string()]), "neq" => (format!("{qcol} != ?"), vec![val.to_string()]), diff --git a/src-tauri/src/db/ssh_tunnel.rs b/src-tauri/src/db/ssh_tunnel.rs index 2e93ccbe..2de59492 100644 --- a/src-tauri/src/db/ssh_tunnel.rs +++ b/src-tauri/src/db/ssh_tunnel.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use std::time::Duration; use tokio::process::{Child, Command}; -/// SSH tunnel configuration — serialized alongside saved connections. +/// SSH tunnel configuration - serialized alongside saved connections. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SshConfig { @@ -36,7 +36,7 @@ impl Drop for SshTunnel { } fn find_free_port() -> Result { - // Bind to :0 — the OS assigns an ephemeral port — then release the listener. + // Bind to :0 - the OS assigns an ephemeral port - then release the listener. let listener = std::net::TcpListener::bind("127.0.0.1:0") .map_err(|e| format!("Cannot find a free local port: {e}"))?; let port = listener @@ -103,7 +103,7 @@ impl SshTunnel { } if tokio::time::Instant::now() >= deadline { return Err(format!( - "SSH tunnel to {}@{} timed out — \ + "SSH tunnel to {}@{} timed out - \ check credentials, host reachability, and that port {} is not blocked", ssh.username, ssh.host, ssh.port )); diff --git a/src-tauri/src/docker.rs b/src-tauri/src/docker.rs index 715db561..a34a7b4a 100644 --- a/src-tauri/src/docker.rs +++ b/src-tauri/src/docker.rs @@ -275,7 +275,7 @@ use serde_json::Value; #[derive(Debug, Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct DockerDatabase { - /// Container name — stable across restarts, unlike the id. + /// Container name - stable across restarts, unlike the id. pub name: String, pub container_id: String, pub image: String, @@ -335,7 +335,7 @@ fn engine_for_image(image: &str) -> Option<&'static str> { None } -/// Falls back to what the container listens on when the image name is useless — +/// Falls back to what the container listens on when the image name is useless - /// a locally-built tag, or an image id like `cc4c61127125`. fn engine_for_ports(ports: &Value) -> Option<&'static str> { let map = ports.as_object()?; @@ -367,7 +367,7 @@ fn env_map(env: &Value) -> std::collections::HashMap { } /// The credentials the container was started with, straight out of its -/// environment — the same values the compose file already spells out. +/// environment - the same values the compose file already spells out. fn credentials(engine: &str, env: &std::collections::HashMap) -> (String, String, String) { let get = |keys: &[&str]| keys.iter().find_map(|k| env.get(*k)).cloned(); match engine { @@ -412,7 +412,7 @@ fn credentials(engine: &str, env: &std::collections::HashMap) -> } /// The host port this container's database is reachable on. Prefers the binding -/// for the engine's own port — a Postgres container that also publishes an +/// for the engine's own port - a Postgres container that also publishes an /// exporter on 9187 must not hand back the exporter. fn published_port(ports: &Value, engine: &str) -> Option { let map = ports.as_object()?; @@ -465,7 +465,7 @@ fn database_from_inspect(c: &Value) -> Option { let port = published_port(&ports, &engine); let reason = port.is_none().then(|| { format!( - "Not reachable from the host — this container publishes no port (run it with -p {}:{}).", + "Not reachable from the host - this container publishes no port (run it with -p {}:{}).", engine_port(&engine), engine_port(&engine) ) @@ -495,7 +495,7 @@ fn database_from_inspect(c: &Value) -> Option { } /// Every database container running on this machine, with the credentials it was -/// started with. Docker missing or not running is not an error — it just means +/// started with. Docker missing or not running is not an error - it just means /// there is nothing to offer, and the connection screen stays quiet about it. #[tauri::command] pub async fn scan_docker_databases() -> Result, String> { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d0594b6c..e0daea3c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,4 @@ +mod app_lock; mod cloudflare; mod commands; mod copilot; @@ -6,6 +7,7 @@ mod docker; mod license; mod mcp; mod omniroute; +mod plugins; mod metrics; mod proc; mod providers; @@ -22,11 +24,11 @@ use tauri::{ }; // 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 +// frame - a cold start, a reload, or any moment the UI is mid-repaint. The // default is white, which flashes hard against the (mostly dark) app themes, so // match the `--background` token of the base light/dark theme in // src/lib/themes/app-themes.css: oklch(0.132 0 0) and oklch(0.975 0 0). -// Only the light/dark end has to be right — the frontend paints the exact +// Only the light/dark end has to be right - the frontend paints the exact // per-theme background as soon as it has a frame. const DARK_SURFACE: tauri::window::Color = tauri::window::Color(8, 8, 8, 255); const LIGHT_SURFACE: tauri::window::Color = tauri::window::Color(247, 247, 247, 255); @@ -41,7 +43,7 @@ fn surface_for_theme(theme: tauri::Theme) -> tauri::window::Color { /// Paint the webview's own backdrop on macOS. /// /// `WebviewWindow::set_background_color` is documented as "not implemented for -/// the webview layer" on macOS, and it is the WKWebView — not the NSWindow — +/// the webview layer" on macOS, and it is the WKWebView - not the NSWindow - /// that owns the white rectangle the user sees before the page paints. The /// equivalent knob there is `underPageBackgroundColor`. #[cfg(target_os = "macos")] @@ -91,7 +93,7 @@ fn tray_icon_for_theme( #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - // Linux WebKitGTK rendering fix — set before any threads spawn. + // Linux WebKitGTK rendering fix - set before any threads spawn. #[cfg(target_os = "linux")] // SAFETY: called before any threads are spawned. unsafe { @@ -99,21 +101,21 @@ pub fn run() { // text as GPU textures that get bilinearly sampled at fractional pixel // offsets during scroll/zoom, producing the characteristic blur on Linux. // Disabling it falls back to a Cairo/FreeType software path that stays crisp. - // This is the only verified safe WebKitGTK rendering env var — others like + // This is the only verified safe WebKitGTK rendering env var - others like // WEBKIT_USE_LEGACY_TEXT_RENDERER are not real and can trigger SIGTRAP crashes. std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); - // GDK_SCALE is intentionally NOT forced here — overriding it breaks HiDPI + // GDK_SCALE is intentionally NOT forced here - overriding it breaks HiDPI // setups (2× displays) and can cause rendering panics on Wayland compositors. } // Set a human-readable process title so the app shows as "stroke" in - // htop / ps / /proc — makes it easy to identify among WebKit helper processes. + // htop / ps / /proc - makes it easy to identify among WebKit helper processes. let _ = metrics::set_process_title("stroke".into()); - // Create the shared connection Arc — both DbState and McpState point to the same lock. + // Create the shared connection Arc - both DbState and McpState point to the same lock. let db_conn: Arc>> = Arc::new(Mutex::new(None)); let db_state = DbState { conn: Arc::clone(&db_conn), - cancel_tx: Arc::new(std::sync::Mutex::new(None)), + cancels: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())), }; let db_conn_for_setup = Arc::clone(&db_conn); let mcp_state = McpState::new(db_conn); @@ -149,8 +151,8 @@ pub fn run() { .resizable(true) .maximized(true) // 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 + // known to the frontend (localStorage), so start on the dark base - + // 11 of the 16 themes are dark - and correct to light right after // build(), which still runs before the event loop composites. .background_color(DARK_SURFACE); @@ -182,7 +184,7 @@ pub fn run() { .devtools(cfg!(debug_assertions)) // The app implements its own CSS-based zoom; disable Tauri's injected // zoom polyfill. On macOS/Linux that polyfill attaches a `mousewheel` - // (legacy event) listener that calls set_webview_zoom on ctrl+scroll — + // (legacy event) listener that calls set_webview_zoom on ctrl+scroll - // a stray trackpad pinch near a column resize handle would then page-zoom // the whole webview (devicePixelRatio jumps, canvas renders blurry). .zoom_hotkeys_enabled(false) @@ -221,7 +223,7 @@ pub fn run() { // Install the standard macOS application menu. WKWebView text // fields rely on the app menu's Edit items for the standard editing - // shortcuts — ⌘Z undo, ⌘⇧Z redo, ⌘X/⌘C/⌘V, ⌘A select-all, and + // shortcuts - ⌘Z undo, ⌘⇧Z redo, ⌘X/⌘C/⌘V, ⌘A select-all, and // ⌥⌫ / ⌘⌫ word/line delete. Install the standard menu so native // text editing works everywhere (inputs, textareas, cell editors). let menu = tauri::menu::Menu::default(app.handle())?; @@ -231,13 +233,13 @@ pub fn run() { // Logging is installed in release too, not just dev. Connection // problems get reported from machines we can't attach a debugger to (a // user's Windows PC behind a VPN), and the connect path logs which - // phase was slow — name lookup vs TCP vs TLS+auth. Without a release + // phase was slow - name lookup vs TCP vs TLS+auth. Without a release // log there is nothing to go on but "it's slow". // // The plugin's default targets are already [Stdout, LogDir], so dev // gets the terminal and release gets the rotating file in the platform // log dir (%LOCALAPPDATA%\\logs on Windows, ~/.local/share//logs - // on Linux, ~/Library/Logs/ on macOS). Don't add a target here — + // on Linux, ~/Library/Logs/ on macOS). Don't add a target here - // `target()` appends to that list and every line would be logged twice. // Info level keeps this to phase timings and failures, not query traffic. app.handle().plugin( @@ -318,6 +320,11 @@ pub fn run() { Ok(()) }) .invoke_handler(tauri::generate_handler![ + plugins::plugins_list, + plugins::plugins_read_source, + plugins::plugins_install, + plugins::plugins_uninstall, + plugins::plugins_dir, commands::ai_fetch, commands::ai_fetch_cancel, commands::ai_list_models, @@ -406,6 +413,11 @@ pub fn run() { docker::scan_docker_databases, db::local_scan::scan_local_studios, db::local_scan::scan_machine_databases, + app_lock::app_lock_status, + app_lock::app_lock_set_pin, + app_lock::app_lock_verify, + app_lock::app_lock_disable, + app_lock::app_lock_set_prefs, secrets::ai_store_key, secrets::ai_load_key, secrets::ai_delete_key, @@ -439,6 +451,7 @@ pub fn run() { commands::enable_autostart, commands::disable_autostart, commands::get_autostart_status, + commands::advisor_scan, commands::cancel_query, #[cfg(debug_assertions)] commands::debug_set_trial_days_ago, @@ -448,7 +461,7 @@ 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 + // 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. if let tauri::RunEvent::Exit = event { diff --git a/src-tauri/src/license.rs b/src-tauri/src/license.rs index 9f2f4750..dad38f2c 100644 --- a/src-tauri/src/license.rs +++ b/src-tauri/src/license.rs @@ -1,4 +1,4 @@ -/// License system — Ed25519 signed keys + 5-day trial. +/// License system - Ed25519 signed keys + 5-day trial. /// /// Key format: . /// Message: "v1:{email}:{plan}:{iat}:{exp}" (UTF-8, exp=0 means lifetime) @@ -7,12 +7,12 @@ /// /license.json /// /// Trial is tracked in TWO locations (8 LE bytes = u64 unix timestamp of first launch): -/// /.trial — removed on a full uninstall -/// /.stroke/trial — survives uninstall, so reinstalling does NOT +/// /.trial - removed on a full uninstall +/// /.stroke/trial - survives uninstall, so reinstalling does NOT /// grant a fresh trial. The earliest timestamp /// across both wins, and both are repaired on read. /// (For tamper-proof enforcement across full disk wipes, register the trial -/// start server-side keyed by device_id — the API scaffolding already exists.) +/// start server-side keyed by device_id - the API scaffolding already exists.) /// /// ------------------------------------------------------------------- /// SETUP: run `node scripts/license-tool.mjs keygen` once. @@ -205,7 +205,7 @@ fn now_secs() -> u64 { } /// Reads the trial start across all mirror locations and returns `days_remaining` -/// (negative = expired). The EARLIEST timestamp found wins — so deleting one copy +/// (negative = expired). The EARLIEST timestamp found wins - so deleting one copy /// (e.g. uninstalling, which wipes app-data) can't grant a fresh trial while the /// home-dir copy survives. Every location is (re)written with the winning start so /// a missing/newer copy is repaired on each launch. @@ -222,7 +222,7 @@ pub fn trial_days_remaining(data_dir: &Path) -> i64 { } } - // First launch on this device (or every copy was removed) — start now. + // First launch on this device (or every copy was removed) - start now. let start_secs = earliest.unwrap_or_else(now_secs); // Propagate/repair the winning start to every location. @@ -254,7 +254,7 @@ fn local_trial_start(data_dir: &Path) -> Option { /// Phone home to sync the trial clock with the server (earliest-wins). The /// server records the start the first time it sees this device and always -/// returns the earliest known start — so a reinstall or disk wipe can't hand +/// returns the earliest known start - so a reinstall or disk wipe can't hand /// out a fresh trial. On success the authoritative start is written to every /// local mirror so the next local check reflects it. No-op when offline. pub async fn reconcile_trial(data_dir: &Path) { @@ -269,7 +269,7 @@ pub async fn reconcile_trial(data_dir: &Path) { } } -/// DEBUG ONLY — write a trial start timestamp N days in the past so the UI +/// DEBUG ONLY - write a trial start timestamp N days in the past so the UI /// shows "N days elapsed" without waiting for real time to pass. #[cfg(debug_assertions)] pub fn debug_set_trial_days_ago(data_dir: &Path, days_ago: u64) -> Result<(), String> { @@ -283,7 +283,7 @@ pub fn debug_set_trial_days_ago(data_dir: &Path, days_ago: u64) -> Result<(), St Ok(()) } -/// DEBUG ONLY — delete every trial mirror so the next check starts a fresh trial. +/// DEBUG ONLY - delete every trial mirror so the next check starts a fresh trial. #[cfg(debug_assertions)] pub fn debug_reset_trial(data_dir: &Path) -> Result<(), String> { for p in trial_paths(data_dir) { @@ -304,7 +304,7 @@ pub struct ParsedKey { pub expires_at: Option, } -/// Return the hex SHA-256 of a key string — used to populate REVOKED_KEY_HASHES. +/// Return the hex SHA-256 of a key string - used to populate REVOKED_KEY_HASHES. pub fn key_hash(key: &str) -> String { let mut h = Sha256::new(); h.update(key.as_bytes()); @@ -328,7 +328,7 @@ pub fn verify_key(key: &str) -> Result { // Reject placeholder key if pk_bytes == [0u8; 32] { - return Err("Public key not configured — run `node scripts/license-tool.mjs keygen` and update PUBLIC_KEY_HEX in license.rs".to_string()); + return Err("Public key not configured - run `node scripts/license-tool.mjs keygen` and update PUBLIC_KEY_HEX in license.rs".to_string()); } let verifying_key = @@ -355,7 +355,7 @@ pub fn verify_key(key: &str) -> Result { // Verify Ed25519 signature verifying_key .verify(&msg_bytes, &signature) - .map_err(|_| "invalid license signature — key may be forged or corrupted".to_string())?; + .map_err(|_| "invalid license signature - key may be forged or corrupted".to_string())?; // Parse message "v1:{email}:{plan}:{iat}:{exp}" let msg_str = String::from_utf8(msg_bytes) @@ -438,7 +438,7 @@ fn http_client() -> &'static reqwest::Client { }) } -/// POST /api/license/activate — registers this device with the server. +/// POST /api/license/activate - registers this device with the server. /// Returns the server JSON on success, or an error string. /// On network failure returns Ok(None) so the caller can apply an offline grace. pub async fn api_activate( @@ -472,7 +472,7 @@ pub async fn api_activate( } } -/// POST /api/license/check — phone-home to detect server-side revocation. +/// POST /api/license/check - phone-home to detect server-side revocation. /// Returns `true` if still valid, `false` if revoked/invalid. /// Returns `None` on network error (caller should skip and retry later). pub async fn api_check(key: &str, device_id: &str) -> Option { @@ -487,7 +487,7 @@ pub async fn api_check(key: &str, device_id: &str) -> Option { Some(json.get("valid").and_then(|v| v.as_bool()).unwrap_or(false)) } -/// POST /api/license/trial — register/sync this device's trial start. +/// POST /api/license/trial - register/sync this device's trial start. /// Sends the local start (if any) so the server can keep the earliest; returns /// the server's authoritative start (unix seconds), or None on network error. pub async fn api_trial(device_id: &str, local_started_at: Option) -> Option { @@ -505,7 +505,7 @@ pub async fn api_trial(device_id: &str, local_started_at: Option) -> Option json.get("started_at").and_then(|v| v.as_u64()) } -/// POST /api/license/deactivate — releases the seat on the server. +/// POST /api/license/deactivate - releases the seat on the server. /// Fire-and-forget; silently ignores errors. pub async fn api_deactivate(key: &str, device_id: &str) { let body = serde_json::json!({ "key": key, "device_id": device_id }); @@ -533,10 +533,10 @@ pub fn check_status(data_dir: &Path) -> LicenseStatus { expires_at: parsed.expires_at, }; } - // Device mismatch — treat as expired trial so they can re-activate + // Device mismatch - treat as expired trial so they can re-activate } Err(_) => { - // Corrupted / tampered license file — remove it + // Corrupted / tampered license file - remove it let _ = delete_license(data_dir); } } diff --git a/src-tauri/src/mcp/mod.rs b/src-tauri/src/mcp/mod.rs index 12c33a84..f8edacad 100644 --- a/src-tauri/src/mcp/mod.rs +++ b/src-tauri/src/mcp/mod.rs @@ -6,10 +6,10 @@ use tokio::sync::oneshot; use crate::db::ActiveConnection; use tauri::{AppHandle, Manager, State}; -/// Port we always try first — stable so AI clients don't need reconfiguration. +/// Port we always try first - stable so AI clients don't need reconfiguration. const PREFERRED_PORT: u16 = 39847; -/// Credential-free metadata about a saved connection — safe to pass to the MCP layer. +/// Credential-free metadata about a saved connection - safe to pass to the MCP layer. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct ConnMeta { pub id: String, @@ -31,9 +31,9 @@ pub struct McpState { shutdown_tx: Mutex>>, /// Actual bound port (0 = not yet started). pub port: Mutex, - /// Persistent token — loaded from disk once in `init_token`, never regenerated. + /// Persistent token - loaded from disk once in `init_token`, never regenerated. token: Mutex, - /// Credential-free list of all saved connections — synced from frontend. + /// Credential-free list of all saved connections - synced from frontend. pub connections: Arc>>, /// ID of the currently active connection (matches one entry in `connections`). pub active_conn_id: Arc>>, @@ -54,7 +54,7 @@ impl McpState { } } - /// Called once from `setup()` — loads or generates a stable token from disk. + /// Called once from `setup()` - loads or generates a stable token from disk. pub fn init_token(&self, app: &AppHandle) { let mut tok = self.token.lock().unwrap_or_else(|e| e.into_inner()); if !tok.is_empty() { @@ -101,7 +101,7 @@ pub struct McpStatus { #[tauri::command] pub async fn mcp_start(mcp: State<'_, McpState>) -> Result { - // Already running — return current status. + // Already running - return current status. { let running = mcp.shutdown_tx.lock().map_err(|e| e.to_string())?.is_some(); if running { @@ -116,7 +116,7 @@ pub async fn mcp_start(mcp: State<'_, McpState>) -> Result { } } - // Always try the preferred port first — fall back only if it's taken by another process. + // Always try the preferred port first - fall back only if it's taken by another process. let preferred = { let p = *mcp.port.lock().map_err(|e| e.to_string())?; if p > 0 { p } else { PREFERRED_PORT } @@ -150,7 +150,7 @@ pub async fn mcp_stop(mcp: State<'_, McpState>) -> Result<(), String> { if let Some(tx) = mcp.shutdown_tx.lock().map_err(|e| e.to_string())?.take() { let _ = tx.send(()); } - // Intentionally keep port and token stable — AI clients stay configured. + // Intentionally keep port and token stable - AI clients stay configured. Ok(()) } @@ -204,7 +204,7 @@ async fn find_free_port(preferred: u16) -> Result { fn generate_token() -> String { // Use OS-provided randomness (getrandom syscall on Linux/macOS, BCryptGenRandom - // on Windows) for a 128-bit token — far stronger than PID+nanos (~32 bits). + // on Windows) for a 128-bit token - far stronger than PID+nanos (~32 bits). let mut bytes = [0u8; 16]; if getrandom::getrandom(&mut bytes).is_ok() { return bytes.iter().map(|b| format!("{b:02x}")).collect(); diff --git a/src-tauri/src/mcp/tools.rs b/src-tauri/src/mcp/tools.rs index f11aff9f..d329c254 100644 --- a/src-tauri/src/mcp/tools.rs +++ b/src-tauri/src/mcp/tools.rs @@ -237,7 +237,7 @@ const WRITE_KEYWORDS: &[&str] = &[ ]; /// Returns true if the SQL is read-only (SELECT, WITH, EXPLAIN, SHOW, DESCRIBE, -/// PRAGMA) — used to enforce MCP read-only mode. +/// PRAGMA) - used to enforce MCP read-only mode. pub fn is_read_only_sql(sql: &str) -> bool { // Strip leading whitespace and block/line comments, then check the first keyword. let s = sql.trim(); @@ -465,7 +465,7 @@ async fn execute_sql_mysql( sql: &str, max_rows: usize, ) -> Result { - // Stream rows instead of fetch_all — caps memory at max_rows regardless of result size. + // Stream rows instead of fetch_all - caps memory at max_rows regardless of result size. let mut stream = sqlx::query(sql).fetch(pool); let mut columns: Vec = Vec::new(); let mut data: Vec> = Vec::with_capacity(max_rows.min(256)); @@ -494,7 +494,7 @@ async fn execute_sql_pg( sql: &str, max_rows: usize, ) -> Result { - // Stream rows — never materialises more than max_rows rows in memory. + // Stream rows - never materialises more than max_rows rows in memory. let mut stream = sqlx::query(sql).fetch(pool); let mut columns: Vec = Vec::new(); let mut data: Vec> = Vec::with_capacity(max_rows.min(256)); @@ -524,7 +524,7 @@ async fn execute_sql_sqlite( sql: &str, max_rows: usize, ) -> Result { - // Stream rows — never materialises more than max_rows rows in memory. + // Stream rows - never materialises more than max_rows rows in memory. let mut stream = sqlx::query(sql).fetch(pool); let mut columns: Vec = Vec::new(); let mut data: Vec> = Vec::with_capacity(max_rows.min(256)); @@ -1519,7 +1519,7 @@ fn pg_cell(row: &sqlx::postgres::PgRow, idx: usize) -> Value { try_str!(Uuid); if let Ok(v) = row.try_get::, _>(idx) { return match v { - // Cap oversized text the same way the grid does — a multi-MB cell + // Cap oversized text the same way the grid does - a multi-MB cell // in an MCP response floods the calling AI tool's context. Some(s) if s.len() > crate::db::sql_util::CELL_VALUE_CAP => { crate::db::sql_util::oversize_cell( diff --git a/src-tauri/src/metrics.rs b/src-tauri/src/metrics.rs index 05ce7100..425efe31 100644 --- a/src-tauri/src/metrics.rs +++ b/src-tauri/src/metrics.rs @@ -2,10 +2,10 @@ Process naming and runtime metrics for Stroke. Exposes two Tauri commands: - - `set_process_title` — renames the process so it shows up as a recognisable + - `set_process_title` - renames the process so it shows up as a recognisable name in htop / ps / Activity Monitor instead of the raw binary name. - - `get_app_metrics` — returns the current process's PID, resident memory + - `get_app_metrics` - returns the current process's PID, resident memory (RSS in bytes), virtual memory, and CPU percentage so the frontend can display a live performance indicator. */ @@ -34,11 +34,11 @@ fn sys() -> &'static Mutex { pub struct AppMetrics { /// OS process ID of this Stroke process. pub pid: u32, - /// Resident Set Size in bytes — actual physical RAM in use. + /// Resident Set Size in bytes - actual physical RAM in use. pub rss_bytes: u64, /// Virtual memory size in bytes. pub virtual_bytes: u64, - /// CPU usage as a percentage (0–100, may exceed 100 on multi-core). + /// CPU usage as a percentage (0-100, may exceed 100 on multi-core). pub cpu_percent: f32, /// Human-readable label for the process visible in the OS monitor. pub process_name: String, @@ -54,7 +54,7 @@ pub fn set_process_title(_name: String) -> Result<(), String> { #[cfg(target_os = "linux")] { use std::ffi::CString; - // Truncate to 15 bytes — Linux kernel limit for PR_SET_NAME. + // Truncate to 15 bytes - Linux kernel limit for PR_SET_NAME. let truncated: String = _name.chars().take(15).collect(); let cname = CString::new(truncated).map_err(|e| e.to_string())?; // SAFETY: prctl is thread-safe for PR_SET_NAME; no memory aliasing. diff --git a/src-tauri/src/omniroute.rs b/src-tauri/src/omniroute.rs index 784967a1..f19f9c33 100644 --- a/src-tauri/src/omniroute.rs +++ b/src-tauri/src/omniroute.rs @@ -28,7 +28,7 @@ impl OmniRouteState { } /// Synchronously kill the child if this app started one. Called from the - /// `RunEvent::Exit` hook in lib.rs — the async `omniroute_stop` path never + /// `RunEvent::Exit` hook in lib.rs - the async `omniroute_stop` path never /// runs on quit, so without this the proxy outlives the app. pub fn kill_now(&self) { if let Ok(mut slot) = self.child.lock() { @@ -59,7 +59,7 @@ fn emit(app: &tauri::AppHandle, line: &str, kind: &str) { // // A windowed app does not get the PATH a terminal gets. macOS starts a .app // from launchd, which hands it `/usr/bin:/bin:/usr/sbin:/sbin` and nothing -// else — no Homebrew, no nvm, no fnm, no Volta. Node lives in exactly those +// else - no Homebrew, no nvm, no fnm, no Volta. Node lives in exactly those // places, so every lookup here missed and the app told users who plainly had // Node installed that they had none, with no way to act on it. Linux .desktop // launches have the same gap; Windows GUI processes do inherit the user PATH. @@ -124,7 +124,7 @@ async fn login_shell_path() -> Option { .await .ok()? .ok()?; - // `printf` emits no newline, so the PATH is whatever follows the last one — + // `printf` emits no newline, so the PATH is whatever follows the last one - // login shells are free to print a banner ahead of it. let text = String::from_utf8_lossy(&out.stdout); let p = text.rsplit('\n').next().unwrap_or("").trim().to_string(); @@ -146,7 +146,7 @@ fn fallback_dirs() -> Vec { out.push(format!("{home}/{rel}")); } // Version managers keep every Node release in its own directory, so the bin - // path cannot be written down — enumerate, newest first. + // path cannot be written down - enumerate, newest first. out.extend(node_version_bins(&format!("{home}/.nvm/versions/node"), "bin")); out.extend(node_version_bins( &format!("{home}/Library/Application Support/fnm/node-versions"), @@ -232,15 +232,15 @@ async fn omniroute_command() -> Cmd { // do was tell them to go run npm in a terminal themselves. // // So the app keeps its own copy under its data directory. That needs no -// elevation on any platform, leaves the user's global npm prefix alone, and — -// because the entry script then sits at a known absolute path — lets the server +// elevation on any platform, leaves the user's global npm prefix alone, and - +// because the entry script then sits at a known absolute path - lets the server // start as `node `, with no PATH search and no shell in between. On // Windows that is what stops a console window from existing, rather than merely // hiding one. // // A pre-existing global install still works: every lookup falls back to it. -/// `/omniroute` — the npm project the app installs into. +/// `/omniroute` - the npm project the app installs into. fn managed_root(app: &tauri::AppHandle) -> Result { Ok(app .path() diff --git a/src-tauri/src/plugins.rs b/src-tauri/src/plugins.rs new file mode 100644 index 00000000..f8ab3507 --- /dev/null +++ b/src-tauri/src/plugins.rs @@ -0,0 +1,415 @@ +//! External plugins: discovery, validation, install and removal. +//! +//! A plugin is a folder under `/plugins//` holding a +//! `manifest.json` and one JavaScript entry file. This module never executes +//! any of it - it reads the manifest, decides whether the folder is a plugin at +//! all, and hands the source up to the frontend, which runs it inside a Web +//! Worker with the network globals removed. +//! +//! Everything here is deliberately suspicious of what it reads. A manifest is a +//! file a stranger wrote: the id has to be a plain slug because it becomes a +//! folder name, the entry has to be a bare filename because it is joined onto a +//! path, sizes are capped because the source is read into memory, and every +//! resolved path is checked to still be inside the plugins directory. + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::fs; +use std::path::{Component, Path, PathBuf}; +use tauri::Manager; + +/// Plugin API generation this build implements. A plugin declaring anything else +/// is listed with an error rather than loaded, so an older app never runs a +/// manifest written against a contract it does not have. +pub const PLUGIN_API_VERSION: u32 = 1; + +/// Largest entry file we will read. Formatters are small; a megabyte is already +/// a hundred times more than the built-ins. +const MAX_SOURCE_BYTES: u64 = 1024 * 1024; +/// Largest folder we will install, counting every file in it. +const MAX_PLUGIN_BYTES: u64 = 4 * 1024 * 1024; +/// Files allowed inside a plugin folder. Anything else is refused at install. +const ALLOWED_EXTENSIONS: [&str; 6] = ["js", "mjs", "json", "md", "txt", "svg"]; +/// Hooks this version knows how to run. +const KNOWN_KINDS: [&str; 1] = ["formatter"]; +/// Permissions this version knows how to *enforce*. Nothing else is accepted: +/// listing a permission the host cannot police would be a promise it cannot +/// keep. `cells:read` is the only capability a formatter needs - the values of +/// the columns it says it applies to. +const KNOWN_PERMISSIONS: [&str; 1] = ["cells:read"]; + +/// What the manifest is allowed to say. +#[derive(Debug, Deserialize)] +struct RawManifest { + id: String, + name: String, + version: String, + #[serde(rename = "apiVersion")] + api_version: u32, + kind: String, + entry: String, + #[serde(default)] + description: String, + #[serde(default)] + author: String, + #[serde(default)] + homepage: String, + #[serde(default)] + permissions: Vec, +} + +/// One plugin folder as the frontend sees it. A folder that fails validation is +/// still returned, with `error` set and `loadable` false, so the Extensions +/// panel can show why instead of silently ignoring it. +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ExternalPlugin { + pub id: String, + pub name: String, + pub version: String, + pub api_version: u32, + pub kind: String, + pub description: String, + pub author: String, + pub homepage: String, + pub permissions: Vec, + /// Absolute path of the plugin folder. + pub dir: String, + /// Entry filename, relative to `dir`. + pub entry: String, + /// SHA-256 of the entry file, so the UI can show that code changed on disk. + pub source_hash: String, + pub bytes: u64, + pub loadable: bool, + pub error: String, +} + +fn plugins_root(app: &tauri::AppHandle) -> Result { + let dir = app + .path() + .app_data_dir() + .map_err(|e| format!("no app data directory: {e}"))? + .join("plugins"); + fs::create_dir_all(&dir).map_err(|e| format!("could not create {}: {e}", dir.display()))?; + Ok(dir) +} + +/// A plugin id is a folder name, so it may only be a lowercase slug. This is the +/// check that stops `../../etc` from ever becoming a path. +fn valid_id(id: &str) -> bool { + let bytes = id.as_bytes(); + (2..=48).contains(&bytes.len()) + && bytes[0].is_ascii_lowercase() + && id + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + && !id.contains("--") + && !id.ends_with('-') +} + +/// An entry must be a bare filename with a JavaScript extension: no directory +/// components, no traversal, nothing that resolves outside the folder. +fn valid_entry(entry: &str) -> bool { + let p = Path::new(entry); + p.components().count() == 1 + && matches!(p.components().next(), Some(Component::Normal(_))) + && matches!( + p.extension().and_then(|e| e.to_str()), + Some("js") | Some("mjs") + ) +} + +/// True when `path` really sits inside `root` after both are canonicalized. +/// Guards every destructive operation: a symlinked plugin folder must not let a +/// remove escape the plugins directory. +fn inside(root: &Path, path: &Path) -> bool { + match (root.canonicalize(), path.canonicalize()) { + (Ok(r), Ok(p)) => p.starts_with(r), + _ => false, + } +} + +fn dir_size(dir: &Path) -> u64 { + let mut total = 0u64; + let Ok(entries) = fs::read_dir(dir) else { + return 0; + }; + for entry in entries.flatten() { + let Ok(meta) = entry.metadata() else { continue }; + if meta.is_dir() { + total = total.saturating_add(dir_size(&entry.path())); + } else { + total = total.saturating_add(meta.len()); + } + } + total +} + +/// Read and validate one plugin folder. `Err` means "not a plugin folder at +/// all" (no manifest, unreadable); a validation failure comes back as an +/// `ExternalPlugin` with `loadable: false`. +fn read_plugin(dir: &Path) -> Result { + let manifest_path = dir.join("manifest.json"); + let raw = fs::read_to_string(&manifest_path).map_err(|e| e.to_string())?; + let folder = dir + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_string(); + + let mut plugin = ExternalPlugin { + id: folder.clone(), + name: folder.clone(), + version: String::new(), + api_version: 0, + kind: String::new(), + description: String::new(), + author: String::new(), + homepage: String::new(), + permissions: Vec::new(), + dir: dir.to_string_lossy().to_string(), + entry: String::new(), + source_hash: String::new(), + bytes: dir_size(dir), + loadable: false, + error: String::new(), + }; + + let m: RawManifest = match serde_json::from_str(&raw) { + Ok(m) => m, + Err(e) => { + plugin.error = format!("manifest.json is not valid: {e}"); + return Ok(plugin); + } + }; + + plugin.id = m.id.clone(); + plugin.name = if m.name.trim().is_empty() { + m.id.clone() + } else { + m.name.trim().to_string() + }; + plugin.version = m.version.clone(); + plugin.api_version = m.api_version; + plugin.kind = m.kind.clone(); + plugin.description = m.description.clone(); + plugin.author = m.author.clone(); + plugin.homepage = m.homepage.clone(); + plugin.permissions = m.permissions.clone(); + plugin.entry = m.entry.clone(); + + // Validation, most-fundamental first. One reason is reported, because the + // first one is the one worth fixing. + if !valid_id(&m.id) { + plugin.error = "id must be 2-48 characters of lowercase letters, digits and single hyphens" + .to_string(); + return Ok(plugin); + } + if m.id != folder { + plugin.error = format!("id \"{}\" does not match its folder \"{folder}\"", m.id); + return Ok(plugin); + } + if m.api_version != PLUGIN_API_VERSION { + plugin.error = format!( + "needs plugin API {} and this build implements {PLUGIN_API_VERSION}", + m.api_version + ); + return Ok(plugin); + } + if !KNOWN_KINDS.contains(&m.kind.as_str()) { + plugin.error = format!( + "kind \"{}\" is not one this build runs ({})", + m.kind, + KNOWN_KINDS.join(", ") + ); + return Ok(plugin); + } + if !valid_entry(&m.entry) { + plugin.error = "entry must be a .js file in the plugin folder, with no path".to_string(); + return Ok(plugin); + } + if let Some(bad) = m + .permissions + .iter() + .find(|p| !KNOWN_PERMISSIONS.contains(&p.as_str())) + { + plugin.error = format!("permission \"{bad}\" is not one this build can enforce"); + return Ok(plugin); + } + + let entry_path = dir.join(&m.entry); + let meta = match fs::metadata(&entry_path) { + Ok(meta) => meta, + Err(_) => { + plugin.error = format!("entry file \"{}\" is missing", m.entry); + return Ok(plugin); + } + }; + if meta.len() > MAX_SOURCE_BYTES { + plugin.error = format!( + "entry file is {} bytes, over the {MAX_SOURCE_BYTES} byte limit", + meta.len() + ); + return Ok(plugin); + } + let source = match fs::read(&entry_path) { + Ok(bytes) => bytes, + Err(e) => { + plugin.error = format!("entry file could not be read: {e}"); + return Ok(plugin); + } + }; + plugin.source_hash = hex::encode(Sha256::digest(&source)); + plugin.loadable = true; + Ok(plugin) +} + +/// Every plugin folder found, sorted by name. Unreadable folders are skipped; +/// invalid ones are included with their error. +#[tauri::command] +pub fn plugins_list(app: tauri::AppHandle) -> Result, String> { + let root = plugins_root(&app)?; + let mut out = Vec::new(); + for entry in fs::read_dir(&root).map_err(|e| e.to_string())?.flatten() { + if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + continue; + } + if let Ok(plugin) = read_plugin(&entry.path()) { + out.push(plugin); + } + } + out.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + Ok(out) +} + +/// The entry file's source, for the frontend to hand to a Worker. Refuses +/// anything that does not validate, so unvalidated code never reaches the host. +#[tauri::command] +pub fn plugins_read_source(app: tauri::AppHandle, id: String) -> Result { + if !valid_id(&id) { + return Err("not a valid plugin id".into()); + } + let root = plugins_root(&app)?; + let dir = root.join(&id); + if !inside(&root, &dir) { + return Err("plugin folder is outside the plugins directory".into()); + } + let plugin = read_plugin(&dir).map_err(|e| format!("{id}: {e}"))?; + if !plugin.loadable { + return Err(plugin.error); + } + fs::read_to_string(dir.join(&plugin.entry)).map_err(|e| e.to_string()) +} + +/// Copy a folder into the plugins directory. The source is validated *before* +/// anything is written, and only allowed file types are copied, so installing +/// cannot drop an executable into the app's data directory. +#[tauri::command] +pub fn plugins_install(app: tauri::AppHandle, path: String) -> Result { + let src = PathBuf::from(&path); + if !src.is_dir() { + return Err("pick the folder holding manifest.json".into()); + } + let candidate = read_plugin(&src).map_err(|_| { + "no manifest.json in that folder - pick the folder holding it".to_string() + })?; + if !candidate.loadable { + return Err(candidate.error); + } + let size = dir_size(&src); + if size > MAX_PLUGIN_BYTES { + return Err(format!( + "the folder is {size} bytes, over the {MAX_PLUGIN_BYTES} byte limit" + )); + } + + let root = plugins_root(&app)?; + let dest = root.join(&candidate.id); + if dest.exists() { + fs::remove_dir_all(&dest).map_err(|e| format!("could not replace the existing copy: {e}"))?; + } + fs::create_dir_all(&dest).map_err(|e| e.to_string())?; + copy_allowed(&src, &dest)?; + + let installed = read_plugin(&dest).map_err(|e| e)?; + if !installed.loadable { + // Never leave a half-copied folder behind to be listed as broken. + let _ = fs::remove_dir_all(&dest); + return Err(installed.error); + } + Ok(installed) +} + +/// Copy one level of a plugin folder, skipping file types that have no business +/// being there. Deliberately not recursive: a plugin is a manifest, an entry +/// file and a readme, and a nested tree is a sign of a packed node_modules. +fn copy_allowed(src: &Path, dest: &Path) -> Result<(), String> { + for entry in fs::read_dir(src).map_err(|e| e.to_string())?.flatten() { + let meta = entry.metadata().map_err(|e| e.to_string())?; + if !meta.is_file() { + continue; + } + let name = entry.file_name(); + let ext = Path::new(&name) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + if !ALLOWED_EXTENSIONS.contains(&ext.as_str()) { + continue; + } + fs::copy(entry.path(), dest.join(&name)).map_err(|e| e.to_string())?; + } + Ok(()) +} + +/// Delete an installed plugin folder. Only ever inside the plugins directory. +#[tauri::command] +pub fn plugins_uninstall(app: tauri::AppHandle, id: String) -> Result<(), String> { + if !valid_id(&id) { + return Err("not a valid plugin id".into()); + } + let root = plugins_root(&app)?; + let dir = root.join(&id); + if !dir.exists() { + return Ok(()); + } + if !inside(&root, &dir) { + return Err("plugin folder is outside the plugins directory".into()); + } + fs::remove_dir_all(&dir).map_err(|e| e.to_string()) +} + +/// Absolute path of the plugins directory, for "show me where these live". +#[tauri::command] +pub fn plugins_dir(app: tauri::AppHandle) -> Result { + Ok(plugins_root(&app)?.to_string_lossy().to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ids_are_folder_safe_slugs() { + assert!(valid_id("status-badge")); + assert!(valid_id("ab")); + assert!(!valid_id("a")); + assert!(!valid_id("Status")); + assert!(!valid_id("../etc")); + assert!(!valid_id("with space")); + assert!(!valid_id("double--hyphen")); + assert!(!valid_id("trailing-")); + assert!(!valid_id("1leading")); + } + + #[test] + fn entries_are_bare_js_filenames() { + assert!(valid_entry("main.js")); + assert!(valid_entry("index.mjs")); + assert!(!valid_entry("src/main.js")); + assert!(!valid_entry("../main.js")); + assert!(!valid_entry("main.ts")); + assert!(!valid_entry("main")); + } +} diff --git a/src-tauri/src/proc.rs b/src-tauri/src/proc.rs index ff5ba6d8..e5ee39d5 100644 --- a/src-tauri/src/proc.rs +++ b/src-tauri/src/proc.rs @@ -1,7 +1,7 @@ // Spawning child processes without showing the user a console window. // // On Windows a GUI process has no console, so when it starts a console program -// the OS allocates one — and that console is a cmd.exe window that pops up in +// the OS allocates one - and that console is a cmd.exe window that pops up in // front of the app. Every process this app runs is a background detail nobody // asked to watch: npm installs, docker, ssh tunnels, port scans. All of them go // through here. @@ -9,7 +9,7 @@ // Unix has no equivalent behaviour and no equivalent flag, so on those platforms // these are pass-throughs that the optimiser removes entirely. -/// `CREATE_NO_WINDOW` — run the console program with no console at all. +/// `CREATE_NO_WINDOW` - run the console program with no console at all. /// /// Not `SW_HIDE` via `STARTUPINFO`: that still allocates the console and merely /// starts it hidden, which leaves a taskbar entry and a window that anything can diff --git a/src-tauri/src/providers/mod.rs b/src-tauri/src/providers/mod.rs index 21cc8a48..094f05ee 100644 --- a/src-tauri/src/providers/mod.rs +++ b/src-tauri/src/providers/mod.rs @@ -1,12 +1,12 @@ /*! - * Database provider adapters — sign in to a hosting provider (Neon, Supabase, + * Database provider adapters - sign in to a hosting provider (Neon, Supabase, * PlanetScale, Prisma Postgres) with OAuth, list every database on the account, * and connect in one click without hunting for connection strings. * * Design: this module owns the shared OAuth 2.0 + PKCE machinery (mirrors * `cloudflare.rs`, kept separate so the working D1 flow is untouched) and - * dispatches provider-specific work — listing databases, building a connectable - * spec — to the per-provider submodules via a `Provider` enum. Every provider is + * dispatches provider-specific work - listing databases, building a connectable + * spec - to the per-provider submodules via a `Provider` enum. Every provider is * a *public* PKCE client: no client secret is shipped in the binary. * * Adding a provider = add an enum variant, its `OAuthConfig`, and a submodule @@ -33,7 +33,7 @@ const AUTH_TIMEOUT_SECS: u64 = 300; const CALLBACK_PORTS: &[u16] = &[8989, 8990, 8991, 8992, 8993]; // Token-exchange proxy. Neon/Supabase/PlanetScale are confidential OAuth clients -// whose token endpoint requires a client_secret — which must never ship in a +// whose token endpoint requires a client_secret - which must never ship in a // desktop binary. The stroke-web app (TanStack Start on Cloudflare Workers) // hosts a server route that holds the secrets, injects them server-side, and // forwards to the real provider token endpoint. The app sends only the public @@ -107,7 +107,7 @@ impl Provider { } /// Whether the provider's authorize/token endpoints support PKCE. PlanetScale - /// does NOT — sending code_challenge makes it reject the request — so it uses + /// does NOT - sending code_challenge makes it reject the request - so it uses /// a plain confidential authorization-code flow (secret only, via the proxy). fn uses_pkce(&self) -> bool { !matches!(self, Self::PlanetScale) @@ -247,7 +247,7 @@ async fn bind_callback_listener(ports: &[u16]) -> Result<(TcpListener, u16), Str } if ports.len() == 1 { return Err(format!( - "Port {} is in use — this provider requires that exact port for its OAuth redirect. \ + "Port {} is in use - this provider requires that exact port for its OAuth redirect. \ Close whatever is using it (another Stroke window or dev server) and retry.", ports[0] )); @@ -259,12 +259,12 @@ async fn bind_callback_listener(ports: &[u16]) -> Result<(TcpListener, u16), Str )) } -const OK_HTML: &str = r#"Stroke — authorized +const OK_HTML: &str = r#"Stroke - authorized

Authorization successful

You can close this tab and return to Stroke.

"#; -const ERR_HTML: &str = r#"Stroke — error +const ERR_HTML: &str = r#"Stroke - error

Authorization failed

You can close this tab and try again in Stroke.

"#; @@ -330,7 +330,7 @@ async fn await_oauth_callback(listener: TcpListener, expected_state: &str) -> Re }; if state.as_deref() != Some(expected_state) { let _ = stream.write_all(send(ERR_HTML).as_bytes()).await; - return Err("OAuth state mismatch — possible CSRF".into()); + return Err("OAuth state mismatch - possible CSRF".into()); } let _ = stream.write_all(send(OK_HTML).as_bytes()).await; @@ -346,7 +346,7 @@ struct TokenResponse { expires_in: Option, } -/// POST a token request to `url` — either the real provider token endpoint +/// POST a token request to `url` - either the real provider token endpoint /// (public clients) or the stroke.click proxy (confidential clients, where the /// proxy injects the secret and `params` includes `provider`). async fn post_token(url: &str, params: &[(&str, &str)]) -> Result { @@ -407,7 +407,7 @@ async fn exchange_code( params.push(("code_verifier", v)); } if is_public { - // Direct to the provider — no secret, no proxy. + // Direct to the provider - no secret, no proxy. post_token(cfg.token_url, ¶ms).await } else { // Via the proxy, which injects the client_secret. @@ -437,7 +437,7 @@ async fn refresh_token( // ── Token storage (namespaced per provider in the secrets store) ───────────────── -// `async` because the keychain is only ever touched off the caller's thread — +// `async` because the keychain is only ever touched off the caller's thread - // see the module comment in secrets.rs. async fn store_tokens( app: &tauri::AppHandle, @@ -487,7 +487,7 @@ async fn valid_token(app: &tauri::AppHandle, p: Provider) -> Result assume the token is long-lived and valid, mirroring the // Cloudflare flow (`unwrap_or(u64::MAX)`). A `0` default treated every such - // token as already expired and forced a needless re-login on every call — + // token as already expired and forced a needless re-login on every call - // the main cause of "it asks me to sign in again each day". let expires = map .get(&format!("__{k}_expires__")) @@ -498,8 +498,8 @@ async fn valid_token(app: &tauri::AppHandle, p: Provider) -> Result Result { } /// Each Neon project is presented as one connectable database. Neon scopes -/// projects under organizations, so `/projects` requires an `org_id` — list the +/// projects under organizations, so `/projects` requires an `org_id` - list the /// user's orgs first, then each org's projects, and flatten them. pub async fn list_databases(token: &str) -> Result, String> { let orgs_body = get(token, "/users/me/organizations").await?; diff --git a/src-tauri/src/providers/planetscale.rs b/src-tauri/src/providers/planetscale.rs index 7091c830..3a7fe8b2 100644 --- a/src-tauri/src/providers/planetscale.rs +++ b/src-tauri/src/providers/planetscale.rs @@ -1,5 +1,5 @@ /*! - * PlanetScale adapter — MySQL/Vitess. Sign in via OAuth, list databases across + * PlanetScale adapter - MySQL/Vitess. Sign in via OAuth, list databases across * the account's organizations, and mint fresh connection credentials on connect * (PlanetScale returns a password exactly once, at password-creation time). */ @@ -9,7 +9,7 @@ use serde_json::Value; pub const OAUTH: OAuthConfig = OAuthConfig { // Public client_id (safe to embed). The client SECRET lives only on the - // stroke.click proxy as PLANETSCALE_CLIENT_SECRET — never in this binary. + // stroke.click proxy as PLANETSCALE_CLIENT_SECRET - never in this binary. client_id: "pscale_app_1c8af8139904805f4506cfb88a4f9967", auth_url: "https://auth.planetscale.com/oauth/authorize", // token_url is used by the proxy, not the app (the app posts to TOKEN_PROXY). diff --git a/src-tauri/src/providers/prisma.rs b/src-tauri/src/providers/prisma.rs index 1dd52dce..7688ec65 100644 --- a/src-tauri/src/providers/prisma.rs +++ b/src-tauri/src/providers/prisma.rs @@ -1,7 +1,7 @@ /*! - * Prisma Postgres adapter — Prisma's Management API OAuth (authorization-code, + * Prisma Postgres adapter - Prisma's Management API OAuth (authorization-code, * confidential client). Sign in, list projects, and connect using the direct - * Postgres credentials (`ppgDirectConnection`) the API exposes — no pasted + * Postgres credentials (`ppgDirectConnection`) the API exposes - no pasted * connection string, and never the Accelerate `prisma+postgres://` form (which * a normal Postgres driver can't speak). */ @@ -11,12 +11,12 @@ use serde_json::Value; pub const OAUTH: OAuthConfig = OAuthConfig { // Public client_id (safe to embed). The client SECRET lives only on the - // stroke.click proxy as PRISMA_CLIENT_SECRET — never in this binary. + // stroke.click proxy as PRISMA_CLIENT_SECRET - never in this binary. client_id: "cmr8zjp5z012evvf7cycv88xn", auth_url: "https://auth.prisma.io/authorize", // token_url is used by the proxy, not the app (the app posts to TOKEN_PROXY). token_url: "https://auth.prisma.io/token", - // `offline_access` is REQUIRED to receive a refresh token — without it the + // `offline_access` is REQUIRED to receive a refresh token - without it the // access token dies after 1h and every Management-API call 401s (the app // still shows "connected" because the dead access token is on disk). scopes: "workspace:admin offline_access", @@ -43,7 +43,7 @@ async fn get(token: &str, path: &str) -> Result { } /// Format a Management-API error. A 401 almost always means the stored token -/// expired — tell the user to reconnect rather than showing "request failed". +/// expired - tell the user to reconnect rather than showing "request failed". fn api_error(status: u16, body: &Value) -> String { let msg = body["message"] .as_str() @@ -172,7 +172,7 @@ pub async fn build_connection(token: &str, project_id: &str) -> Result Result`), and session mode (port 5432) behaves like a normal - // Postgres connection — the right choice for a GUI client. + // Postgres connection - the right choice for a GUI client. // - // Ask the Management API for the exact pooler host — never guess the region + // Ask the Management API for the exact pooler host - never guess the region // prefix (aws-0 vs aws-1), since a wrong host connects to a pooler node that // doesn't host this project's tenant → "tenant/user … not found". let pooler = get(token, &format!("/projects/{project_ref}/config/database/pooler")) @@ -91,7 +91,7 @@ pub async fn build_connection(token: &str, project_ref: &str) -> Result Result &'static Mutex>> { } // All secrets (AI keys, provider OAuth tokens, Cloudflare tokens) live in a -// single JSON blob stored in the OS keychain — macOS Keychain, Windows +// single JSON blob stored in the OS keychain - macOS Keychain, Windows // Credential Manager, or Linux Secret Service. A legacy plaintext file // (`ai-keys.json`) is migrated in on first read and then deleted; it also // remains the fallback store if the keychain is unavailable, so credentials are @@ -93,13 +93,13 @@ pub(crate) fn write_all(app: &tauri::AppHandle, map: &HashMap) - // the new value regardless of what the durable backend does. *cache().lock().unwrap_or_else(|e| e.into_inner()) = Some(map.clone()); - // Prefer the keychain, but only trust it — and drop the plaintext copy — once + // Prefer the keychain, but only trust it - and drop the plaintext copy - once // a read-back proves the data actually persisted. if write_keychain(map).is_ok() && keychain_holds(map) { let _ = std::fs::remove_file(legacy_path(app)); return Ok(()); } - // Keychain missing, mock, or not round-tripping — persist to the file so + // Keychain missing, mock, or not round-tripping - persist to the file so // secrets survive a restart. let path = legacy_path(app); if let Some(parent) = path.parent() { @@ -115,7 +115,7 @@ pub(crate) fn write_all(app: &tauri::AppHandle, map: &HashMap) - // the OS " wants to use your confidential information" prompt on screen and // only returns once the user answers it. Tauri runs a command *without* `async` // on the main thread (see "Async Commands" in the Tauri docs), and blocking the -// main thread stalls the event loop — the window stops compositing and shows an +// main thread stalls the event loop - the window stops compositing and shows an // unpainted surface (pure white) for as long as the prompt is up. Inside an // async command the same call instead parks a runtime worker, stalling unrelated // queries. diff --git a/src-tauri/src/web_search.rs b/src-tauri/src/web_search.rs index 8a03c4b0..4278f38b 100644 --- a/src-tauri/src/web_search.rs +++ b/src-tauri/src/web_search.rs @@ -1,7 +1,7 @@ //! Web access for the AI agent: a search, and a page reader. //! //! Both live in Rust rather than the webview because the webview cannot make -//! these requests at all — no search endpoint or arbitrary site sends CORS +//! these requests at all - no search endpoint or arbitrary site sends CORS //! headers that would let a `fetch()` from the app origin read the response. //! //! Search goes through DuckDuckGo's HTML endpoint, which needs no API key. That diff --git a/src/App.svelte b/src/App.svelte index d913a01a..4c8f4b2d 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -3,6 +3,7 @@ import { Toaster } from '$lib/components/ui/sonner/index.js' import StudioShell from './lib/components/StudioShell.svelte' import LicenseGate from './lib/components/LicenseGate.svelte' + import AppLockGate from './lib/components/AppLockGate.svelte' import AppErrorBoundary from './lib/components/AppErrorBoundary.svelte' import GlobalTooltip from './lib/components/GlobalTooltip.svelte' import { loadSettings, applySettings, installZoomShortcuts } from '$lib/stores/settings.js' @@ -67,15 +68,19 @@ // 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. + // all window state - no JS show/maximize/geometry handling needed here. document.documentElement.style.opacity = '1' }) - - - + + + + + + diff --git a/src/app.css b/src/app.css index d303e968..eb4e0862 100644 --- a/src/app.css +++ b/src/app.css @@ -144,7 +144,7 @@ html[data-os="linux"] { --window-radius: 8px; } /* Ceiling for every floating menu panel (context menu, dropdown, submenus). Panels size to their longest label, so this is the only thing standing - between a long value — a column name, a file path, a cell preview — and a + between a long value - a column name, a file path, a cell preview - and a menu that runs off the edge of the window. Paired with overflow-x-hidden on the panels so anything past it is clipped inside the rounded border rather than painted over it. */ @@ -814,7 +814,7 @@ input[type='number']::-webkit-inner-spin-button { `color` and `background-color` from the + + + + +
+ {#if error} +
+

The scan failed

+

{error}

+
+ {:else if loading && !report} +
+ +

Reading the catalog…

+
+ {:else if report?.unsupported} +
+ +

No checks for {report.engine} yet

+

+ The checks are PostgreSQL catalog queries. Rather than show an empty report that + reads as a clean bill of health, the Advisor says nothing until it has checks for + this engine. +

+
+ {:else if groups.length === 0} +
+ +

+ {findings.length === 0 ? 'Nothing to report' : 'Nothing matches this filter'} +

+ {#if findings.length > 0} + + {/if} +
+ {:else} +
+ {#each groups as group (group.checkId)} + {@const meta = SEVERITY_META[group.severity] ?? SEVERITY_META.info} + {@const open = expanded.has(group.checkId)} +
+ + + {#if open} + +

+ {group.findings[0].description} +

+
    + {#each group.findings as f (f.entity)} +
  • + + + {f.entity} + {#if f.detail} + {f.detail} + {/if} + + + {f.severity} + + {#if f.remediation} + + {/if} +
  • + {/each} +
+ {/if} +
+ {/each} +
+ {/if} + + + {#if failedChecks.length > 0} +
+

+ {failedChecks.length} check{failedChecks.length === 1 ? '' : 's'} could not run +

+
    + {#each failedChecks as c (c.id)} +
  • + {c.title} - {c.error ?? 'unknown error'} +
  • + {/each} +
+
+ {/if} +
+ diff --git a/src/lib/components/AiChat.svelte b/src/lib/components/AiChat.svelte index 84230b75..1a398b40 100644 --- a/src/lib/components/AiChat.svelte +++ b/src/lib/components/AiChat.svelte @@ -212,7 +212,7 @@ /** * Save a chart canvas as PNG through the native dialog. - * Both call sites used to build an ``, which WKWebView ignores — + * Both call sites used to build an ``, which WKWebView ignores - * the button looked like it worked and produced no file. * @param {string} selector CSS selector for the chart's canvas * @param {string} title used for the default filename @@ -495,7 +495,7 @@ const plainHistory = $state.snapshot(rawApiHistory); if (activeConvId) { // The title is deliberately NOT written here. It is seeded from the first - // message on create, then owned by generateAiTitle (and by rename) — this + // message on create, then owned by generateAiTitle (and by rename) - this // save runs after every turn, so re-deriving it from the first message // clobbered the generated title moments after it landed, which is why every // conversation in the list stayed named after its opening word. @@ -528,7 +528,7 @@ const { content } = await chatCompletionRaw(settings, [ { role: "user", - content: `Name this database-assistant conversation with a specific 3-6 word title: what the user is working on, naming the tables, metric or task involved. Prefer "Revenue by plan, last 90d" over "Data question". Never echo a greeting. Reply with ONLY the title — no quotes, no trailing punctuation.\n\nUser: ${String(userMsg.content).slice(0, 600)}\nAssistant: ${String(assistantMsg.content).slice(0, 600)}`, + content: `Name this database-assistant conversation with a specific 3-6 word title: what the user is working on, naming the tables, metric or task involved. Prefer "Revenue by plan, last 90d" over "Data question". Never echo a greeting. Reply with ONLY the title - no quotes, no trailing punctuation.\n\nUser: ${String(userMsg.content).slice(0, 600)}\nAssistant: ${String(assistantMsg.content).slice(0, 600)}`, }, ]); // Small models like to wrap the answer in quotes or prefix "Title:". @@ -602,7 +602,7 @@ let rawApiHistory = $state([]); let loading = $state(false); let error = $state(""); - /** Provider payload behind the error banner — collapsed until asked for. */ + /** Provider payload behind the error banner - collapsed until asked for. */ let errorDetailOpen = $state(false); /** @param {string} detail */ @@ -1556,7 +1556,7 @@ * 'ACTIVE' or '1', that a date is stored as an epoch string, or that a * nullable column is null for every row that matters. Queries written against * the type alone get those wrong, so the model sees actual values before it - * writes any SQL. Cached per table for the session — the shape of a table's + * writes any SQL. Cached per table for the session - the shape of a table's * data doesn't change between turns. * * Best effort throughout: a table that can't be read is simply left out. @@ -1600,7 +1600,7 @@ /** * Quoted, schema-qualified table reference for the connected engine. - * SQLite/D1/LibSQL have no user schemas, so the schema half is dropped there — + * SQLite/D1/LibSQL have no user schemas, so the schema half is dropped there - * qualifying would turn a valid name into a missing-table error. * @param {string} schema @param {string} table */ @@ -1930,13 +1930,13 @@ last_error: prior.lastError, // The old wording here was "explain to the user why this cannot be // done", which asked the model to account for a failure it had no - // information about — so it invented one, and a plain "no such table" + // information about - so it invented one, and a plain "no such table" // got reported to the user as a database permission problem. Report // the error; do not theorise about its cause. instruction: "Either try a genuinely different approach (a different tool, or SQL for THIS engine), " + "or tell the user the operation failed and quote last_error verbatim. " + - "Do NOT guess at a cause you have not verified — in particular, never claim a permissions, " + + "Do NOT guess at a cause you have not verified - in particular, never claim a permissions, " + "access, or authentication problem unless last_error actually says so.", }), }); @@ -2057,7 +2057,7 @@ const hint = classifyDbError(msg); // Show the failure in the transcript rather than deleting the // indicator. Hiding it left the user watching the model narrate a - // cause for an error they were never shown — the query that actually + // cause for an error they were never shown - the query that actually // failed, and why, is the one thing they need to see. const execIdx = items.findIndex((i) => i.id === execId); const failedItem = /** @type {ChatItem} */ ({ @@ -3370,7 +3370,7 @@ {:else if item.kind === "executing"} {@const meta = execMeta(item.op, item.sql)}
void) | null} */ let omniUnlisten = null; @@ -219,7 +219,7 @@ // Latched, not guarded on the result. A failed fetch leaves the lists empty, // so a guard on emptiness re-enters the moment it settles and the spinner - // never stops — the same loop the Copilot model fetch had, a few lines up. + // never stops - the same loop the Copilot model fetch had, a few lines up. // One attempt per dialog session; Recheck is the retry. // // Runs for any Ollama setup rather than only the empty state: /v1/models @@ -260,11 +260,11 @@ localModels = ids; if (ids.length === 0) { // Answered, with nothing to offer. That is not a failure and must not be - // dressed as one — telling someone to start a server that just replied + // dressed as one - telling someone to start a server that just replied // is worse than saying nothing. // A marker, not a command: the suggestions below come from Ollama's // registry, and a model name written down here would be wrong within - // months — llama3.1:8b, which used to live on this line, is no longer + // months - llama3.1:8b, which used to live on this line, is no longer // on the registry at all. localModelsEmpty = isOllama ? "ollama" : isOmniroute ? "omniroute" : "server"; } else if (!ids.includes(formModel)) { @@ -394,7 +394,7 @@ // so bouncing back to the list just asks for a second dismissal. if (isNew) open = false; } catch (e) { - // Stay on the form and surface the failure in the step-2 banner — + // Stay on the form and surface the failure in the step-2 banner - // otherwise the spinner just stops and the click appears to do nothing. testState = "error"; testMsg = String(/** @type {any} */ (e)?.message ?? e).slice(0, 200); @@ -709,7 +709,7 @@ {#if omniEnv && !omniEnv.node}

- Node.js is not installed. OmniRoute runs on Node — install it from nodejs.org, then Recheck. + Node.js is not installed. OmniRoute runs on Node - install it from nodejs.org, then Recheck.

- Cloud models need ollama signin once — no download, and they are far + Cloud models need ollama signin once - no download, and they are far larger than anything local.

{/if} @@ -856,8 +856,8 @@ {/if} {/if} - {#if isOllama && !localModelsEmpty && ollamaSuggestions.cloud.length > 0}
@@ -985,7 +985,7 @@ { testState = "idle"; testMsg = "" }} /> {:else if isStroke}

- No key needed — this is Stroke's own free tier, with a daily limit + No key needed - this is Stroke's own free tier, with a daily limit per device. Add your own provider any time for unlimited use.

{:else if !isOllama} diff --git a/src/lib/components/AppLockGate.svelte b/src/lib/components/AppLockGate.svelte new file mode 100644 index 00000000..2ea5426b --- /dev/null +++ b/src/lib/components/AppLockGate.svelte @@ -0,0 +1,71 @@ + + +{#if ready} + {#if mountApp} + {@render children()} + + {/if} + {#if $locked} + + {/if} +{/if} diff --git a/src/lib/components/AppLockScreen.svelte b/src/lib/components/AppLockScreen.svelte new file mode 100644 index 00000000..c81d597b --- /dev/null +++ b/src/lib/components/AppLockScreen.svelte @@ -0,0 +1,131 @@ + + +
+ + +
+
+ +
+
+ +
+ +

Stroke is locked

+

+ Enter your {$lockStatus.pinLength}-digit PIN to unlock +

+ +
+ +
+

+ {#if checking}Checking…{:else if wrong}Wrong PIN. Try again.{/if} +

+ +
+ + · + +
+ + {#if showHelp} +

+ There is no recovery code, by design: the PIN is only ever stored as a + salted hash. To get back in, delete the + secrets-vault entry for + app.stroke.desktop from your + OS keychain. That clears the PIN and every saved credential with it. +

+ {/if} +
+
+
diff --git a/src/lib/components/BackupPage.svelte b/src/lib/components/BackupPage.svelte index 78c34191..c155e1f6 100644 --- a/src/lib/components/BackupPage.svelte +++ b/src/lib/components/BackupPage.svelte @@ -232,7 +232,7 @@ const name = `backup_${dbLabel.toLowerCase().replace(/\s+/g, "_")}${schemaSlug}_${ts}.sql`; // Was an `
` click followed by an unconditional success toast. // WKWebView ignores that anchor, so the app reported a saved backup when no - // file had been written — the worst possible place to be wrong. + // file had been written - the worst possible place to be wrong. try { const path = await saveExportAs(exportResult.sql, name, { name: "SQL", diff --git a/src/lib/components/ChartsPage.svelte b/src/lib/components/ChartsPage.svelte index f9da3dae..0c103b46 100644 --- a/src/lib/components/ChartsPage.svelte +++ b/src/lib/components/ChartsPage.svelte @@ -91,7 +91,7 @@ */ function cleanPreviewOption(option) { // Shared with the dashboard: a saved option also carries the theme colours - // it was built in, so re-theming has to happen here too — a chart saved in + // it was built in, so re-theming has to happen here too - a chart saved in // light mode used to render its axis labels as dark text on a dark card. return restyleSavedOption(option, { isDark: $isCurrentThemeDark }) } diff --git a/src/lib/components/CloudflareLogin.svelte b/src/lib/components/CloudflareLogin.svelte index a6536ec6..07857f5a 100644 --- a/src/lib/components/CloudflareLogin.svelte +++ b/src/lib/components/CloudflareLogin.svelte @@ -25,7 +25,7 @@ ondisconnect = () => {}, // Seeds from a saved connection picked in the dialog's sidebar, so its // account and database show as already selected instead of the picker - // restarting on the first account. Selection only — see `seedFromSaved`. + // restarting on the first account. Selection only - see `seedFromSaved`. initialAccountId = '', initialDatabaseId = '', initialDatabaseName = '', @@ -56,7 +56,7 @@ ) // Show a saved connection's database as the current selection. Deliberately - // does not call `onselect` — that is what connects, and picking a row in the + // does not call `onselect` - that is what connects, and picking a row in the // connections sidebar is not a request to dial it. Latched on the id so it // seeds once per saved connection and never fights a manual pick. let seededDbId = '' @@ -186,7 +186,7 @@ {#if phase === 'idle'} {#snippet mark()}{/snippet} diff --git a/src/lib/components/CommandPalette.svelte b/src/lib/components/CommandPalette.svelte index 0c29bfca..18e55448 100644 --- a/src/lib/components/CommandPalette.svelte +++ b/src/lib/components/CommandPalette.svelte @@ -6,6 +6,7 @@ import AiMarkdown from './AiMarkdown.svelte' import { chatCompletionStream, buildSystemPrompt, AI_TOOLS, isDestructiveSql, classifyDbError } from '$lib/ai.js' import { aiSettings, isAiConfigured } from '$lib/stores/ai-settings.js' + import { pinEnabled, lockNow } from '$lib/stores/app-lock.js' import { appCmdkAi } from '$lib/stores/settings.js' import { executeSql } from '$lib/api.js' @@ -44,11 +45,12 @@ hasSecurity = true, /** Redis (key-value) connection - hides SQL-only pages, shows the keyspace. */ isRedis = false, - /** PostGIS present on this connection — gates the Map entry. */ + /** PostGIS present on this connection - gates the Map entry. */ geoAvailable = false, onopenredis = () => {}, onopenlogs = () => {}, onopeninsights = () => {}, + onopenadvisor = () => {}, onopenobjects = () => {}, onopenormschema = () => {}, onopendashboard = () => {}, @@ -107,6 +109,7 @@ { icon: 'network', label: 'ER Diagram', action: onopenerd, show: connected && !isRedis, value: 'open er diagram entity relationship foreign key pk fk graph' }, { icon: 'layout-template', label: 'Schema Explorer', action: onopenSchema, show: connected && hasSchemaExplorer, value: 'open schema explorer indexes enums views materialized' }, { icon: 'shield-check', label: 'Security', action: onopensecurity, show: connected && hasSecurity, value: 'open security roles users policies rls row level' }, + { icon: 'shield-check', label: 'Advisor', action: onopenadvisor, show: connected && !isRedis, value: 'open advisor lint audit checks security performance schema rls unused index foreign key bloat' }, { icon: 'database', label: 'Instance Insights', action: onopeninsights, show: connected && !isRedis, value: 'open instance insights monitoring sessions locks replication config pg_settings' }, { icon: 'layout-dashboard',label: 'Dashboard', action: onopendashboard, show: connected && !isRedis, value: 'open dashboard saved metrics overview widgets' }, { icon: 'bar-chart-2', label: 'Charts', action: onopencharts, show: connected && !isRedis, value: 'open charts visualize query results graphs plots' }, @@ -326,7 +329,7 @@ // state changed is always behind what is actually on screen. The old effect // scrolled to a bottom that did not exist yet, fell a little further behind on // every token, and once the gap passed its 120px "near the bottom" gate it - // stopped following for the rest of the answer — which is exactly when there + // stopped following for the rest of the answer - which is exactly when there // was most left to read. Watching the box means the scroll happens after the // content it is scrolling to. $effect(() => { @@ -490,7 +493,7 @@ const s = scoreName(x, q, qLoose) if (s > 0) scored.push({ t: x.t, s, len: x.l.length }) } - // Higher score first; ties broken by shorter name (more relevant) then A–Z. + // Higher score first; ties broken by shorter name (more relevant) then A-Z. scored.sort((a, b) => b.s - a.s || a.len - b.len || (a.t.name < b.t.name ? -1 : 1)) return { items: scored.slice(0, TABLES_PAGE_CAP).map((x) => x.t), total: scored.length } } @@ -804,6 +807,12 @@ Settings + {#if $pinEnabled} + run(lockNow)}> + + Lock Stroke + + {/if} run(onopenshortcuts)}> Keyboard shortcuts diff --git a/src/lib/components/ConnectionModal.svelte b/src/lib/components/ConnectionModal.svelte index fa2be620..20ee5a79 100644 --- a/src/lib/components/ConnectionModal.svelte +++ b/src/lib/components/ConnectionModal.svelte @@ -36,6 +36,8 @@ setLastConnectionId, } from "$lib/stores/connections.js"; import { Input } from "$lib/components/ui/input/index.js"; + import PasswordInput from "./PasswordInput.svelte"; + import { requireUnlock } from "$lib/stores/app-lock.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"; @@ -222,7 +224,7 @@ let saved = $state(loadSavedConnections().sort(byLastConnected)); /** Filter over the saved rail. Matches the things you would recognise a - * connection by — its name, its engine, and where it points. */ + * connection by - its name, its engine, and where it points. */ let savedQuery = $state(""); /** @type {HTMLInputElement | null} */ let savedSearchEl = $state(null); @@ -235,7 +237,7 @@ .some((v) => String(v).toLowerCase().includes(q)), ); }); - /** What Enter in the filter acts on — same target the eye is already on. */ + /** What Enter in the filter acts on - same target the eye is already on. */ const firstSavedMatch = $derived(savedMatches[0] ?? null); /** @@ -943,7 +945,7 @@ ) return { title: "The username or password was rejected", - hint: "The server answered, so the address is right — only the credentials were refused.", + hint: "The server answered, so the address is right - only the credentials were refused.", actionLabel: "Check password", action: () => focusField("cn-pass"), }; @@ -969,7 +971,7 @@ ) return { title: "That database doesn't exist on the server", - hint: 'Check the name — on PostgreSQL the default database is usually "postgres".', + hint: 'Check the name - on PostgreSQL the default database is usually "postgres".', actionLabel: "Edit database", action: () => focusField("cn-db"), }; @@ -1047,7 +1049,12 @@ } /** Open `conn` with the driver its `type` calls for. */ - function openConnection(conn) { + async function openConnection(conn) { + // Every connect from this dialog is user-initiated, so it is exactly what + // the "ask when connecting" PIN setting is about. A no-op when no PIN is set. + if (!(await requireUnlock("Unlock to connect to a database"))) { + throw new Error("Cancelled - the PIN was not entered."); + } if (conn.type === "sqlite") return connectSqlite(conn); if (conn.type === "d1") return connectD1(conn); if (conn.type === "libsql") return connectLibSql(conn); @@ -1172,7 +1179,7 @@ let localPhase = $state(/** @type {'idle'|'scanning'|'done'} */ ("idle")); /** - * ⌘R / Ctrl+R — rescan. Reloads the saved list as well as the local scan, so + * ⌘R / Ctrl+R - rescan. Reloads the saved list as well as the local scan, so * one key means "show me what is actually there now". * @param {KeyboardEvent} e */ @@ -1220,7 +1227,7 @@ badge: `:${s.port}`, subtitle: built ? s.target : (s.reason ?? ""), hint: built - ? `${s.target} — from ${s.projectDir}/${s.source}` + ? `${s.target} - from ${s.projectDir}/${s.source}` : (s.reason ?? ""), conn: built && { ...built, @@ -1281,7 +1288,7 @@ title: m.name, badge: `:${m.port}`, subtitle: m.target, - hint: `${m.name} (pid ${m.pid}) — connects as ${m.user || "the default user"}`, + hint: `${m.name} (pid ${m.pid}) - connects as ${m.user || "the default user"}`, conn: { id: m.id, type: m.engine, @@ -1395,7 +1402,7 @@ /** * Open a local target. Nobody typed these credentials, but the connection - * still has to survive a disconnect and an app restart — so it joins the list + * still has to survive a disconnect and an app restart - so it joins the list * under a stable id and becomes "last connection", and the normal resume path * brings it back even once the studio or container scan is stale. * @param {LocalTarget} t @@ -1471,7 +1478,7 @@ /** * Persist the form WITHOUT connecting. Same validation and payload as - * handleConnect, minus the connect round trip and the dialog close — so a + * handleConnect, minus the connect round trip and the dialog close - so a * connection can be filed away (or an edit corrected) while the current * session stays live. Re-stamps `baseline` so the "Unsaved" dot clears and the * close guard stops treating the form as dirty. @@ -1670,7 +1677,7 @@ preventDefault matters: without it the webview reloads the whole app. --> @@ -1996,7 +2003,7 @@
@@ -3351,7 +3352,7 @@ ? "Everything stays on this machine" : railOpen ? "Or pick a saved connection on the left" - : "Saved connections are hidden — reopen them from the top left"} + : "Saved connections are hidden - reopen them from the top left"} {:else if connecting} setViewOnly(true)} > diff --git a/src/lib/components/DataDiffPage.svelte b/src/lib/components/DataDiffPage.svelte index 76b860df..146a7d35 100644 --- a/src/lib/components/DataDiffPage.svelte +++ b/src/lib/components/DataDiffPage.svelte @@ -102,8 +102,8 @@ const MONACO_OPTS = /** @type {monaco.editor.IStandaloneEditorConstructionOptions} */ ({ language: 'sql', - // No hardcoded theme. `monaco.editor.setTheme` — and a `theme` in create - // options — is *global*: pinning this to 'vs-dark' re-tinted every other + // No hardcoded theme. `monaco.editor.setTheme` - and a `theme` in create + // options - is *global*: pinning this to 'vs-dark' re-tinted every other // editor in the app the moment this page mounted, and left a stock VS Code // dark editor sitting on a light app theme. The app theme is applied below. minimap: { enabled: false }, diff --git a/src/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte index 13d04bde..964d505d 100644 --- a/src/lib/components/DataTable.svelte +++ b/src/lib/components/DataTable.svelte @@ -5,7 +5,8 @@ import { zoomState } from '$lib/stores/canvas-zoom.svelte.js' // Zoom is driven through the app-level settings so the canvas scales together // with the rest of the UI (applySettings mirrors the app zoom into zoomState). - import { increaseZoom, decreaseZoom, resetZoom, appPreviewDml, appTableStyle, TABLE_STYLES, normalizeTableStyle, appVimMode, appTableAlign } from '$lib/stores/settings.js' + import { increaseZoom, decreaseZoom, resetZoom, appPreviewDml, appTableStyle, TABLE_STYLES, normalizeTableStyle, appVimMode, appTableAlign, appNativeScroll, appRowSpacing, appZebraRows, rowSpacingHeight } from '$lib/stores/settings.js' + import { createSmoothScroll, wheelPixels } from '$lib/smooth-scroll.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"; @@ -94,6 +95,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; } from "$lib/stores/pending-table-edits.js"; import { formatCellValue, transformsFor, transformById, enabledGeneratorGroups, linkifyValue, statsNeeded, annotatorEnabled, anyDisplayExtEnabled } from "$lib/plugins/registry.js"; import { pluginState, isPluginEnabled } from "$lib/stores/plugins.js"; + import { externalFormatVersion } from "$lib/plugins/external/host.js"; import { isImageUrl } from "$lib/plugins/extensions/cell-transforms.js"; import { t } from "$lib/i18n.js"; import Wand2 from "@lucide/svelte/icons/wand-2"; @@ -112,6 +114,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; import Maximize2 from "@lucide/svelte/icons/maximize-2"; import Check from "@lucide/svelte/icons/check"; import Loader from "@lucide/svelte/icons/loader"; + import TriangleAlert from "@lucide/svelte/icons/triangle-alert"; import X from "@lucide/svelte/icons/x"; import DateTimePicker from "./DateTimePicker.svelte"; import ColumnStatsPanel from "./ColumnStatsPanel.svelte"; @@ -124,6 +127,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; withAlpha, drawIcon, roundRect, + roundRectPath, drawCheckbox, computeColumnGeometry, colDrawnX, @@ -296,6 +300,12 @@ import FilterX from "@lucide/svelte/icons/filter-x"; windowed = false, /** Called (on change) with the currently visible row range in windowed mode. */ onvisiblerange = /** @type {(start: number, end: number) => void} */ (() => {}), + /** Windowed mode: what the parent's window fetcher is doing, so the grid can + * say what it is waiting for instead of shimmering silently. + * @type {{ slow: boolean, failed: boolean } | null} */ + windowStatus = null, + /** Retry the windows that gave up (the loading pill's Retry button). */ + onretrywindows = /** @type {() => void} */ (() => {}), /** Infinite scroll mode - when true the table fires onloadmore near the bottom. */ infiniteScroll = false, /** True while an incremental "load more" fetch is in flight. */ @@ -610,7 +620,10 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // and the level persists via app settings. // All layout constants scale with canvasZoom so the entire canvas zooms together. - const ROW_HEIGHT = $derived(Math.round(24 * canvasZoom)) + // The base height comes from Settings → Appearance → Row spacing; everything + // else in the grid (row tops, hit tests, the scroll spacer) is derived from + // ROW_HEIGHT, so changing it reflows the whole grid with no other edits. + const ROW_HEIGHT = $derived(Math.round(rowSpacingHeight($appRowSpacing) * canvasZoom)) // `_scrollTop` is the VIRTUAL scroll offset used for all row math (content // space). `_physScrollTop` is the raw DOM scrollTop of the container, which is @@ -935,6 +948,13 @@ import FilterX from "@lucide/svelte/icons/filter-x"; const _vexprTextCache = new Map(); /** @type {Map>} */ const _colTfCache = new Map(); + /** The row object each cached index was built from. The array-identity check + * below misses one case: saving a cell swaps that ONE row for a new array and + * leaves `rows` itself the same object (it is $state.raw - the parent bumps + * dataVersion instead of replacing it), so the caches kept serving the + * pre-edit text and an applied edit only appeared after a refresh. + * @type {Map} */ + const _dispCacheRowRef = new Map(); let _dispCacheRows = /** @type {unknown} */ (null); let _dispCacheVFns = /** @type {unknown} */ (null); let _dispCacheTFns = /** @type {unknown} */ (null); @@ -944,6 +964,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; if (_dispCacheRows !== rows) { _dispCacheRows = rows; _cellTextCache.clear(); _vexprTextCache.clear(); _colTfCache.clear(); + _dispCacheRowRef.clear(); // Keyed by the full vector literal (10-20KB each for big embeddings), so // stale entries from previous pages/tables must not accumulate. _vectorDisplayCache.clear(); @@ -951,6 +972,18 @@ import FilterX from "@lucide/svelte/icons/filter-x"; if (_dispCacheVFns !== _vcolFns) { _dispCacheVFns = _vcolFns; _vexprTextCache.clear(); } if (_dispCacheTFns !== _colTransformFns) { _dispCacheTFns = _colTransformFns; _colTfCache.clear(); } } + /** Drop one row's cached text when the row behind it was replaced in place. + * Called once per row per frame from drawBodyRow - not per cell, which would + * put a Map lookup on every drawn cell for a check that can only change per row. */ + function syncRowDisplayCache(/** @type {number} */ idx) { + const row = rows[idx]; + if (_dispCacheRowRef.get(idx) === row) return; + if (_dispCacheRowRef.size >= _DISP_CACHE_MAX) _dispCacheRowRef.clear(); + _dispCacheRowRef.set(idx, row); + _cellTextCache.delete(idx); + _vexprTextCache.delete(idx); + _colTfCache.delete(idx); + } function cellDisplayText(/** @type {number} */ idx, /** @type {number} */ actualIdx, /** @type {unknown} */ value) { let arr = _cellTextCache.get(idx); if (!arr) { @@ -982,7 +1015,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // Whether any formatter/linkifier is enabled - gates the per-cell directive // lookup so the scroll hot path does zero extension work in the common case. - const _extActive = $derived.by(() => { void $pluginState; return anyDisplayExtEnabled(); }); + const _extActive = $derived.by(() => { void $pluginState; void $externalFormatVersion; return anyDisplayExtEnabled(); }); // Reused per-cell stats context - formatters read `.stats` synchronously and // don't retain it, so one scratch object avoids an allocation per drawn cell // while a stats-dependent extension (heatmap / annotator) is enabled. @@ -995,6 +1028,17 @@ import FilterX from "@lucide/svelte/icons/filter-x"; $effect(() => { void dataVersion; scheduleDraw(); }); let _emittedFirst = -1; let _emittedLast = -1; + // A reload that keeps the scroll position hands over a *fresh* rows array - + // for a windowed view, one holding nothing but its probe. The viewport has not + // moved, so the range is the same one the parent already heard and the dedupe + // below would swallow the emit, leaving every row under the viewport a + // skeleton until the user happened to scroll. Identity change = forget what + // was emitted, and let the next frame re-ask for the windows that matter. + $effect(() => { + void rows; + _emittedFirst = -1; + _emittedLast = -1; + }); function emitVisibleRange(first, last) { if (!windowed) return; if (first === _emittedFirst && last === _emittedLast) return; @@ -1005,14 +1049,25 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // Repaint when extension settings or column stats change - both affect drawn // cell text, badges, tints, and the header annotator strip. - $effect(() => { void $pluginState; void _colStats; scheduleDraw(); }); + // `externalFormatVersion` is the other half of the external-plugin contract: + // a plugin's directives arrive after the frame that asked for them, and this + // is what brings that frame back. + $effect(() => { void $pluginState; void $externalFormatVersion; void _colStats; scheduleDraw(); }); // Resolved canvas-grid style preset (Settings → Appearance). Read once per frame // by draw() and passed into the row context, so it never adds per-cell reactivity. - const _tableStyle = $derived(TABLE_STYLES[normalizeTableStyle($appTableStyle)]); - // Repaint the grid the moment the user switches preset. + // The zebra flag is folded in here rather than checked per cell: the preset can + // bring its own alternating shading (Striped, Dots) and the standalone setting + // adds it to any other preset, so draw() only ever reads one boolean. + const _tableStyle = $derived.by(() => { + const preset = TABLE_STYLES[normalizeTableStyle($appTableStyle)] + const zebra = preset.zebra === true || $appZebraRows === true + return zebra === (preset.zebra === true) ? preset : { ...preset, zebra } + }); + // Repaint the grid the moment the user switches preset, alignment, spacing or shading. $effect(() => { void $appTableStyle; scheduleDraw(); }); $effect(() => { void $appTableAlign; scheduleDraw(); }); + $effect(() => { void $appZebraRows; scheduleDraw(); }); // ── Search-match highlighting ────────────────────────────────────────────── // The toolbar search filters rows server-side (ILIKE, case-insensitive); @@ -1772,7 +1827,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; if (e.key === 'Tab' || e.key === 'Enter') { e.preventDefault() // Every column takes a value now, including generated ones, so Tab must - // be able to reach them — initial focus still skips them (see beginInsertRow), + // be able to reach them - initial focus still skips them (see beginInsertRow), // because overriding a sequence is the exception rather than the flow. const editableCols = columns if (!editableCols.length) return @@ -2585,7 +2640,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // ── Image / avatar cell thumbnails ─────────────────────────────────────────── // When a column's transform is avatar / image-thumb, the cell renders the image // instead of text. Source images are routinely multi-megapixel, and a decoded - // 4000×3000 JPEG costs ~48 MB of RGBA regardless of how small it is drawn — so + // 4000×3000 JPEG costs ~48 MB of RGBA regardless of how small it is drawn - so // the loader is deliberately stingy: // • only urls painted in the current frame are ever fetched (see _imgWanted), // • at most _IMG_MAX_INFLIGHT decode at a time, the rest queue, @@ -2599,7 +2654,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; const _imgCache = new Map(); /** @type {Map} per-url transient-failure retry counter */ const _imgRetry = new Map(); - /** Urls waiting out a retry backoff — not cached, not queued. @type {Set} */ + /** Urls waiting out a retry backoff - not cached, not queued. @type {Set} */ const _imgBackoff = new Set(); /** Pending retry timers, so a destroyed table cannot fire them. @type {Set} */ const _imgTimers = new Set(); @@ -2616,7 +2671,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; /** * Center-crop and downscale a loaded image onto a small canvas. Used when - * createImageBitmap is unavailable or refuses the image — it rejects + * createImageBitmap is unavailable or refuses the image - it rejects * cross-origin sources that were not served with CORS headers, and canvas * drawing has no such restriction. Falling back here (rather than caching the * full-res element) is what keeps a remote image column from retaining @@ -2660,7 +2715,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; * Begin one decode. Remote avatar CDNs (lh3.googleusercontent.com, * avatars.githubusercontent.com) throttle bursts of parallel requests and * occasionally 403 on the app-origin Referer, so a single onerror is usually - * transient — retry with backoff before giving up rather than freezing the + * transient - retry with backoff before giving up rather than freezing the * cell as "broken image" permanently. * @param {string} url */ @@ -2752,7 +2807,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; } /** - * Thumbnail for a url if one is ready. Never starts a decode itself — the draw + * Thumbnail for a url if one is ready. Never starts a decode itself - the draw * path only registers interest, and pumpCellImages decides what actually runs. * @param {string} url * @returns {ImageBitmap | HTMLCanvasElement | 'error' | null} @@ -2778,7 +2833,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; if (ready) { ctx.save(); roundRect(ctx, ix, iy, size, size, radius); ctx.clip(); - // Already a center-cropped square thumbnail — cheap blit, no per-frame resample. + // Already a center-cropped square thumbnail - cheap blit, no per-frame resample. ctx.drawImage(/** @type {CanvasImageSource} */ (thumb), ix, iy, size, size); ctx.restore(); } @@ -2811,7 +2866,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; const MAX_VIRTUAL_COLS = 5 /** Per-column logical width overrides for virtual rel columns, keyed by label (unset = auto). */ let _vrelWidths = $state(/** @type {Record} */ ({})) - // Auto width adapts to the longest label (8px/char estimate + padding), clamped 150–260px. + // Auto width adapts to the longest label (8px/char estimate + padding), clamped 150-260px. const _vrelBaseW = $derived.by(() => { if (!virtualRelCols.length) return 200 const maxChars = Math.max(...virtualRelCols.map(v => v.label.length)) @@ -2938,7 +2993,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; * bottom margin so the last row can be scrolled clear of the viewport edge. * * Embedded results get no margin. They are height-capped, not filled, so the - * margin never earns its keep there — it just left two empty row-heights + * margin never earns its keep there - it just left two empty row-heights * hanging under a short result (a one-row COUNT looked like a broken table). */ const contentHeight = $derived( @@ -3749,7 +3804,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // Modified arrows belong to the app-level handler in StudioShell, not to the // cell cursor: Cmd/Ctrl+Arrow is table-level navigation (scroll to top/bottom, // first/last column, paginate) and Alt+Arrow is Go Back / Go Forward. Let them - // bubble untouched — the old double-handling (cursor jumped one cell AND the + // bubble untouched - the old double-handling (cursor jumped one cell AND the // view navigated) is what made both shortcuts feel broken. if ( (e.ctrlKey || e.metaKey || e.altKey) && @@ -3969,7 +4024,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; * it sideways. Reserving a fixed strip instead left a permanent dead gap down * the column. * - * Must stay in step with the draw pass below — this is the click target for + * Must stay in step with the draw pass below - this is the click target for * what that paints. */ function cellButtonRects(cellX, w, ry, rh, { canExpand, alignRight = false }) { @@ -3999,6 +4054,15 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // multiply the probe fonts by canvasZoom again - that double-scales them. if (!_fonts) _fonts = readFonts(colorProbe) syncDisplayCaches() + // Shimmer phase for skeleton rows, advanced from the clock so it moves at the + // same rate whatever the frame rate. _sawSkeleton is reset here and set by any + // skeleton row painted below; the tail uses it to decide whether to keep + // animating, so a fully loaded grid schedules nothing at all. + _sawSkeleton = false + _skelMin = -1 + _skelMax = -1 + _shimmerFill = null + if (_shimmerOn) _shimmerPhase = (performance.now() % SHIMMER_PERIOD) / SHIMMER_PERIOD const W = _viewportWidth const H = _viewportHeight @@ -4025,7 +4089,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; ctx.imageSmoothingEnabled = false // The panel colour is opaque and covers the whole canvas, so this single - // fill also clears the previous frame — a separate clearRect would just be a + // fill also clears the previous frame - a separate clearRect would just be a // second full-surface pass (measurable on WebKitGTK's CPU-rendered canvas). ctx.fillStyle = cPanel ctx.fillRect(0, 0, W, H) @@ -4151,25 +4215,186 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // Now that the visible set is known, drop the backlog the viewport has moved // past and start whatever decodes fit in the remaining slots. if (_imgQueue.length) pumpCellImages() + // Keep the shimmer moving while skeleton rows are on screen, and only then. + // Capped at ~30fps: it's a slow sweep across a few grey bars, so half the + // frames look identical and the other half are free. The moment the windows + // land, _sawSkeleton stays false and this loop ends by itself. + if (_shimmerOn && _sawSkeleton) { + const now = performance.now() + if (now - _lastShimmerFrame > 32) { + _lastShimmerFrame = now + scheduleDraw() + } else if (!_shimmerTimer) { + _shimmerTimer = setTimeout(() => { _shimmerTimer = 0; scheduleDraw() }, 32) + } + } + publishLoadingSpan() + } + + // ── "What is it loading?" ───────────────────────────────────────────────── + // Skeleton bars say *that* rows are coming, never which ones or how far along, + // and on a million-row table a jump can leave a screenful of them up for a + // second with nothing to read. The rows being waited on are already known here + // - draw() paints them - so they're published to a pill instead of thrown away. + /** First/last skeleton row painted this frame. */ + let _skelMin = -1 + let _skelMax = -1 + /** When the current run of skeleton frames began (0 = none on screen). */ + let _skelSince = 0 + let _spanPublished = 0 + /** @type {ReturnType | 0} */ + let _spanTimer = 0 + /** Rows drawn as skeletons, for the pill. @type {{first:number,last:number}|null} */ + 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 + /** Reactivity budget: the pill re-reads at most this often, not per frame. */ + const SPAN_PUBLISH_MS = 150 + + /** Push (or clear) the skeleton span for the pill. Called at the end of every + * frame, so it stays off the per-cell path and costs one comparison. */ + function publishLoadingSpan() { + if (!_sawSkeleton || !windowed) { + _skelSince = 0 + if (loadingSpan) loadingSpan = null + return + } + const now = performance.now() + if (!_skelSince) _skelSince = now + if (now - _skelSince < SPAN_SHOW_AFTER) { + // Not yet worth showing. A repaint is already queued while the shimmer + // animates; with reduced motion nothing else would come back to check. + if (!_shimmerOn && !_spanTimer) { + _spanTimer = setTimeout(() => { _spanTimer = 0; scheduleDraw() }, SPAN_SHOW_AFTER) + } + return + } + if (now - _spanPublished < SPAN_PUBLISH_MS) return + _spanPublished = now + if (loadingSpan?.first === _skelMin && loadingSpan?.last === _skelMax) return + loadingSpan = { first: _skelMin, last: _skelMax } } - /** Skeleton row for a window that hasn't loaded yet (windowed mode only). */ + /** + * Deterministic 0..1 from a cell's coordinates. + * + * Deterministic matters twice over: a bar whose width changed between frames + * would flicker, and a skeleton that reshuffles on every scroll frame reads as + * the layout moving. Same cell, same width, every frame, until the real value + * replaces it. + */ + function cellNoise(/** @type {number} */ row, /** @type {number} */ col) { + const h = Math.imul(row * 73856093 ^ col * 19349663, 0x45d9f3b) + return ((h >>> 8) & 0xffff) / 0xffff + } + + /** Fraction of a frame's sweep the shimmer band covers, in viewport widths. */ + const SHIMMER_BAND = 0.28 + /** One full left-to-right pass, in ms. */ + const SHIMMER_PERIOD = 1400 + /** True while any skeleton row was painted this frame - drives the shimmer loop. */ + let _sawSkeleton = false + let _shimmerPhase = 0 + let _lastShimmerFrame = 0 + /** The frame's highlight gradient, built once and shared by every skeleton row. + * @type {CanvasGradient | null} */ + let _shimmerFill = null + /** @type {ReturnType | 0} */ + let _shimmerTimer = 0 + /** Someone who asked for less motion gets static bars, not a sweep. */ + const _shimmerOn = + typeof matchMedia !== 'function' || !matchMedia('(prefers-reduced-motion: reduce)').matches + + /** + * Skeleton row for a window that hasn't loaded yet (windowed mode only). + * + * Built to be replaced without anything appearing to move: each bar sits on the + * text baseline band, is as wide as a plausible value for that cell rather than a + * flat 50%, and is flush right in the columns whose values will be flush right. + * The old version drew identical half-width bars hard left in every column, so + * the moment real rows landed every number jumped to the other side of its cell - + * which is what read as the layout shifting. + */ function drawLoadingRow(ctx, idx, ry, rh, c) { if (c.tableStyle.zebra && (idx & 1)) { - ctx.fillStyle = withAlpha(c.cMutedBg, 0.05) + // Softer than a loaded row's stripe: a skeleton row is already busy with bars. + ctx.fillStyle = withAlpha(c.cMutedBg, ZEBRA_ALPHA * 0.6) ctx.fillRect(0, ry, c.usedW, rh) } + _sawSkeleton = true + if (_skelMin < 0 || idx < _skelMin) _skelMin = idx + if (idx > _skelMax) _skelMax = idx + // Sit on the text's own band: cap height, centred where the glyphs will be, so + // the bar and the value it becomes occupy the same pixels. + const barH = Math.max(3, Math.round(rh * 0.28)) const cy = ry + rh / 2 - const barH = Math.max(4, Math.round(6 * canvasZoom)) const gut = geom.gutterWidth - ctx.fillStyle = withAlpha(c.cMutedBg, 0.45) - for (const col of geom.cols) { + // Two passes so each opacity is one fill() for the whole row: the base bars, + // then the highlight over the same path. Per-bar fills were the most + // expensive thing on screen in exactly the state that has to feel smooth. + ctx.beginPath() + let any = false + for (let ci = 0; ci < geom.cols.length; ci++) { + const col = geom.cols[ci] const x = colDrawnX(col, geom, _scrollLeft) if (x + col.w <= gut || x >= _viewportWidth) continue - const bx = Math.max(x, gut) + CELL_PAD_X - const bw = Math.min(col.w - CELL_PAD_X * 2, Math.round(col.w * 0.5)) - if (bw > 4) { roundRect(ctx, bx, cy - barH / 2, bw, barH, barH / 2); ctx.fill() } - } + const avail = col.w - CELL_PAD_X * 2 + if (avail <= 4) continue + // 40-92% of the cell, stable per cell: text of varying length, not a barcode. + const bw = Math.max(6, Math.round(avail * (0.4 + cellNoise(idx, ci) * 0.52))) + const cellLeft = Math.max(x, gut) + const bx = _skeletonRightAligned(col) + ? x + col.w - CELL_PAD_X - bw + : cellLeft + CELL_PAD_X + if (bx + bw <= gut || bx >= _viewportWidth) continue + roundRectPath(ctx, bx, cy - barH / 2, bw, barH, barH / 2) + any = true + } + if (!any) return + ctx.fillStyle = withAlpha(c.cMutedBg, 0.4) + ctx.fill() + if (!_shimmerOn) return + // Same path again under the sweep. The gradient is transparent outside the + // band, so bars away from it are untouched without testing them. + if (!_shimmerFill) _shimmerFill = buildShimmerFill(ctx, c.cMutedBg) + ctx.fillStyle = _shimmerFill + ctx.fill() + } + + /** + * The frame's sweep, as one gradient every skeleton row fills through. + * + * Per-bar hit-testing against a band was what made this read as flicker rather + * than shimmer: a bar was either lit or not, so it *stepped* between two + * opacities, and every row stepped on the same frame - a hard-edged column of + * bars blinking on and off. A gradient ramps each bar continuously instead, and + * costs one allocation per frame rather than one per row. + * + * The axis is tilted a little so the highlight crosses the grid on a diagonal; + * a perfectly vertical edge over aligned bars is what the eye reads as a seam. + */ + function buildShimmerFill(ctx, mutedBg) { + const band = Math.max(1, _viewportWidth * SHIMMER_BAND) + // Travel a full band clear of both edges. The old sweep stopped with the band + // still half on screen and jumped back to the left, so the highlight vanished + // mid-grid once a period - a blink, exactly on the beat. + const cx = -band + _shimmerPhase * (_viewportWidth + band * 2) + const g = ctx.createLinearGradient(cx - band / 2, 0, cx + band / 2, band * 0.35) + const peak = 0.34 + g.addColorStop(0, withAlpha(mutedBg, 0)) + g.addColorStop(0.35, withAlpha(mutedBg, peak * 0.45)) + g.addColorStop(0.5, withAlpha(mutedBg, peak)) + g.addColorStop(0.65, withAlpha(mutedBg, peak * 0.45)) + g.addColorStop(1, withAlpha(mutedBg, 0)) + return g + } + + /** Whether a skeleton bar should hug the right edge, matching where the real + * value will sit. Uses the same name→index map drawCell does, so it costs a + * Map lookup rather than a scan of `columns` per bar per frame. @param {any} col */ + function _skeletonRightAligned(col) { + const actualIdx = _nameToActualIdx.get(col.name) ?? -1 + return actualIdx >= 0 && isRightAlignedColumn(actualIdx) } /** @@ -4187,12 +4412,22 @@ import FilterX from "@lucide/svelte/icons/filter-x"; if (selected.has(idx)) return withAlpha(c.cPrimary, hoveredRow === idx ? 0.18 : 0.13) if (focusedRow === idx) return withAlpha(c.cPrimary, hoveredRow === idx ? 0.09 : 0.07) if (hoveredRow === idx) return withAlpha(c.cMutedBg, 0.18) - // Zebra striping - a soft tint on odd rows. Below every interactive state - // above so selection/hover/focus always win; O(1), no per-row allocation. - if (c.tableStyle.zebra && (idx & 1)) return withAlpha(c.cMutedBg, 0.07) + // Zebra striping - a tint on odd rows. Below every interactive state above so + // selection/hover/focus always win; O(1), no per-row allocation. + if (c.tableStyle.zebra && (idx & 1)) return withAlpha(c.cMutedBg, ZEBRA_ALPHA) return null } + /** + * Alpha of the alternating-row tint, over `--muted`. + * + * Was 0.07, which in a dark theme put `--muted` a couple of values away from + * `--panel` and made the stripe invisible - the Striped preset and the + * "Alternating row colours" setting both looked like they did nothing. One + * constant so the preset and the setting can never disagree. + */ + const ZEBRA_ALPHA = 0.16 + /** Row tint for the row currently being drawn, hoisted so drawCell reads it for free. */ let _rowBg = /** @type {string | null} */ (null) @@ -4200,6 +4435,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; function drawBodyRow(ctx, idx, ry, c) { const rh = ROW_HEIGHT if (windowed && rows[idx] === undefined) { _rowBg = null; drawLoadingRow(ctx, idx, ry, rh, c); return } + syncRowDisplayCache(idx) const isSel = selected.has(idx) const isPendingDelete = hasPendingDeletes && pendingDeletes.has(idx) _rowBg = rowBgStyle(idx, c) @@ -4293,36 +4529,42 @@ import FilterX from "@lucide/svelte/icons/filter-x"; const isVHov = hoveredRow === idx && hoveredColName === _vrelHoverKeys[vi] if (!_fonts) return - // Badge: compact tag style - no border at rest, border on hover/active. + // Badge: a quiet chip that is always a chip. It used to be a fully rounded + // capsule with no border at rest, so hovering conjured a lozenge out of + // what looked like plain text, and a long table name inside a full-radius + // pill read as a balloon. Now the shape never changes on hover - only its + // surface and border strength do - and the radius matches the app's + // rounded-md, not a capsule. const badgeFontPx = Math.max(10, _fonts.cellPx - 1) - const bPadX = 10 - const bH = Math.round(badgeFontPx * 1.7) - const bR = Math.round(bH / 2) // pill - fully rounded, reads as a chip + const bPadX = Math.round(9 * canvasZoom) + const bH = Math.min(Math.round(badgeFontPx * 1.9), rh - Math.round(6 * canvasZoom)) + const bR = Math.min(Math.round(6 * canvasZoom), Math.round(bH / 2)) ctx.font = `500 ${badgeFontPx}px ${_fonts.family}` - // Consistent side gutters so the pill is centered with breathing room. - const gutter = Math.round(14 * canvasZoom) + // Consistent side gutters so the chip is centered with breathing room. + const gutter = Math.round(12 * canvasZoom) const maxLabelW = cw - gutter * 2 - bPadX * 2 const labelTxt = truncText(ctx, vc.label, maxLabelW) const textW = textWidth(ctx, labelTxt) const bW = Math.min(textW + bPadX * 2, cw - gutter * 2) - const bX = cellX + (cw - bW) / 2 - const bY = ry + (rh - bH) / 2 + const bX = Math.round(cellX + (cw - bW) / 2) + const bY = Math.round(ry + (rh - bH) / 2) if (isActive) { ctx.fillStyle = withAlpha(c.cPrimary, 0.05); ctx.fillRect(cellX, ry, cw, rh) } ctx.fillStyle = isActive - ? withAlpha(c.cPrimary, 0.15) - : isVHov ? withAlpha(c.cMutedBg, 0.6) : withAlpha(c.cMutedBg, 0.3) + ? withAlpha(c.cPrimary, 0.12) + : isVHov ? withAlpha(c.cMutedBg, 0.55) : withAlpha(c.cMutedBg, 0.32) roundRect(ctx, bX, bY, bW, bH, bR); ctx.fill() - if (isActive || isVHov) { - ctx.strokeStyle = isActive ? withAlpha(c.cPrimary, 0.45) : withAlpha(c.cMuted, 0.25) - ctx.lineWidth = 1 - roundRect(ctx, bX + 0.5, bY + 0.5, bW - 1, bH - 1, bR); ctx.stroke() - } + // Hairline at rest too, so the chip has an edge without shouting. + ctx.strokeStyle = isActive + ? withAlpha(c.cPrimary, 0.32) + : isVHov ? withAlpha(c.cMuted, 0.28) : withAlpha(c.cBorder, 0.5) + ctx.lineWidth = 1 + roundRect(ctx, bX + 0.5, bY + 0.5, bW - 1, bH - 1, bR); ctx.stroke() - ctx.fillStyle = isActive ? c.cPrimary : withAlpha(c.cFg, 0.72) + ctx.fillStyle = isActive ? c.cPrimary : withAlpha(c.cFg, isVHov ? 0.85 : 0.7) ctx.textBaseline = 'middle'; ctx.textAlign = 'center' ctx.fillText(labelTxt, bX + bW / 2, ry + rh / 2 + 0.5) } @@ -4500,14 +4742,14 @@ import FilterX from "@lucide/svelte/icons/filter-x"; !dir?.badge && !dir?.swatch && !dir?.dot && isRightAlignedColumn(actualIdx) const warnW = dir?.warn ? Math.round(14 * canvasZoom) : 0 - // The hover actions live on the side the value isn't using — right of - // left-aligned text, left of right-aligned text — so they occupy empty + // The hover actions live on the side the value isn't using - right of + // left-aligned text, left of right-aligned text - so they occupy empty // space in both cases. Only long values, the ones that would actually // collide, give up room, and only while the pointer is in the cell. const hoverW = isHover ? ICON_HIT + (canExpand ? ICON_HIT : 0) : 0 const fkW = (activeFk && rowHover) ? 20 : 0 // The same gap on both sides. Left-aligned text used to reserve 4px on the - // assumption that a value never reaches the right edge — but a *truncated* + // assumption that a value never reaches the right edge - but a *truncated* // value reaches it every time, which put the ellipsis hard against the column // divider and made every long column read as congested. const rightReserve = CELL_PAD_X + (alignRight ? 0 : hoverW) + fkW + warnW @@ -4852,25 +5094,33 @@ import FilterX from "@lucide/svelte/icons/filter-x"; } const indReserve = indicators.length * 18 - // Required marker - a red asterisk ahead of the name on NOT NULL columns. - // The same mark a required form field carries, so it reads without a legend, - // and it answers "will this row insert" from the header instead of from the - // column menu. Its width comes out of the name's budget rather than being - // painted over it, so a long name still truncates against the right edge. + // Required marker - a red asterisk on NOT NULL columns. The same mark a + // required form field carries, so it reads without a legend, and it answers + // "will this row insert" from the header instead of from the column menu. Its + // width comes out of the name's budget rather than being painted over it, so a + // long name still truncates against the right edge. ctx.font = _fonts.header const required = !!meta && !meta.nullable const reqW = required ? Math.ceil(textWidth(ctx, '*')) + Math.round(4 * canvasZoom) : 0 - // Column name - primary, medium weight. + // Column name - primary, medium weight. ALWAYS starts at CELL_PAD_X, the same + // inset the cells below use, so every header lines up with every other header + // and with its own column's values. const nameMaxW = w - CELL_PAD_X - sortReserve - indReserve - reqW - 8 const name = truncText(ctx, col.name, Math.max(0, nameMaxW)) + ctx.fillStyle = withAlpha(c.cFg, sorted ? 1 : 0.9) + ctx.fillText(name, x + CELL_PAD_X, cy + 0.5) + let tx = x + CELL_PAD_X + textWidth(ctx, name) + // The asterisk TRAILS the name. Leading it - which is what this used to do - + // indented the name by its width on required columns only, so those headers + // sat a few pixels right of every other header, and right of the values + // underneath them. That mismatch is the misalignment; the mark itself is fine. if (required) { ctx.fillStyle = withAlpha(c.RED, 0.85) - ctx.fillText('*', x + CELL_PAD_X, cy + 0.5) + ctx.fillText('*', tx + Math.round(2 * canvasZoom), cy + 0.5) + tx += reqW } - ctx.fillStyle = withAlpha(c.cFg, sorted ? 1 : 0.9) - ctx.fillText(name, x + CELL_PAD_X + reqW, cy + 0.5) - let tx = x + CELL_PAD_X + reqW + textWidth(ctx, name) + 7 + tx += 7 // PK / FK glyphs (vertically centred, accent-coloured). for (const ind of indicators) { @@ -5058,6 +5308,8 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // Thumbnails hold GPU/heap memory that is not reclaimed by dropping the // component, and pending retry timers would fire against a dead canvas. releaseCellImages() + if (_shimmerTimer) { clearTimeout(_shimmerTimer); _shimmerTimer = 0 } + if (_spanTimer) { clearTimeout(_spanTimer); _spanTimer = 0 } // Remove any window resize listeners still attached from a drag in progress. clearActiveResizeListeners() }) @@ -5066,19 +5318,27 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // but bails out immediately on non-Shift events so the compositor waits <0.05ms. // Wheel handling is split so PLAIN vertical scrolling stays on the compositor // (buttery, no per-tick main-thread round-trip). A *non-passive* wheel listener - // — needed to preventDefault ctrl-zoom and shift-horizontal — otherwise forces + // - needed to preventDefault ctrl-zoom and shift-horizontal - otherwise forces // the browser to consult JS before every scroll tick, and while the redraw loop // is busy that round-trip lands late → the exact stutter reported even on tiny // tables. So the non-passive listener is attached ONLY while Ctrl/Shift is // physically held; the rest of the time there is no blocking wheel listener at // all and the OS scrolls the container directly. + // With eased scrolling on (the default - Settings → Appearance → Native + // scrolling), the listener has to be live for EVERY tick, because the offset is + // animated here rather than by the OS. That is the cost the setting exists to + // let people opt out of: turning native scrolling on removes this listener + // except while Ctrl/Shift is held, restoring the compositor-driven path exactly + // as described above. $effect(() => { const el = tableContainer if (!el) return + const easedScroll = !$appNativeScroll // Accumulates pinch/ctrl-wheel delta so many small gesture ticks map to whole // app-zoom steps instead of one step per event. let _zoomAccum = 0 let _activeAttached = false + const scroller = easedScroll ? createSmoothScroll(el) : null function doZoom(/** @type {number} */ deltaY) { _zoomAccum += deltaY @@ -5086,30 +5346,54 @@ import FilterX from "@lucide/svelte/icons/filter-x"; while (_zoomAccum >= 24) { decreaseZoom(); _zoomAccum -= 24 } } - // Non-passive: only live while a modifier that needs preventDefault is down. + // Non-passive: live for every tick under eased scrolling, otherwise only while + // a modifier that needs preventDefault is down. const onWheelActive = (/** @type {WheelEvent} */ e) => { if (e.ctrlKey) { e.preventDefault(); doZoom(e.deltaY); return } - if (!e.shiftKey) return - const delta = e.deltaY || e.deltaX - if (!delta) return // If the pointer is over a nested horizontally-scrollable panel (the FK // sub-view), scroll that instead of the main grid. const inner = e.target instanceof Element ? e.target.closest('[data-fk-subview-scroll]') : null - if (inner && inner !== el && inner.scrollWidth > inner.clientWidth) { + if (e.shiftKey) { + // Normalize FIRST. A mouse that reports deltaMode 1 (lines) sends ±1..3, + // and treating that as pixels moved the grid by three pixels a notch - + // horizontal scrolling looked broken under eased scrolling while native + // mode looked fine, because there the browser does this conversion itself. + const { dx, dy } = wheelPixels(e, el.clientWidth) + const delta = dy || dx // shift + vertical wheel IS horizontal movement + if (!delta) return + if (inner && inner !== el && inner.scrollWidth > inner.clientWidth) { + e.preventDefault() + inner.scrollLeft += delta + return + } e.preventDefault() - inner.scrollLeft += delta + if (scroller) scroller.push(delta, 0) + else el.scrollLeft += delta return } - e.preventDefault() - el.scrollLeft += delta + if (!scroller) return + // Plain wheel, eased: animate the offset ourselves. The rAF redraw loop + // still runs off the resulting `scroll` events, so nothing else changes. + if (inner && inner !== el) return + const { dx, dy } = wheelPixels(e, el.clientHeight) + if (!dy && !dx) return + // A trackpad's horizontal swipe arrives as plain deltaX (no Shift). + if (dy && el.scrollHeight > el.clientHeight) { + e.preventDefault() + scroller.push(0, dy) + } + if (dx && el.scrollWidth > el.clientWidth) { + e.preventDefault() + scroller.push(dx, 0) + } } // Passive fallback (always on, never blocks scroll): catches a trackpad pinch, // which arrives as a synthetic ctrl+wheel with NO physical Ctrl keydown, so the // gated listener above isn't attached for it. Drives the app's own zoom without - // preventDefault — native page-zoom is already blocked (macOS + // preventDefault - native page-zoom is already blocked (macOS // setAllowsMagnification / Tauri zoom_hotkeys_enabled=false). Skips when the // active listener is attached so a real Ctrl+wheel isn't handled twice. const onWheelPassive = (/** @type {WheelEvent} */ e) => { @@ -5127,19 +5411,45 @@ import FilterX from "@lucide/svelte/icons/filter-x"; el.removeEventListener('wheel', onWheelActive) _activeAttached = false } + // Under eased scrolling the listener stays attached; the modifier gating below + // is what keeps it OFF the hot path when the OS is doing the scrolling. /** @param {KeyboardEvent} e */ - const onKey = (e) => { if (e.ctrlKey || e.shiftKey) attach(); else detach() } + const onKey = (e) => { + if (easedScroll) return + if (e.ctrlKey || e.shiftKey) attach() + else detach() + } + // A keyboard scroll, scrollIntoView, or a scrollbar drag moved the element + // without us: adopt the new position so the next wheel tick eases from there. + const onScrollSync = () => { if (scroller && !scroller.animating()) scroller.sync() } + // Blur drops the modifier-gated listener because a keyup that happens while + // another window has focus never reaches us. Under eased scrolling the + // listener is not modifier-gated and must survive - detaching it there would + // leave the grid unable to scroll until the component remounted. + const onBlur = () => { if (!easedScroll) detach() } + + // A press interrupts coasting - grabbing the scrollbar, or clicking a cell + // while the tail of an ease is still running, takes effect now. + const onInterrupt = () => { scroller?.stop(); scroller?.sync() } el.addEventListener('wheel', onWheelPassive, { passive: true }) + if (easedScroll) { + attach() + el.addEventListener('scroll', onScrollSync, { passive: true }) + el.addEventListener('pointerdown', onInterrupt, { passive: true }) + } window.addEventListener('keydown', onKey, true) window.addEventListener('keyup', onKey, true) - window.addEventListener('blur', detach) + window.addEventListener('blur', onBlur) return () => { + scroller?.stop() detach() + el.removeEventListener('pointerdown', onInterrupt) + el.removeEventListener('scroll', onScrollSync) el.removeEventListener('wheel', onWheelPassive) window.removeEventListener('keydown', onKey, true) window.removeEventListener('keyup', onKey, true) - window.removeEventListener('blur', detach) + window.removeEventListener('blur', onBlur) } }) @@ -5349,7 +5659,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; const vi = actualToVisColIdx(actualIdx) if (vi >= 0) focusedCol = vi // Clicking a cell is a deliberate jump, however short the distance, so it - // earns a back/forward entry. Arrow-key roaming deliberately does not — + // earns a back/forward entry. Arrow-key roaming deliberately does not - // that is what the parent's row-gap threshold is for. onjump() if (inspectorRow !== null) inspectorRow = idx @@ -5789,7 +6099,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; {@const blankLabel = omit === 'default' ? 'default' : omit === 'null' ? 'NULL' : 'Required'} {@const blankClass = omit === 'required' ? 'placeholder:text-muted-foreground/60' @@ -5917,9 +6227,18 @@ import FilterX from "@lucide/svelte/icons/filter-x"; {#if rows[exIdx] !== undefined} {#if _scrollScale === 1} + no per-frame re-render), sticky-left for the horizontal pin. + clip-path keeps it out of the header band: the panel sits above + the canvas (it has to - the canvas paints an opaque background), + so without the clip it drew straight over the sticky column + header as soon as its row scrolled up behind it. Only panels + actually touching the band pay anything, and only while they do. --> + {@const clipTop = Math.max(0, HEADER_H - (rowDocTop(exIdx) + ROW_HEIGHT - _scrollTop))}
-
+
{@render expandBody(exIdx)}
@@ -5928,8 +6247,13 @@ import FilterX from "@lucide/svelte/icons/filter-x"; would be tens of millions of px (past WebKit's layout range), and a far-off-screen panel there would also blow out the scroll height, so only render when the expanded row is near the viewport. --> + {@const clipTopScaled = Math.max(0, HEADER_H - (rowViewportY(exIdx) + ROW_HEIGHT))}
-
+
{@render expandBody(exIdx)}
@@ -5951,7 +6275,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; proved the cell is non-null. Committing an edit (date pick, enum pick, boolean toggle) sets `editingCell = null` synchronously, and these {@const}s are deriveds that a child's lazy prop getter can - force to revalidate before the {#if} tears this branch down — + force to revalidate before the {#if} tears this branch down - `columns[editingCell.colIdx]` then throws "null is not an object". Editing a `created_at`-style column reproduced exactly that. --> {@const ecol = columns[editingCell?.colIdx ?? -1]} @@ -6157,8 +6481,8 @@ import FilterX from "@lucide/svelte/icons/filter-x";
@@ -6173,6 +6497,52 @@ import FilterX from "@lucide/svelte/icons/filter-x";
{/if} + + {#if windowed && loadingSpan} +
+
+
+ {#if windowStatus?.failed} + + + Couldn't load rows {(loadingSpan.first + 1).toLocaleString()}-{(loadingSpan.last + 1).toLocaleString()} + + + {:else} + + + Loading rows {(loadingSpan.first + 1).toLocaleString()}-{(loadingSpan.last + 1).toLocaleString()} + of {rows.length.toLocaleString()} + + {#if windowStatus?.slow} + · slow connection + {/if} + {/if} +
+
+
+ {/if} + {#if infiniteScroll && loadingMore}
+ // Read-only properties for one database: owner, encoding, collation, size, + // open sessions. The shell runs the catalog query and hands the rows over, so + // this file has no engine knowledge in it. + import * as Dialog from '$lib/components/ui/dialog/index.js' + import Icon from './Icon.svelte' + + let { + open = $bindable(false), + name = '', + /** @type {import('$lib/database-admin.js').DbInfoRow[]} */ + rows = [], + loading = false, + error = '', + } = $props() + + + + + + + +
+
+ +
+
+ {name} +

Database properties

+
+ +
+ +
+ {#if loading} +

Reading the catalog…

+ {:else if error} +

{error}

+ {:else if rows.length === 0} +

No properties returned.

+ {:else} +
+ {#each rows as row (row.label)} +
+
{row.label}
+
{row.value}
+
+ {/each} +
+ {/if} +
+ +
+ +
+ +
+
+
diff --git a/src/lib/components/DatabaseNameDialog.svelte b/src/lib/components/DatabaseNameDialog.svelte new file mode 100644 index 00000000..ff759c4f --- /dev/null +++ b/src/lib/components/DatabaseNameDialog.svelte @@ -0,0 +1,169 @@ + + + + + + + +
+
+ +
+
+ {isRename ? 'Rename database' : 'Duplicate database'} +

+ {isRename ? 'Renames' : 'Copies'} {source} + {isRename ? 'on this server' : 'with all of its data'} +

+
+ +
+ +
+
+ + { if (e.key === 'Enter') void submit() }} + /> + {#if nameError} +

{nameError}

+ {:else if trimmed && trimmed === source} +

That is already its name

+ {/if} +
+ + {#if !isRename} +

+ The copy is taken server-side, so a large database takes a while and needs the disk space twice over. +

+ {/if} + +
+

SQL preview

+ +
{sql}
+
+ + {#if error} +

{error}

+ {/if} +
+ +
+ + +
+ +
+
+
diff --git a/src/lib/components/DateFilterControl.svelte b/src/lib/components/DateFilterControl.svelte index f8c1d858..9ec2b823 100644 --- a/src/lib/components/DateFilterControl.svelte +++ b/src/lib/components/DateFilterControl.svelte @@ -51,8 +51,8 @@ const label = $derived.by(() => { if (op === "between") { const [f, t] = (value || "").split(","); - if (f && t) return `${f} – ${t}`; - if (f) return `${f} – …`; + if (f && t) return `${f} - ${t}`; + if (f) return `${f} - …`; return "Pick range…"; } if (op === "is_not_null") return "any date"; diff --git a/src/lib/components/DateRangePicker.svelte b/src/lib/components/DateRangePicker.svelte index 37b2c54f..caaaf276 100644 --- a/src/lib/components/DateRangePicker.svelte +++ b/src/lib/components/DateRangePicker.svelte @@ -33,8 +33,8 @@ })); const label = $derived.by(() => { - if (from && to) return `${from} – ${to}`; - if (from) return `${from} – …`; + if (from && to) return `${from} to ${to}`; + if (from) return `${from} to …`; return "Pick range…"; }); diff --git a/src/lib/components/DateTimePicker.svelte b/src/lib/components/DateTimePicker.svelte index f84f1f39..fed8eb5c 100644 --- a/src/lib/components/DateTimePicker.svelte +++ b/src/lib/components/DateTimePicker.svelte @@ -43,7 +43,7 @@ const s = String(value ?? "").trim(); if (!s) return null; try { - // Epoch numbers are not parseable as `s + "T00:00:00"` — that produced an + // Epoch numbers are not parseable as `s + "T00:00:00"` - that produced an // Invalid Date, so the calendar rendered "Pick a date…" for a row that // plainly held a timestamp, and picking a date overwrote it with ISO text. if (valueFormat === "epoch-ms" || valueFormat === "epoch-s") { @@ -108,7 +108,7 @@ }); /** - * Serialise a picked date back into the format the column already stores — + * Serialise a picked date back into the format the column already stores - * see `valueFormat`. Emitting ISO into an epoch column is a silent data * corruption: the write succeeds and the timestamp is destroyed. */ @@ -121,7 +121,7 @@ if (valueFormat === "epoch-ms" || valueFormat === "epoch-s") { // An epoch always carries a time component, so honour hh:mm even when the // picker is in date-only mode. Seconds/millis are carried over from the - // stored value rather than zeroed — the picker only edits down to the + // stored value rather than zeroed - the picker only edits down to the // minute, so it must not quietly drop precision it never showed. const ms = new Date( Number(yyyy), Number(mm) - 1, Number(dd), Number(hh), Number(mi), @@ -146,7 +146,7 @@
diff --git a/src/lib/components/DdlView.svelte b/src/lib/components/DdlView.svelte index f5ea1281..d2a6be7a 100644 --- a/src/lib/components/DdlView.svelte +++ b/src/lib/components/DdlView.svelte @@ -16,7 +16,7 @@ /** @type {string} */ objectName = '', } = $props() - // Engines hand back DDL in whatever shape they stored it — SQLite returns the + // Engines hand back DDL in whatever shape they stored it - SQLite returns the // statement exactly as typed, so a one-line CREATE TABLE stays one line. // formatSql returns its input unchanged if it can't parse, so a dialect it // doesn't understand degrades to the raw DDL rather than to mangled DDL. diff --git a/src/lib/components/DropDatabaseDialog.svelte b/src/lib/components/DropDatabaseDialog.svelte new file mode 100644 index 00000000..3e6b471e --- /dev/null +++ b/src/lib/components/DropDatabaseDialog.svelte @@ -0,0 +1,101 @@ + + +{#snippet extra()} +
+ + +
+ + {#if supportsForce} +
+
+
+

Close other sessions

+

+ {sessions ? `${sessions} session(s) connected. ` : ''}Postgres refuses the drop while anything is connected. +

+
+ +
+ {/if} + +
+
+

Will execute

+
+ {sql} +
+
+{/snippet} + + onconfirm({ sql, force: supportsForce && force })} +/> diff --git a/src/lib/components/EChartPanel.svelte b/src/lib/components/EChartPanel.svelte index a11836c8..731e05d5 100644 --- a/src/lib/components/EChartPanel.svelte +++ b/src/lib/components/EChartPanel.svelte @@ -108,7 +108,7 @@ * Structural fingerprint of an option: which series it declares (type + name) * and the shape of its axes (count + type). Two options with the same * fingerprint differ only in their data, which echarts can merge in place. - * Anything a merge could not express — a dropped series, a swapped axis kind — + * Anything a merge could not express - a dropped series, a swapped axis kind - * changes the fingerprint and falls back to a full rebuild. * @param {any} o */ diff --git a/src/lib/components/EntityRelationPage.svelte b/src/lib/components/EntityRelationPage.svelte index 22701ec1..e525af17 100644 --- a/src/lib/components/EntityRelationPage.svelte +++ b/src/lib/components/EntityRelationPage.svelte @@ -18,13 +18,17 @@ import Check from '@lucide/svelte/icons/check' import LayoutGrid from '@lucide/svelte/icons/layout-grid' import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal' + import ListFilter from '@lucide/svelte/icons/list-filter' + import Crosshair from '@lucide/svelte/icons/crosshair' + import EyeOff from '@lucide/svelte/icons/eye-off' import { toast } from "$lib/components/ui/sonner/toast.svelte.js" import { svgStringToPngBlob, copyPngToClipboard } from '$lib/svg-png.js' import { Popover, PopoverTrigger, PopoverContent } from '$lib/components/ui/popover/index.js' import { isCurrentThemeDark } from '$lib/stores/settings.js' - import { loadErdSettings, saveErdSettings, SPACING_PRESETS, SCOPE_DEFAULTS } from '$lib/stores/erd-settings.js' - import { routeEdges, routeToSvgPath, MAX_ROUTED_NODES } from '$lib/erd-routing.js' - import { separateCards } from '$lib/erd-layout.js' + import { loadErdSettings, saveErdSettings, SPACING_PRESETS, SCOPE_DEFAULTS, DEFAULT_ERD_SETTINGS } from '$lib/stores/erd-settings.js' + import { routeEdges, routeToSvgPath, corridorPathOrtho, CLEAR, MAX_ROUTED_NODES } from '$lib/erd-routing.js' + import { separateCards, wrapTallRanks } from '$lib/erd-layout.js' + import { visibleTables, visibleRels, relatedTo, linkedTables, mergeParallelEdges } from '$lib/erd-filter.js' import { cn } from '$lib/utils.js' let { @@ -64,6 +68,15 @@ const HDR_H = 42 const PAD_B = 10 const WARN_MANY = 60 + /** + * Above this many tables the whole-schema diagram waits to be asked for. + * Dagre, the edge router and the cards themselves are each seconds of work at + * that size, so drawing it unprompted reads as a hang - and nobody looks for + * anything in a thousand-card diagram anyway. The table filter is the way in. + */ + const MAX_AUTO_TABLES = 240 + /** Rows rendered in the table filter at once; the search box reaches the rest. */ + const PICKER_ROWS = 200 const cfg = { NODE_W, ROW_H, HDR_H, PAD_B } @@ -92,22 +105,69 @@ */ const view = $derived.by(() => { const base = focusTable && scope !== 'all' ? SCOPE_DEFAULTS.focused : SCOPE_DEFAULTS.schema + // A wide schema opens keys-only. Full column lists make every card taller, + // and card height is what a layout pass and the edge router both pay for. + const wide = tableMeta.size > 120 && !(focusTable && scope !== 'all') return { ...settings, + columnMode: settings.touched.includes('columnMode') + ? settings.columnMode + : wide ? 'keys' : DEFAULT_ERD_SETTINGS.columnMode, spacing: settings.touched.includes('spacing') ? settings.spacing : base.spacing, routing: settings.touched.includes('routing') ? settings.routing : base.routing, } }) const gaps = $derived(SPACING_PRESETS[view.spacing] ?? SPACING_PRESETS.comfortable) + /** + * Tables the user picked by hand. Empty means "no filter" - the scope and the + * linked-only toggle decide instead. Session-only, per schema: a filter is + * something you set while reading one diagram, not a setting to inherit. + * Always replaced, never mutated, so the reads stay reactive. + * @type {Set} + */ + let picked = $state(new Set()) + /** Bumped on every pick change - the layout key can't diff a Set cheaply. */ + let pickVersion = $state(0) + let pickerOpen = $state(false) + let pickQuery = $state('') + /** Set once the user asks for a schema too big to draw unprompted. */ + let drawAll = $state(false) + /** Table count the diagram is holding back on, 0 when it is drawing. */ + let gatedCount = $state(0) + /** Every foreign key in the schema, filter-independent. @type {any[]} */ + let allRels = $state(/** @type {any[]} */ ([])) /** @type {string|null} */ let selectedTable = $state(null) /** @type {Map} */ - let tableMeta = $state(new Map()) - + // Raw as well, and for the same reason as `nodes` below: every card's columns + // and key sets are read straight out of here into the node data the canvas + // draws from, so a proxy here reaches the per-row hot path no matter what the + // node array is. Both writers below mutate and then replace the Map, which is + // what makes reactivity fire either way. + let tableMeta = $state.raw(new Map()) + + // Raw, not deep-reactive. These arrays are the canvas renderer's hot data: it + // reads n.position, n.data.columns[i].name and friends for every card and every + // row of every frame. Behind a $state proxy each of those reads is a trap, and + // every nested column object gets its own proxy allocated the first time it is + // touched - which is most of what made panning a large ER diagram stutter. + // Nothing here is ever mutated in place; every write below replaces the whole + // array, so the raw form loses no reactivity. /** @type {any[]} */ - let nodes = $state([]) + let nodes = $state.raw([]) /** @type {any[]} */ - let edges = $state([]) + let edges = $state.raw([]) + /** Foreign keys actually drawn - a merged line stands for several of them. */ + const fkCount = $derived(edges.reduce((n, e) => n + (e.mergedCount ?? 1), 0)) + /** + * Edge id → the polyline the layout reserved for it. Present only for layouts + * that route their own edges (Dagre does); empty otherwise, and the renderer + * falls back to its own line. + * @type {Map} + */ + // Raw for the same reason: the renderer looks a hint up per edge, and a proxied + // Map puts a trap on every one of those. Replaced wholesale, never mutated. + let routeHints = $state.raw(new Map()) /** @type {Map} */ const _posCache = new Map() @@ -230,7 +290,7 @@ * neighbours are far enough apart for the relationship lines between them to * have somewhere to run. Dagre reserves that space for the cards it places, * but a hand-dragged card, a re-flowed rank or a card that changed size since - * the layout ran can all break it — so the check happens here, once, on + * the layout ran can all break it - so the check happens here, once, on * whatever the layout produced. * @param {any[]} laid @param {string} [pin] a card that must not move */ @@ -239,19 +299,149 @@ const pos = separateCards( laid.map((n) => ({ id: n.id, x: n.position.x, y: n.position.y, w: NODE_W, h: hOf(n) })), { - // Wide enough for the routed lanes plus their clearance on both sides. - gapX: Math.max(gaps.colGap, 128), - gapY: Math.max(gaps.rowGap, 88), + // Wide enough for a routed lane plus its clearance on both sides, and no + // wider: this runs over grids that are already clear by construction (a + // folded rank, the orphan block), and a floor above the gaps they were + // built with would have it pull every one of those rows apart again. + gapX: Math.max(gaps.colGap, 2 * CLEAR + 12), + gapY: Math.max(gaps.rowGap, 2 * CLEAR + 8), fixed: pin ? new Set([pin]) : undefined, }, ) return laid.map((n) => ({ ...n, position: pos.get(n.id) ?? n.position })) } + /** + * Split the graph into connected components, each with its own edges. + * Union-find rather than a walk: it is one pass over the edges and the result + * does not depend on which node happens to come first. + * @param {any[]} ns @param {any[]} es + * @returns {{ nodes: any[], edges: any[] }[]} + */ + function componentsOf(ns, es) { + /** @type {Map} */ + const up = new Map(ns.map(n => [n.id, n.id])) + /** @param {string} a */ + const find = (a) => { + let r = a + while (up.get(r) !== r) r = up.get(r) ?? r + while (up.get(a) !== r) { const nx = up.get(a) ?? r; up.set(a, r); a = nx } + return r + } + for (const e of es) { + if (!up.has(e.source) || !up.has(e.target)) continue + const ra = find(e.source), rb = find(e.target) + if (ra !== rb) up.set(ra, rb) + } + /** @type {Map} */ + const groups = new Map() + for (const n of ns) { + const r = find(n.id) + let g = groups.get(r) + if (!g) { g = { nodes: [], edges: [] }; groups.set(r, g) } + g.nodes.push(n) + } + for (const e of es) { + if (!up.has(e.source)) continue + groups.get(find(e.source))?.edges.push(e) + } + return [...groups.values()] + } + + /** + * Dagre one component, normalised to its own top-left corner. + * + * The bounding box covers the edge corridors as well as the cards, because a + * corridor that leaves the cards' box would otherwise be packed on top of the + * next component. + * + * A rank taller than the page is folded into sub-columns afterwards (see + * wrapTallRanks): one rank per column is what turns a hub schema into a 1:10 + * vertical strip. A component that had to fold reports `wrapped`, and gives up + * its corridors with it - the space they were reserved in has moved, so the + * caller has to route those lines instead of following them. + * @param {any[]} ns @param {any[]} es + */ + function layoutComponent(ns, es) { + const g = new dagre.graphlib.Graph({ multigraph: true }) + // edgesep is what keeps two foreign keys between the same pair of tables in + // separate corridors instead of one line drawn twice. + g.setGraph({ + rankdir: 'LR', ranksep: gaps.rankSep, nodesep: gaps.nodeSep, + edgesep: Math.max(20, Math.round(gaps.nodeSep / 2)), marginx: 0, marginy: 0, + }) + g.setDefaultEdgeLabel(() => ({})) + for (const n of ns) g.setNode(n.id, { width: NODE_W, height: hOf(n) }) + // Named edges: unnamed ones collapse per pair, so two foreign keys between + // the same tables would share one corridor and one set of markers. Dagre + // needs a multigraph to accept a name. + for (const e of es) if (e.source !== e.target) g.setEdge(e.source, e.target, {}, e.id) + dagre.layout(g) + + // Dagre centres; everything below works in top-left, like the cards do. + const laid = ns.map((n) => { + const p = g.node(n.id), h = hOf(n) + return { id: n.id, x: p.x - NODE_W / 2, y: p.y - h / 2, w: NODE_W, h } + }) + // rowGap, not nodeSep: a folded diagram gives up the layout's corridors, so + // its own lines get routed - and a routed line needs a channel wider than + // twice the clearance it keeps from the cards either side of it. + const folded = wrapTallRanks(laid, { gapX: gaps.colGap, gapY: gaps.rowGap }) + const at = (/** @type {string} */ id) => + folded.pos.get(id) ?? { x: 0, y: 0 } + + /** @type {Map} */ + const raw = new Map() + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity + for (const c of laid) { + const q = at(c.id) + minX = Math.min(minX, q.x); maxX = Math.max(maxX, q.x + c.w) + minY = Math.min(minY, q.y); maxY = Math.max(maxY, q.y + c.h) + } + if (!folded.moved) { + for (const e of es) { + if (e.source === e.target) continue + const pts = g.edge({ v: e.source, w: e.target, name: e.id })?.points + if (!pts || pts.length < 2) continue + raw.set(e.id, pts) + for (const q of pts) { + minX = Math.min(minX, q.x); maxX = Math.max(maxX, q.x) + minY = Math.min(minY, q.y); maxY = Math.max(maxY, q.y) + } + } + } + + /** @type {Map} */ + const pos = new Map() + for (const c of laid) { + const q = at(c.id) + pos.set(c.id, { x: Math.round(q.x - minX), y: Math.round(q.y - minY) }) + } + /** @type {Map} */ + const hints = new Map() + for (const [id, pts] of raw) { + hints.set(id, pts.map((/** @type {{x:number,y:number}} */ q) => ({ + x: Math.round(q.x - minX), y: Math.round(q.y - minY), + }))) + } + return { + pos, hints, ids: ns.map(n => n.id).sort(), wrapped: folded.moved, + w: Math.round(maxX - minX), h: Math.round(maxY - minY), + } + } + + /** + * @param {any[]} ns @param {any[]} es + * @returns {{ nodes: any[], hints: Map }} + */ function layoutNodes(ns, es) { + /** @type {Map} */ + const placedConn = new Map() + /** @type {Map} */ + const hints = new Map() if (focusTable && scope === 'related' && ns.length > 1) { const laid = layoutFocus(ns, es, focusTable) - if (laid) return enforceSpacing(laid, focusTable) + if (laid) return { nodes: enforceSpacing(laid, focusTable), hints } } const linked = new Set() for (const e of es) { linked.add(e.source); linked.add(e.target) } @@ -263,77 +453,61 @@ let bottomY = 0 if (conn.length) { - const g = new dagre.graphlib.Graph() - g.setGraph({ rankdir: 'LR', ranksep: gaps.rankSep, nodesep: gaps.nodeSep, marginx: 80, marginy: 80 }) - g.setDefaultEdgeLabel(() => ({})) - for (const n of conn) { - g.setNode(n.id, { width: NODE_W, height: hOf(n) }) - } - for (const e of es) g.setEdge(e.source, e.target) - dagre.layout(g) - - // Dagre already minimises edge crossings - so for a normal schema we trust - // its coordinates directly and the graph reads clean (few/no crossings). - // The one case it handles poorly is a single rank that fans into a huge - // vertical stack (a hub referenced by dozens of tables): there we re-flow - // that rank into height-capped sub-columns so it becomes a readable grid - // instead of one unreadable tall smear. - /** @type {Map} */ - const ranks = new Map() - for (const n of conn) { - const p = g.node(n.id) - const key = Math.round(p.x) - if (!ranks.has(key)) ranks.set(key, []) - ranks.get(key)?.push({ n, y: p.y, h: hOf(n) }) - } - /** @type {Map} */ - const placed = new Map() - const MAX_STACK = 10 - const oversized = [...ranks.values()].some((items) => items.length > MAX_STACK) - - if (!oversized) { - // Honest Dagre layout: convert node centres → top-left and normalise so the - // graph begins a little in from the origin. This is what removes the - // crossings the sub-column re-pack used to introduce. - let minX = Infinity, minY = Infinity - for (const n of conn) { - const p = g.node(n.id) - const h = hOf(n) - minX = Math.min(minX, p.x - NODE_W / 2) - minY = Math.min(minY, p.y - h / 2) - } - for (const n of conn) { - const p = g.node(n.id) - const h = hOf(n) - placed.set(n.id, { - x: Math.round(p.x - NODE_W / 2 - minX + 40), - y: Math.round(p.y - h / 2 - minY + 40), - }) + // One layout per connected component, packed onto shelves. + // + // A schema is rarely one graph: it is a few clusters plus a lot of small + // pairs. Laying them out together puts every cluster in the same rank + // columns, which is why the diagram came out mostly empty space with the + // cards small enough to be unreadable at fit-zoom. Graphviz calls this + // `pack`, and it is the difference between a wall and a page. Smaller + // graphs also lay out faster than one big one, since Dagre is superlinear. + const comps = componentsOf(conn, es).map(cp => layoutComponent(cp.nodes, cp.edges)) + comps.sort((x, y) => y.h - x.h || y.w - x.w || (x.ids[0] < y.ids[0] ? -1 : 1)) + // All or nothing on corridors. The renderer decides per diagram whether to + // follow the layout's lines or route its own around the cards, so a single + // folded component - whose corridors are stale - means every line gets + // routed. Which is the better half of the trade anyway: a folded diagram is + // compact enough for the router to be quick. + const keepHints = !comps.some(cp => cp.wrapped) + + // Shelves of columns, aimed at a landscape page. Tallest component first + // sets the shelf height, and everything that follows stacks up its column + // while it fits - a plain row of components would leave the whole strip + // beside a tall cluster empty, which is most of the page on a schema with + // one big hub and a tail of small pairs. + const area = comps.reduce((sum, cp) => sum + (cp.w + gaps.colGap) * (cp.h + gaps.rowGap), 0) + const targetW = Math.max(comps[0]?.w ?? 0, Math.round(Math.sqrt(area * (16 / 9)))) + const gapX = gaps.colGap * 2 + const gapY = gaps.rowGap * 2 + let shelfY = 40, shelfH = 0 + let colX = 40, colY = 40, colW = 0 + for (const cp of comps) { + if (colY > shelfY && colY + cp.h > shelfY + shelfH) { + // Column full: start the next one, or the next shelf if we are at the + // page's right edge. + colX += colW + gapX + colY = shelfY + colW = 0 + if (colX > 40 && colX + cp.w > targetW) { + shelfY += shelfH + gapY + colX = 40 + colY = shelfY + shelfH = 0 + } } - } else { - // Huge fan: re-flow each rank into sub-columns capped at TARGET_H so a hub - // spreads into a grid. Generous gaps keep cards from crowding and give - // edges room; RANK_GAP stays modest so a deep graph doesn't stretch into an - // unreadable sliver. - const TARGET_H = 2600 - let cursorX = 40 - for (const key of [...ranks.keys()].sort((a, b) => a - b)) { - const items = (ranks.get(key) ?? []).sort((a, b) => a.y - b.y) - const maxH = items.reduce((m, it) => Math.max(m, it.h), HDR_H) - const rows = Math.max(1, Math.floor(TARGET_H / (maxH + gaps.rowGap))) - const cols = Math.ceil(items.length / rows) - const colY = new Array(cols).fill(40) // per sub-column running Y (packs tight) - for (let i = 0; i < items.length; i++) { - const col = Math.floor(i / rows) - placed.set(items[i].n.id, { x: cursorX + col * (NODE_W + gaps.colGap), y: colY[col] }) - colY[col] += items[i].h + gaps.rowGap + for (const [id, q] of cp.pos) placedConn.set(id, { x: q.x + colX, y: q.y + colY }) + if (keepHints) { + for (const [id, pts] of cp.hints) { + hints.set(id, pts.map(q => ({ x: q.x + colX, y: q.y + colY }))) } - cursorX += cols * (NODE_W + gaps.colGap) + gaps.rankSep } + colY += cp.h + gapY + colW = Math.max(colW, cp.w) + shelfH = Math.max(shelfH, colY - shelfY - gapY) } laidConn = conn.map(n => { - const pos = placed.get(n.id) ?? { x: 0, y: 0 } + const pos = placedConn.get(n.id) ?? { x: 0, y: 0 } bottomY = Math.max(bottomY, pos.y + hOf(n)) return { ...n, position: pos } }) @@ -351,7 +525,11 @@ }, })) - return enforceSpacing([...laidConn, ...laidOrphans]) + const combined = [...laidConn, ...laidOrphans] + // Dagre separates the cards it places, and the orphan grid below uses a + // uniform cell, so both are clear by construction. Nudging them now would + // move cards off the corridors their edge points describe. + return { nodes: hints.size ? combined : enforceSpacing(combined), hints } } // ── Edge list ───────────────────────────────────────────────────────────── @@ -359,8 +537,6 @@ function buildEdgeData(all) { /** @type {any[]} */ const rawEdges = [] - /** @type {Set} */ - const connected = new Set() for (const t of all) { for (const col of t.columns) { if (!col.foreignKey) continue @@ -382,41 +558,46 @@ many: !unique, optional: col.isNullable === true, }) - connected.add(t.name) - connected.add(refTable) } } - return { rawEdges, connected } + return { rawEdges } } // ── Build graph ─────────────────────────────────────────────────────────── /** @param {boolean} [forceLayout] */ function buildGraph(forceLayout = false) { const all = [...tableMeta.values()] - const { rawEdges, connected } = buildEdgeData(all) - const scoped = focusTable && tableMeta.has(focusTable) - let visible - if (scoped && scope === 'self') { - visible = all.filter(t => t.name === focusTable) - } else if (scoped && scope === 'related') { - // Per-table ERD: the focused table + every table directly FK-connected to it. - const neighbors = new Set([focusTable]) - for (const e of rawEdges) { - if (e.source === focusTable) neighbors.add(e.target) - if (e.target === focusTable) neighbors.add(e.source) - } - visible = all.filter(t => neighbors.has(t.name)) - } else { - visible = all.filter(t => !connectedOnly || connected.has(t.name)) - } - const visibleIds = new Set(visible.map(t => t.name)) - let filteredEdges = rawEdges.filter(e => visibleIds.has(e.source) && visibleIds.has(e.target)) - // In the per-table view, only the focused table's own relationships are drawn. - // Neighbour-to-neighbour FKs would add long runs across the whole canvas that - // say nothing about the table you opened. - if (scoped && scope === 'related') { - filteredEdges = filteredEdges.filter(e => e.source === focusTable || e.target === focusTable) + const { rawEdges } = buildEdgeData(all) + allRels = rawEdges + const pick = picked.size ? picked : null + const visibleIds = visibleTables({ + tables: all.map(t => t.name), + rels: rawEdges, + focusTable, + scope, + connectedOnly, + picked: pick, + }) + + // A schema too wide to draw in one go waits for the user to narrow it or to + // ask for all of it. Bailing here is what keeps the app from spending the + // next ten seconds laying out a diagram nobody can read. + if (!pick && !drawAll && visibleIds.size > MAX_AUTO_TABLES) { + gatedCount = visibleIds.size + nodes = [] + edges = [] + return } + gatedCount = 0 + + const visible = all.filter(t => visibleIds.has(t.name)) + // One line per pair, not one per foreign key column: see mergeParallelEdges. + // Merging before the layout runs also means Dagre and the router each have + // fewer edges to work through, which is the difference between a schema + // finishing its routing budget and running out of it. + const filteredEdges = mergeParallelEdges( + visibleRels(rawEdges, visibleIds, { focusTable, scope, picked: pick }), + ) const keysOnly = view.columnMode === 'keys' const rawNodes = visible.map(t => { @@ -445,8 +626,9 @@ const needsLayout = forceLayout || visible.some(t => !_posCache.has(t.name)) if (needsLayout) { const laid = layoutNodes(rawNodes, filteredEdges) - for (const n of laid) _posCache.set(n.id, n.position) - nodes = laid + for (const n of laid.nodes) _posCache.set(n.id, n.position) + nodes = laid.nodes + routeHints = laid.hints } else { nodes = rawNodes } @@ -467,9 +649,11 @@ // Scope / card geometry / gutters all change the shape of the graph, so they // need a fresh layout rather than a rebuild on cached positions. - let _shapeKey = untrack(() => `${scope}|${view.columnMode}|${view.spacing}|${connectedOnly}`) + const shapeKey = () => + `${scope}|${view.columnMode}|${view.spacing}|${connectedOnly}|${pickVersion}|${drawAll}` + let _shapeKey = untrack(shapeKey) $effect(() => { - const key = `${scope}|${view.columnMode}|${view.spacing}|${connectedOnly}` + const key = shapeKey() if (key === _shapeKey) return _shapeKey = key if (tableMeta.size === 0) return @@ -506,6 +690,12 @@ const p = { x: Math.round(x), y: Math.round(y) } _posCache.set(id, p) nodes = nodes.map((n) => (n.id === id ? { ...n, position: p } : n)) + // The layout's corridors described where this card used to be. + if (routeHints.size) { + const next = new Map(routeHints) + for (const e of edges) if (e.source === id || e.target === id) next.delete(e.id) + routeHints = next + } } // ── Load ────────────────────────────────────────────────────────────────── @@ -515,12 +705,17 @@ tableMeta = new Map() nodes = [] edges = [] + // A filter belongs to the schema it was picked in. + picked = new Set() + pickQuery = '' + drawAll = false + gatedCount = 0 _posCache.clear() try { // One call for the whole schema. This used to fan out one request per // table, which meant the diagram couldn't start drawing until N round - // trips had completed — the dominant cost on any non-trivial schema. + // trips had completed - the dominant cost on any non-trivial schema. const schemaCols = await getSchemaColumnStructure(activeSchema) autoConnected = schemaCols.length > WARN_MANY @@ -539,6 +734,7 @@ tableMeta = new Map(tableMeta) await tick() buildGraph(true) + _shapeKey = shapeKey() void refineKeys() } catch (e) { error = String(e) @@ -635,6 +831,64 @@ onopentable?.(activeSchema, name, duplicate ? { duplicate: true, viewMode: 'table' } : undefined) } + /** + * Apply a table filter. An empty set means no filter; a set naming every table + * is the same thing, so it collapses to empty rather than pinning the filter on + * with nothing filtered. + * @param {Iterable} names + */ + function setPicked(names) { + const next = new Set(names) + picked = next.size >= tableMeta.size ? new Set() : next + pickVersion += 1 + // An explicit choice is the point the gate was waiting for. + if (picked.size) { drawAll = false; gatedCount = 0 } + } + + /** @param {string} name */ + function togglePicked(name) { + // From "no filter", the first tick means "just this one" - starting from + // every table selected and asking the user to untick 900 of them is not a + // filter, it's a chore. + if (!picked.size) { setPicked([name]); return } + const next = new Set(picked) + if (next.has(name)) next.delete(name) + else next.add(name) + if (!next.size) { picked = new Set(); pickVersion += 1; return } + setPicked(next) + } + + /** Show only this table and whatever it is FK-linked to. @param {string} name */ + function isolate(name) { + setPicked(relatedTo([name], allRels)) + pickerOpen = false + } + + /** Drop one table from the diagram. @param {string} name */ + function hideTable(name) { + const base = picked.size ? picked : new Set(tableMeta.keys()) + const next = new Set(base) + next.delete(name) + if (!next.size) return + setPicked(next) + if (selectedTable === name) selectedTable = null + } + + /** Table rows for the filter, with the numbers that help pick: columns + FKs. */ + const pickerRows = $derived.by(() => { + const q = pickQuery.trim().toLowerCase() + const rows = [] + for (const t of tableMeta.values()) { + if (q && !t.name.toLowerCase().includes(q)) continue + rows.push({ + name: t.name, + cols: t.columns.length, + fks: t.columns.filter(c => c.foreignKey).length, + }) + } + return rows.sort((a, b) => a.name.localeCompare(b.name)) + }) + /** @param {Partial} patch */ function updateSettings(patch) { settings = saveErdSettings(patch) @@ -658,7 +912,7 @@ // ── Export / Download ───────────────────────────────────────────────────── // The export used to render on a fixed dark sheet with hardcoded ink, so a // diagram exported from a light theme came back as someone else's dark - // diagram. It samples the running theme instead — same tokens the canvas + // diagram. It samples the running theme instead - same tokens the canvas // resolves, so the file matches what's on screen. // // Ink that carries meaning (PK amber, FK blue, focus teal) stays authored here @@ -785,38 +1039,71 @@ const ti = tcols.findIndex(c => `tgt-${c.name}` === e.targetHandle) const rowY = (/** @type {number} */ y, /** @type {number} */ i) => y + HDR_H + (i >= 0 ? i * ROW_H + ROW_H / 2 : -HDR_H / 2) - const leftToRight = tn.position.x >= sn.position.x - links.push({ - id: e.id, - source: e.source, - target: e.target, - sx: sn.position.x + dx + (leftToRight ? NODE_W : 0), - sy: rowY(sn.position.y + dy, ci), - tx: tn.position.x + dx + (leftToRight ? 0 : NODE_W), - ty: rowY(tn.position.y + dy, ti), - sdir: leftToRight ? 1 : -1, - tdir: leftToRight ? -1 : 1, - }) + // Same rule as the canvas: a side port where the cards are separated + // horizontally, the near horizontal edge where they overlap. + const sx0 = sn.position.x + dx, tx0 = tn.position.x + dx + const sy0 = sn.position.y + dy, ty0 = tn.position.y + dy + const toRight = tx0 - (sx0 + NODE_W) + const toLeft = sx0 - (tx0 + NODE_W) + if (toRight >= 0 || toLeft >= 0) { + const leftToRight = toRight >= toLeft + links.push({ + id: e.id, source: e.source, target: e.target, + sx: sx0 + (leftToRight ? NODE_W : 0), sy: rowY(sy0, ci), + tx: tx0 + (leftToRight ? 0 : NODE_W), ty: rowY(ty0, ti), + sdx: leftToRight ? 1 : -1, sdy: 0, + tdx: leftToRight ? -1 : 1, tdy: 0, + }) + } else { + const down = ty0 >= sy0 + links.push({ + id: e.id, source: e.source, target: e.target, + sx: sx0 + NODE_W / 2, sy: down ? sy0 + nodeH(sn.data) : sy0, + tx: tx0 + NODE_W / 2, ty: down ? ty0 : ty0 + nodeH(tn.data), + sdx: 0, sdy: down ? 1 : -1, + tdx: 0, tdy: down ? -1 : 1, + }) + } } - const routed = routeEdges(boxes, links) + // Same lines the canvas draws: the layout's own corridors where it routed + // them, an A* route where it didn't and the graph is small enough to afford + // one. An export that disagrees with the screen is worse than a plain one. + const hinted = routeHints.size > 0 + const routed = hinted || links.length > MAX_ROUTED_NODES + ? new Map() + : routeEdges(boxes, links) // One path element for every line, so a crossing never paints twice as bright. const lineD = [] const markD = [] + /** @type {{x:number,y:number,n:number}[]} */ + const countD = [] for (const l of links) { - const pts = routed.get(l.id) - lineD.push(pts - ? routeToSvgPath(pts) - : `M${l.sx} ${l.sy} C${(l.sx + l.tx) / 2} ${l.sy} ${(l.sx + l.tx) / 2} ${l.ty} ${l.tx} ${l.ty}`) - const a0 = pts?.[0] ?? { x: l.sx, y: l.sy } - const a1 = pts?.[1] ?? { x: l.sx + l.sdir, y: l.sy } - const z0 = pts?.[pts.length - 1] ?? { x: l.tx, y: l.ty } - const z1 = pts?.[pts.length - 2] ?? { x: l.tx + l.tdir, y: l.ty } + const hint = routeHints.get(l.id) + const shifted = hint?.map(p => ({ x: p.x + dx, y: p.y + dy })) + const pts = routed.get(l.id) ?? corridorPathOrtho(l, shifted) + lineD.push(routeToSvgPath(pts)) + const a0 = pts[0], a1 = pts[1] ?? a0 + const z0 = pts[pts.length - 1], z1 = pts[pts.length - 2] ?? z0 const edge = edges.find(e => e.id === l.id) markD.push(svgMarker(a0, a1, edge?.many === false ? 'bar' : 'fork', false)) markD.push(svgMarker(z0, z1, 'bar', edge?.optional === true)) + // Same chip the canvas draws, so a merged pair reads the same on paper. + const merged = edge?.mergedCount ?? 1 + if (merged > 1) { + const m = Math.hypot(a1.x - a0.x, a1.y - a0.y) || 1 + const cx = a0.x + ((a1.x - a0.x) / m) * 30 + const cy = a0.y + ((a1.y - a0.y) / m) * 30 - (Math.abs(a1.y - a0.y) < 0.5 ? 10 : 0) + countD.push({ x: cx, y: cy, n: merged }) + } } o.push(``) o.push(``) + for (const chip of countD) { + const label = `\u00d7${chip.n}` + const w = 9 + label.length * 5 + o.push(``) + o.push(`${label}`) + } for (const n of vis) { const cols = n.data?.columns ?? [] @@ -1063,6 +1350,112 @@ {/if}
+ + + + + {#if picked.size} + {picked.size} + {/if} + + +
+
+ + + { + if (e.key === 'Enter' && pickerRows.length) { togglePicked(pickerRows[0].name); e.preventDefault() } + }} + class="h-7 w-full rounded-md border border-input bg-input/30 pl-7 pr-2 text-ui-sm outline-none placeholder:text-muted-foreground/45 focus:border-ring/55" + /> +
+
+ +
+ {#each [ + { label: 'All', title: 'Clear the filter', on: () => { picked = new Set(); pickVersion += 1 }, off: !picked.size }, + { label: 'Linked', title: 'Only tables with a foreign key', on: () => setPicked(linkedTables(allRels)), off: false }, + { label: '+ Related', title: 'Add everything the picked tables link to', on: () => setPicked(relatedTo(picked, allRels)), off: !picked.size }, + ] as act (act.label)} + + {/each} + + {picked.size || tableMeta.size}/{tableMeta.size} + +
+ +
+ {#if !picked.size} +

+ No filter - every table the scope allows is on the diagram. Tick one to narrow it. +

+ {/if} + {#each pickerRows.slice(0, PICKER_ROWS) as row (row.name)} + {@const on = picked.has(row.name)} +
+ + {#if row.fks} + {row.fks} fk + {/if} + +
+ {/each} + {#if pickerRows.length > PICKER_ROWS} +

+ {pickerRows.length - PICKER_ROWS} more - narrow the search to reach them. +

+ {:else if !pickerRows.length} +

No table matches “{pickQuery}”.

+ {/if} +
+
+
+ {#if focusTable} @@ -1102,8 +1495,19 @@
{#if tableMeta.size > 0 && !loading} - {nodes.length}/{tableMeta.size} tables · {edges.length} fk + {nodes.length}/{tableMeta.size} tables · {fkCount} fk + {#if picked.size} + + {/if} {/if}
@@ -1146,14 +1550,14 @@ Relationships {@render segmented( [ - { value: 'smart', label: 'Routed', hint: 'Route lines around tables' }, - { value: 'direct', label: 'Direct', hint: 'Straight elbows - faster on huge diagrams' }, + { value: 'smart', label: 'Routed', hint: 'Follow the corridors the layout kept clear of the cards' }, + { value: 'direct', label: 'Direct', hint: 'Straight lines - fastest on a huge diagram' }, ], view.routing, (v) => updateSettings({ routing: /** @type {'smart'|'direct'} */ (v) }), )}
- {#if view.routing === 'smart' && nodes.length > MAX_ROUTED_NODES} + {#if view.routing === 'smart' && !routeHints.size && nodes.length > MAX_ROUTED_NODES}

Drawing direct lines: routing is capped at {MAX_ROUTED_NODES} tables and this view has {nodes.length}.

@@ -1237,6 +1641,7 @@ selectedId={selectedTable} {focusId} routing={view.routing} + hints={routeHints} showTypes={view.showTypes} grid={view.grid} onselect={(id) => (selectedTable = id)} @@ -1244,6 +1649,39 @@ onnodemoved={onNodeMoved} /> + {#if gatedCount > 0} + +
+
+ +

+ {gatedCount} tables in {activeSchema} +

+

+ A diagram this wide takes seconds to lay out and is hard to read once it lands. + Pick the tables worth seeing, or draw the whole schema anyway. +

+
+ + +
+
+
+ {/if} + {#if tableMeta.size === 0 && !loading}
@@ -1253,7 +1691,7 @@ {/if} - {#if tableMeta.size > 0} + {#if nodes.length > 0}
PK @@ -1360,12 +1798,27 @@ {/if}
-
+
+ +
{/if} diff --git a/src/lib/components/ErdCanvas.svelte b/src/lib/components/ErdCanvas.svelte index 7bba2e6e..817e439c 100644 --- a/src/lib/components/ErdCanvas.svelte +++ b/src/lib/components/ErdCanvas.svelte @@ -3,7 +3,7 @@ import Plus from '@lucide/svelte/icons/plus' import Minus from '@lucide/svelte/icons/minus' import Maximize from '@lucide/svelte/icons/maximize' - import { routeEdges, CORNER, MAX_ROUTED_NODES } from '$lib/erd-routing.js' + import { createRouteRun, corridorPathOrtho, pathHitsRect, CORNER, MAX_ROUTED_NODES } from '$lib/erd-routing.js' /** * High-FPS canvas ER renderer. Consumes the same node/edge model the page @@ -24,6 +24,9 @@ focusId = /** @type {string|null} */ (null), /** 'smart' routes edges around cards; 'direct' is the cheap elbow (huge schemas). */ routing = /** @type {'smart'|'direct'} */ ('smart'), + /** Edge id → the polyline the layout reserved for it, when it routes its own + * edges. Present means curves along those corridors and no A* at all. */ + hints = /** @type {Map} */ (new Map()), showTypes = true, grid = true, onselect = /** @type {(id:string|null)=>void} */ (() => {}), @@ -62,6 +65,7 @@ // Level-of-detail thresholds (world→screen zoom). const LOD_ROWS = 0.5 // draw full column rows at/above this const LOD_NAME = 0.16 // draw only the table name below LOD_ROWS + const LOD_BARS = 0.18 // draw rows as bars (no text) below LOD_ROWS // ── Interaction state ──────────────────────────────────────────────────── let hoveredId = $state(/** @type {string|null} */ (null)) @@ -148,11 +152,28 @@ pal.edge = resolveColor(tone.edge) pal.edgeSoft = resolveColor(tone.edgeSoft) pal.focus = resolveColor(tone.focus) + refreshRowStyles() markDirty() } /** @param {string} name @param {number} a */ const c = (name, a = 1) => `rgba(${pal[name] ?? '128,128,128'},${a})` + // Row styles, assembled once per theme instead of once per column drawn. + let FONT_ROW = '', FONT_ROW_PK = '', FONT_TYPE = '', FONT_BADGE = '' + let PK_NAME = '', FK_NAME = '', PK_WASH = '', FK_WASH = '', PK_INK = '', FK_INK = '' + function refreshRowStyles() { + FONT_ROW = `12px ${FONT_SANS}` + FONT_ROW_PK = `600 12px ${FONT_SANS}` + FONT_TYPE = `11px ${FONT_MONO}` + FONT_BADGE = `700 9px ${FONT_MONO}` + PK_NAME = `rgba(${PK},0.95)` + FK_NAME = `rgba(${FK},0.95)` + PK_WASH = `rgba(${PK},0.16)` + FK_WASH = `rgba(${FK},0.14)` + PK_INK = `rgba(${PK},1)` + FK_INK = `rgba(${FK},1)` + } + // ── Geometry helpers ─────────────────────────────────────────────────── // A card with hidden columns gets one extra "+N more" row, so its height must // match what the page's layout pass reserved for it. @@ -164,6 +185,16 @@ /** @type {Map} */ let byId = new Map() + /** + * node id → handle name → row index. Resolving a handle by scanning the column + * list built a template string per column per edge per frame - tens of + * thousands of throwaway strings a frame on a schema-sized diagram, which is + * most of what made panning feel heavy. + * @type {Map>} + */ + let handleIdx = new Map() + /** Bumped whenever the node set changes - cache keys hang off it. */ + let graphVersion = 0 // Auto-fit the first time nodes populate (they load async, after mount), then // never again until the graph empties - so search/rebuild doesn't reset the view. let _fitted = false @@ -218,11 +249,23 @@ } $effect(() => { byId = new Map(nodes.map((n) => [n.id, n])) + handleIdx = new Map() + for (const n of nodes) { + /** @type {Map} */ + const m = new Map() + const cols = n.data?.columns ?? [] + for (let i = 0; i < cols.length; i++) { + m.set(`src-${cols[i].name}`, i) + m.set(`tgt-${cols[i].name}`, i) + } + handleIdx.set(n.id, m) + } + graphVersion += 1 if (!nodes.length) _fitted = false ensureFit() markDirty() }) - $effect(() => { void edges; void selectedId; void focusId; void routing; void showTypes; void grid; markDirty() }) + $effect(() => { void edges; void selectedId; void focusId; void routing; void hints; void showTypes; void grid; markDirty() }) // ── Render scheduling ─────────────────────────────────────────────────── let rafId = 0 @@ -239,6 +282,7 @@ // never depend on resolving --background (which can misparse per webview). ctx.setTransform(dpr, 0, 0, dpr, 0, 0) ctx.clearRect(0, 0, cssW, cssH) + forgetStyle() // World transform - everything below is drawn in world coordinates. ctx.setTransform(dpr * zoom, 0, 0, dpr * zoom, dpr * panX, dpr * panY) @@ -267,7 +311,7 @@ /** @param {number} x0 @param {number} y0 @param {number} x1 @param {number} y1 */ function drawGrid(x0, y0, x1, y1) { const G = 22 - ctx.fillStyle = c('mfg', tone.grid) + setFill(c('mfg', tone.grid)) const sx = Math.floor(x0 / G) * G, sy = Math.floor(y0 / G) * G for (let x = sx; x < x1; x += G) for (let y = sy; y < y1; y += G) ctx.fillRect(x, y, 1, 1) @@ -285,75 +329,243 @@ ctx.closePath() } - /** Stable non-negative hash of a string - used to fan parallel edges into - * separate lanes without any global bookkeeping. */ - function hashStr(/** @type {string} */ s) { - let h = 0 - for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0 - return Math.abs(h) - } - // ── Edge routing ──────────────────────────────────────────────────────── // Geometry lives in $lib/erd-routing.js (shared with the SVG/PNG export). // Routes are built once per layout - never per frame - and cached; anything // the router can't place falls back to a plain elbow below. /** @type {Map} */ let routes = new Map() - let routeKey = '' + /** + * Routing state per layout signature. Crossing the row zoom threshold changes + * the ports and so the signature, and a zoom gesture can cross it twice in a + * second - keeping the last few runs means going back to a layout already + * routed is free instead of another second of A*. + * @type {Map, + * run: ReturnType|null, spent: number}>} + */ + const routeCache = new Map() + const ROUTE_CACHE_MAX = 3 + /** Milliseconds of A* allowed per frame, and in total, per layout. */ + const ROUTE_BUDGET_MS = 8 + const ROUTE_MAX_MS = 2500 + /** 0-100 while a run is in flight, -1 when there is nothing left to route. */ + let routePct = $state(-1) + /** Cards and routes the last run was built from, for the incremental path. */ + /** @type {Map|null} */ + let lastBoxes = null + /** @type {Map|null} */ + let lastRoutes = null + + /** + * Which cards moved since the last run, or null when too much changed for + * carrying anything over to be worth it (a new schema, a re-layout, a card + * resized by a column-mode switch). + * @param {{id:string,x:number,y:number,w:number,h:number}[]} boxes + * @returns {{id:string,x:number,y:number,w:number,h:number}[]|null} + */ + function movedSince(boxes) { + if (!lastBoxes || !lastRoutes || lastBoxes.size !== boxes.length) return null + const moved = [] + for (const b of boxes) { + const was = lastBoxes.get(b.id) + if (!was || was.w !== b.w || was.h !== b.h) return null + if (was.x !== b.x || was.y !== b.y) moved.push(was, b) + } + // Nothing moved but the signature changed: something else did, so re-route. + if (!moved.length) return null + // Past a handful this is a re-layout, not a drag, and every line is suspect. + if (moved.length > 8) return null + return moved + } + + /** + * Routes worth keeping: both endpoints stood still, and no card that moved + * (at either its old or its new position, with the clearance a line keeps) + * now lies across the path. + * @param {import('$lib/erd-routing.js').Link[]} links + * @param {{id:string,x:number,y:number,w:number,h:number}[]} stale + */ + function reusableRoutes(links, stale) { + /** @type {Map} */ + const routes = new Map() + if (!lastRoutes) return { routes } + const shifted = new Set(stale.map(b => b.id)) + for (const l of links) { + if (shifted.has(l.source) || shifted.has(l.target)) continue + const pts = lastRoutes.get(l.id) + if (!pts) continue + if (!stale.some(b => pathHitsRect(pts, b))) routes.set(l.id, pts) + } + return { routes } + } /** Positions come from `n.position` (not posOf) so a live drag doesn't thrash - * the cache - the dragged node's own edges fall back to the elbow instead. */ + * the cache - the dragged card's own lines are rebuilt per frame instead. */ function routeSignature(/** @type {boolean} */ rowsShown) { let h = (nodes.length * 397 + edges.length * 31 + (rowsShown ? 7 : 0)) | 0 for (const n of nodes) { h = (h * 33 + Math.round(n.position.x) * 7 + Math.round(n.position.y) * 13) | 0 h = (h * 33 + (n.data?.columns?.length ?? 0)) | 0 } - return `${routing}:${h}` + return `${routing}:${hints.size}:${h}` } - /** Ports: which side of each card the line leaves/enters, and at which row. */ + /** + * Ports: which edge of each card the line leaves and enters, and where on it. + * + * A side port is the one worth having, because it sits on the row the + * relationship is actually about - the foreign key column on one card, the + * column it references on the other. So a side port is what you get whenever + * the two cards are separated horizontally at all, however little. + * + * When they overlap in x there is no side to leave from: picking one anyway + * sends the line out of the card, back across its own width and in again, and + * a quarter of the relationships in a folded schema are in exactly that + * position. Those get the near edge instead - bottom to top, or top to bottom - + * which is the short hop the two cards were always asking for. + */ function portsOf(/** @type {FlowEdge} */ e, /** @type {FlowNode} */ s, /** @type {FlowNode} */ t, /** @type {boolean} */ rowsShown) { const sp = posOf(s), tp = posOf(t) const sh = nodeH(s.data), th = nodeH(t.data) - const scols = s.data?.columns ?? [] - const tcols = t.data?.columns ?? [] - const ci = e.sourceHandle ? scols.findIndex((/** @type {any} */ col) => `src-${col.name}` === e.sourceHandle) : -1 - const ti = e.targetHandle ? tcols.findIndex((/** @type {any} */ col) => `tgt-${col.name}` === e.targetHandle) : -1 + const ci = e.sourceHandle ? (handleIdx.get(s.id)?.get(e.sourceHandle) ?? -1) : -1 + const ti = e.targetHandle ? (handleIdx.get(t.id)?.get(e.targetHandle) ?? -1) : -1 // Past the row LOD the rows aren't drawn, so anchor to the card instead. const rowY = (/** @type {number} */ y, /** @type {number} */ i, /** @type {number} */ h) => rowsShown && i >= 0 ? y + HDR_H + i * ROW_H + ROW_H / 2 : y + (rowsShown ? HDR_H / 2 : h / 2) - const leftToRight = tp.x + NODE_W / 2 >= sp.x + NODE_W / 2 + + const toRight = tp.x - (sp.x + NODE_W) + const toLeft = sp.x - (tp.x + NODE_W) + if (toRight >= 0 || toLeft >= 0) { + const leftToRight = toRight >= toLeft + return { + ci, + sx: leftToRight ? sp.x + NODE_W : sp.x, + sy: rowY(sp.y, ci, sh), + tx: leftToRight ? tp.x : tp.x + NODE_W, + ty: rowY(tp.y, ti, th), + sdx: leftToRight ? 1 : -1, sdy: 0, + tdx: leftToRight ? -1 : 1, tdy: 0, + } + } + // Stacked. There is no row to sit on across a horizontal edge, so both ends + // take the middle of theirs, which is also where they meet if several lines + // arrive from the same direction. + const down = tp.y >= sp.y return { ci, - sx: leftToRight ? sp.x + NODE_W : sp.x, - sy: rowY(sp.y, ci, sh), - tx: leftToRight ? tp.x : tp.x + NODE_W, - ty: rowY(tp.y, ti, th), - sdir: leftToRight ? 1 : -1, - tdir: leftToRight ? -1 : 1, + sx: sp.x + NODE_W / 2, + sy: down ? sp.y + sh : sp.y, + tx: tp.x + NODE_W / 2, + ty: down ? tp.y : tp.y + th, + sdx: 0, sdy: down ? 1 : -1, + tdx: 0, tdy: down ? -1 : 1, } } - /** @param {boolean} rowsShown */ + /** + * Keep the routes for the current layout up to date, spending at most a few + * milliseconds of A* per frame. + * + * Routing a whole schema is seconds of work - 400 tables measured at ~5s - and + * doing it in one call inside the frame is what froze the window and got the + * app killed. The run is stepped instead: every link still without a route is + * drawn as a plain elbow, and the diagram sharpens over the next few frames. + * A run that overruns its total budget stops, leaving the rest as elbows, + * rather than burning frames forever on a graph too dense to route. + * @param {boolean} rowsShown + */ function ensureRoutes(rowsShown) { const key = routeSignature(rowsShown) - if (key === routeKey) return - routeKey = key - routes = new Map() - if (routing === 'direct' || !nodes.length || nodes.length > MAX_ROUTED_NODES) return - const boxes = nodes.map(n => ({ - id: n.id, x: n.position.x, y: n.position.y, w: NODE_W, h: nodeH(n.data), - })) - /** @type {import('$lib/erd-routing.js').Link[]} */ - const links = [] - for (const e of edges) { - const s = byId.get(e.source), t = byId.get(e.target) - if (!s || !t) continue - const { sx, sy, tx, ty, sdir, tdir } = portsOf(e, s, t, rowsShown) - links.push({ id: e.id, source: e.source, target: e.target, sx, sy, tx, ty, sdir, tdir }) + let entry = routeCache.get(key) + if (!entry) { + entry = { routes: new Map(), run: null, spent: 0 } + // Ports for every line, once per layout: working them out per frame was + // tens of thousands of throwaway objects a frame, which is what made + // panning feel heavy. + /** @type {import('$lib/erd-routing.js').Link[]} */ + const links = [] + for (const e of edges) { + const s = byId.get(e.source), t = byId.get(e.target) + if (!s || !t) continue + const { sx, sy, tx, ty, sdx, sdy, tdx, tdy } = portsOf(e, s, t, rowsShown) + links.push({ + id: e.id, source: e.source, target: e.target, + sx, sy, tx, ty, sdx, sdy, tdx, tdy, + }) + } + + if (hints.size) { + // The layout routed these already. 'Direct' keeps the ports and the + // stubs but ignores the detours, which is the honest reading of the + // setting: fewer bends, may cross a card. + // + // Drawn as right angles, not as a straight line between the layout's + // points: those points sit at different heights, so joining them + // directly turned every relationship into a long diagonal across the + // canvas. Turning only inside the gutters keeps each run in space the + // layout kept clear, which is also what stops a line disappearing under + // a card. + const follow = routing !== 'direct' + for (const l of links) { + entry.routes.set(l.id, corridorPathOrtho(l, follow ? hints.get(l.id) : undefined)) + } + } else if (links.length && routing !== 'direct' && nodes.length <= MAX_ROUTED_NODES) { + const boxes = nodes.map(n => ({ + id: n.id, x: n.position.x, y: n.position.y, w: NODE_W, h: nodeH(n.data), + })) + // Dropping one card should not rebuild the whole diagram in front of + // you. Only the cards that actually moved invalidate anything: a line + // between two cards that stayed put is still the line it was, unless one + // of the movers is now sitting on it. Everything else is carried over, + // so a drag re-routes a handful of lines in one frame instead of playing + // the whole routing run out again. + const stale = movedSince(boxes) + const keep = stale === null ? null : reusableRoutes(links, stale) + if (keep) { + entry.routes = keep.routes + const todo = links.filter(l => !keep.routes.has(l.id)) + if (todo.length) entry.run = createRouteRun(boxes, todo) + } else { + entry.run = createRouteRun(boxes, links) + } + lastBoxes = new Map(boxes.map(b => [b.id, b])) + lastRoutes = entry.routes + } else { + // 'Direct', or a graph past the routing cap. No corridor to follow, so + // each line takes its own turn column - with the ports fanned, that is + // still readable where a hub's lines all landing on one pixel is not. + for (const l of links) entry.routes.set(l.id, corridorPathOrtho(l)) + } + routeCache.set(key, entry) + // Oldest first: a Map iterates in insertion order. + while (routeCache.size > ROUTE_CACHE_MAX) { + const oldest = routeCache.keys().next().value + if (oldest === undefined) break + routeCache.delete(oldest) + } } - routes = routeEdges(boxes, links) + routes = entry.routes + + const run = entry.run + if (!run) { routePct = -1; return } + const t0 = performance.now() + run.step(ROUTE_BUDGET_MS) + entry.spent += performance.now() - t0 + // The run owns its own map; ours may already hold lines carried over from + // the last layout, so results are folded in rather than swapped for. + if (entry.routes !== run.routes) { + for (const [id, pts] of run.routes) entry.routes.set(id, pts) + } + if (run.done || entry.spent > ROUTE_MAX_MS) { + // Whatever is still unrouted keeps its elbow - a diagram this dense has no + // clean corridors left anyway, and frames matter more than the last line. + entry.run = null + routePct = -1 + return + } + routePct = Math.round((run.placed / run.total) * 100) + // Another slice next frame; the draw already shows what has landed. + markDirty() } /** Add a rounded orthogonal polyline to the current path. @@ -377,6 +589,33 @@ // parent end - bar ("one"), preceded by a hollow ring when the FK is nullable // (participation is optional: zero-or-one) const MARK_LOD = 0.3 + + /** + * Bounding box of a routed polyline, memoised on the array itself. + * + * The viewport cull needs one per edge per frame, and computing it walked + * every point of every line - tens of thousands of reads a frame on a schema + * this size, for a number that only changes when the line is re-routed. A + * re-route hands back a new array, which misses the map and recomputes; a + * stable one never does. + * @type {WeakMap<{x:number,y:number}[], {x0:number,y0:number,x1:number,y1:number}>} + */ + const _bbox = new WeakMap() + /** @param {{x:number,y:number}[]} pts */ + function bboxOf(pts) { + let b = _bbox.get(pts) + if (b) return b + let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity + for (const p of pts) { + if (p.x < x0) x0 = p.x + if (p.x > x1) x1 = p.x + if (p.y < y0) y0 = p.y + if (p.y > y1) y1 = p.y + } + b = { x0, y0, x1, y1 } + _bbox.set(pts, b) + return b + } /** * @param {number} x @param {number} y * @param {number} dx @param {number} dy unit vector pointing away from the card @@ -420,16 +659,24 @@ if (!s || !t) continue const dragging = !!dragPos && (dragPos.id === e.source || dragPos.id === e.target) const cached = dragging ? null : routes.get(e.id) - const { ci, sx, sy, tx, ty } = portsOf(e, s, t, rowsShown) - const pts = cached ?? elbow(e, ci, sx, sy, tx, ty) - // Cull: skip when the polyline's bounding box misses the viewport. - let bx0 = Infinity, bx1 = -Infinity, by0 = Infinity, by1 = -Infinity - for (const p of pts) { - bx0 = Math.min(bx0, p.x); bx1 = Math.max(bx1, p.x) - by0 = Math.min(by0, p.y); by1 = Math.max(by1, p.y) + // The ports only exist to build a path. When the route is already cached + // - which is every line on every frame that isn't a drag - working them + // out again allocated an object per edge per frame for nothing. + let pts = cached + if (!pts) { + const { sx, sy, tx, ty, sdx, sdy, tdx, tdy } = portsOf(e, s, t, rowsShown) + const port = { sx, sy, tx, ty, sdx, sdy, tdx, tdy } + // A dragged card's lines are rebuilt every frame, and they have to come + // out the same shape as the cached ones - otherwise the line snaps to a + // different geometry the moment the card is picked up. There is no + // corridor for a card that has moved off its own, so it turns on its own + // lane like any unrouted line. + pts = corridorPathOrtho(port) } - if (bx1 < vx0 || bx0 > vx1 || by1 < vy0 || by0 > vy1) continue + // Cull: skip when the polyline's bounding box misses the viewport. + const bb = bboxOf(pts) + if (bb.x1 < vx0 || bb.x0 > vx1 || bb.y1 < vy0 || bb.y0 > vy1) continue const active = e.source === selectedId || e.target === selectedId const dim = s.data?.highlighted === false && t.data?.highlighted === false @@ -438,11 +685,17 @@ const styles = [ { color: c('edgeSoft', 1), width: 1 }, - { color: c('edge', 1), width: 1.25 }, - { color: c('fg', 0.8), width: 1.9 }, + { color: c('edge', 1), width: 1.4 }, + { color: c('fg', 0.85), width: 2 }, ] + // Zoomed out, every line is still a full-strength screen-width stroke, and a + // few hundred of them bury the cards they are there to connect. Below the row + // LOD everything but the selected relationship recedes, so the cards stay the + // subject and the lines read as texture. + const wash = cam.zoom < LOD_ROWS for (let b = 0; b < bands.length; b++) { if (!bands[b].length) continue + ctx.globalAlpha = wash && b < 2 ? 0.75 : 1 ctx.lineWidth = styles[b].width / cam.zoom ctx.strokeStyle = styles[b].color ctx.lineJoin = 'round' @@ -463,6 +716,39 @@ } ctx.stroke() } + + // A merged line says how many foreign keys it stands for, so collapsing a + // pair's columns into one line never quietly hides a relationship. Only + // where the rows are readable: at fit-zoom it would be noise on noise. + if (rowsShown) { + for (const band of bands) { + for (const { pts, e } of band) { + const n = e.mergedCount ?? 1 + if (n < 2) continue + const a0 = pts[0], a1 = pts[1] ?? a0 + const d = unit(a1.x - a0.x, a1.y - a0.y) + drawEdgeCount(a0.x + d.x * 30, a0.y + d.y * 30 - (d.y === 0 ? 10 : 0), n) + } + } + } + ctx.globalAlpha = 1 + } + + /** Count chip on a merged relationship. @param {number} x @param {number} y @param {number} n */ + function drawEdgeCount(x, y, n) { + const label = `×${n}` + setFont(FONT_BADGE) + const w = ctx.measureText(label).width + 9 + roundRect(x - w / 2, y - 7, w, 14, 4) + setFill(c('panel', 0.94)) + ctx.fill() + ctx.lineWidth = 1 / cam.zoom + ctx.strokeStyle = c('border', 1) + ctx.stroke() + setFill(c('mfg', 0.95)) + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(label, x, y + 0.5) } /** @param {number} x @param {number} y */ @@ -471,16 +757,18 @@ return m < 0.001 ? { x: 1, y: 0 } : { x: x / m, y: y / m } } - /** Fallback elbow: direct mode, live drag, or no clear corridor found. */ - function elbow(/** @type {FlowEdge} */ e, /** @type {number} */ ci, sx, sy, tx, ty) { - const leftToRight = tx >= sx - const lane = (((ci >= 0 ? ci : 0) % 5) - 2) * 12 + ((hashStr(e.id) % 3) - 1) * 6 - let midX = sx + (tx - sx) * 0.5 + (leftToRight ? lane : -lane) - const loX = Math.min(sx, tx) + 12, hiX = Math.max(sx, tx) - 12 - midX = loX <= hiX ? Math.max(loX, Math.min(hiX, midX)) : (sx + tx) / 2 - if (Math.abs(ty - sy) < 1) return [{ x: sx, y: sy }, { x: tx, y: ty }] - return [{ x: sx, y: sy }, { x: midX, y: sy }, { x: midX, y: ty }, { x: tx, y: ty }] - } + // Canvas state assignments are not free - WebKit re-parses a font string on + // every set, and a card with 25 columns used to set the font three times a row. + // Tracking the live values collapses that to one set per distinct value per + // frame, across every card on screen. + let _font = '' + let _fill = '' + /** @param {string} f */ + function setFont(f) { if (f !== _font) { ctx.font = f; _font = f } } + /** @param {string} v */ + function setFill(v) { if (v !== _fill) { ctx.fillStyle = v; _fill = v } } + /** ctx.save()/restore() rolls these back behind our back. */ + function forgetStyle() { _font = ''; _fill = '' } /** @param {FlowNode} n @param {{x:number,y:number}} p @param {number} h @param {number} zoom */ function drawNode(n, p, h, zoom) { @@ -503,7 +791,7 @@ // Card + border. roundRect(p.x, p.y, NODE_W, h, 8) - ctx.fillStyle = c('card', 1) + setFill(c('card', 1)) ctx.fill() ctx.lineWidth = (isFocus ? 1.75 : sel ? 2 : 1) / zoom ctx.strokeStyle = isFocus @@ -523,6 +811,7 @@ ctx.fillRect(p.x, p.y, NODE_W, HDR_H) } ctx.restore() + forgetStyle() ctx.beginPath() ctx.moveTo(p.x, p.y + HDR_H); ctx.lineTo(p.x + NODE_W, p.y + HDR_H) ctx.lineWidth = 1 / zoom; ctx.strokeStyle = isFocus ? c('focus', 0.35) : c('border', tone.rim * 0.8); ctx.stroke() @@ -534,25 +823,33 @@ let nameW = NODE_W - 32 if (isFocus) { const label = 'current' - ctx.font = `700 9px ${FONT_MONO}` + setFont(FONT_BADGE) const bw = ctx.measureText(label).width + 12 const bx = p.x + NODE_W - bw - 12 roundRect(bx, p.y + HDR_H / 2 - 7, bw, 14, 4) ctx.lineWidth = 1 / zoom ctx.strokeStyle = c('focus', 0.55) ctx.stroke() - ctx.fillStyle = c('focus', 0.95) + setFill(c('focus', 0.95)) ctx.textAlign = 'center' ctx.fillText(label, bx + bw / 2, p.y + HDR_H / 2 + 0.5) nameW = bx - p.x - 24 } - ctx.font = `600 13.5px ${FONT_SANS}` - ctx.fillStyle = c('fg', 1) + setFont(`600 13.5px ${FONT_SANS}`) + setFill(c('fg', 1)) ctx.textAlign = 'left' fillClipped(d.name ?? '', p.x + 16, p.y + HDR_H / 2, nameW, 13.5) } - if (zoom < LOD_ROWS) { ctx.globalAlpha = 1; return } + if (zoom < LOD_ROWS) { + // Row text is sub-pixel down here and rightly skipped - but leaving the + // body empty turns a schema-wide view into a field of blank rectangles, + // which is what the whole diagram looks like at fit-zoom. The rows are + // drawn as bars instead: same rhythm, same PK/FK colour, no glyphs. + if (zoom >= LOD_BARS) drawRowBars(d, p, h) + ctx.globalAlpha = 1 + return + } // Column rows. const pk = d.pkCols ?? new Set() @@ -573,15 +870,15 @@ const nameRight = showTypes ? typeRight - 4 : p.x + NODE_W - (badge ? 46 : 16) // Column name. - ctx.font = `${isPk ? '600 ' : ''}12px ${FONT_SANS}` - ctx.fillStyle = isPk ? `rgba(${PK},0.95)` : isFk ? `rgba(${FK},0.95)` : c('fg', 0.82) + setFont(isPk ? FONT_ROW_PK : FONT_ROW) + setFill(isPk ? PK_NAME : isFk ? FK_NAME : c('fg', 0.82)) ctx.textAlign = 'left' fillClipped(col.name ?? '', p.x + 16, midY, nameRight - (p.x + 16), 12) // Data type. if (showTypes) { - ctx.font = `11px ${FONT_MONO}` - ctx.fillStyle = c('mfg', 0.6) + setFont(FONT_TYPE) + setFill(c('mfg', 0.6)) ctx.textAlign = 'right' fillClipped(String(col.dataType ?? ''), typeRight, midY, 72, 11, true) } @@ -590,10 +887,10 @@ if (badge) { const bw = 26, bx = p.x + NODE_W - bw - 12 roundRect(bx, cy + 6, bw, ROW_H - 12, 3) - ctx.fillStyle = isPk ? `rgba(${PK},0.16)` : `rgba(${FK},0.14)` + setFill(isPk ? PK_WASH : FK_WASH) ctx.fill() - ctx.font = `700 9px ${FONT_MONO}` - ctx.fillStyle = isPk ? `rgba(${PK},1)` : `rgba(${FK},1)` + setFont(FONT_BADGE) + setFill(isPk ? PK_INK : FK_INK) ctx.textAlign = 'center' ctx.fillText(badge, bx + bw / 2, midY + 0.5) } @@ -606,8 +903,8 @@ ctx.beginPath() ctx.moveTo(p.x, cy); ctx.lineTo(p.x + NODE_W, cy) ctx.lineWidth = 0.75 / zoom; ctx.strokeStyle = c('border', 0.12); ctx.stroke() - ctx.font = `11px ${FONT_MONO}` - ctx.fillStyle = c('mfg', 0.45) + setFont(FONT_TYPE) + setFill(c('mfg', 0.45)) ctx.textAlign = 'left' fillClipped(`+${d.hiddenCount} more`, p.x + 16, cy + ROW_H / 2, NODE_W - 32, 11) } @@ -631,35 +928,102 @@ const MINI_W = 168, MINI_H = 116, MINI_PAD = 8 /** @type {{s:number,ox:number,oy:number}|null} */ let miniXf = null + /** + * The cards on the minimap only move when the graph does, but the viewport + * rectangle moves on every frame of every pan - so the cards are painted once + * into an offscreen canvas and blitted, instead of a thousand fills a frame. + * @type {HTMLCanvasElement|null} + */ + let miniCache = null + let miniKey = '' + /** + * A card's rows as coloured bars, for zoom levels where the text would not be + * legible anyway. Batched into one path per tint, so a card costs three fills + * rather than one per column. + * @param {any} d @param {{x:number,y:number}} p @param {number} h + */ + function drawRowBars(d, p, h) { + const cols = d.columns ?? [] + if (!cols.length) return + const pk = d.pkCols ?? new Set() + const barH = Math.max(2, ROW_H * 0.4) + const inset = 14 + const full = NODE_W - inset * 2 + const bottom = p.y + h + // 0 = primary key, 1 = foreign key, 2 = everything else. + for (let kind = 0; kind < 3; kind++) { + ctx.beginPath() + let any = false + for (let i = 0; i < cols.length; i++) { + const col = cols[i] + const k = pk.has(col.name) ? 0 : col.foreignKey ? 1 : 2 + if (k !== kind) continue + const by = p.y + HDR_H + i * ROW_H + (ROW_H - barH) / 2 + if (by + barH > bottom) break + // A plain column gets a shorter bar, so a key still reads as the long one. + ctx.rect(p.x + inset, by, kind === 2 ? full * 0.6 : full * 0.85, barH) + any = true + } + if (!any) continue + setFill(kind === 0 ? PK_WASH : kind === 1 ? FK_WASH : c('mfg', 0.2)) + ctx.fill() + } + } + function drawMinimap() { if (!mini || !nodes.length) return const mctx = mini.getContext('2d') if (!mctx) return - let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity - for (const n of nodes) { - const p = posOf(n), h = nodeH(n.data) - x0 = Math.min(x0, p.x); y0 = Math.min(y0, p.y) - x1 = Math.max(x1, p.x + NODE_W); y1 = Math.max(y1, p.y + h) + + // Only on a real change: assigning width/height reallocates the backing + // store and clears it, and this runs on every frame of every pan. + if (mini.width !== MINI_W * dpr || mini.height !== MINI_H * dpr) { + mini.width = MINI_W * dpr; mini.height = MINI_H * dpr + mini.style.width = MINI_W + 'px'; mini.style.height = MINI_H + 'px' + miniKey = '' } - const bw = x1 - x0 || 1, bh = y1 - y0 || 1 - const s = Math.min((MINI_W - MINI_PAD * 2) / bw, (MINI_H - MINI_PAD * 2) / bh) - const ox = MINI_PAD - x0 * s + (MINI_W - MINI_PAD * 2 - bw * s) / 2 - const oy = MINI_PAD - y0 * s + (MINI_H - MINI_PAD * 2 - bh * s) / 2 - miniXf = { s, ox, oy } - mini.width = MINI_W * dpr; mini.height = MINI_H * dpr - mini.style.width = MINI_W + 'px'; mini.style.height = MINI_H + 'px' - mctx.setTransform(dpr, 0, 0, dpr, 0, 0) - mctx.fillStyle = c('panel', 1); mctx.fillRect(0, 0, MINI_W, MINI_H) - for (const n of nodes) { - const p = posOf(n), h = nodeH(n.data) - const isFocus = !!focusId && n.id === focusId - mctx.fillStyle = isFocus ? c('focus', 1) - : n.id === selectedId ? c('fg', 0.8) - : c('mfg', n.data?.highlighted === false ? 0.2 : 0.45) - mctx.fillRect(p.x * s + ox, p.y * s + oy, NODE_W * s, h * s) + // Drag position is keyed by id only: following it per frame would repaint the + // cache on every frame of the drag, which is the thing being avoided. + const key = `${graphVersion}|${selectedId}|${focusId}|${dragPos?.id ?? ''}|${pal.fg}|${dpr}` + if (key !== miniKey || !miniCache) { + let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity + for (const n of nodes) { + const p = posOf(n), h = nodeH(n.data) + x0 = Math.min(x0, p.x); y0 = Math.min(y0, p.y) + x1 = Math.max(x1, p.x + NODE_W); y1 = Math.max(y1, p.y + h) + } + const bw = x1 - x0 || 1, bh = y1 - y0 || 1 + const s = Math.min((MINI_W - MINI_PAD * 2) / bw, (MINI_H - MINI_PAD * 2) / bh) + const ox = MINI_PAD - x0 * s + (MINI_W - MINI_PAD * 2 - bw * s) / 2 + const oy = MINI_PAD - y0 * s + (MINI_H - MINI_PAD * 2 - bh * s) / 2 + miniXf = { s, ox, oy } + + if (!miniCache) miniCache = document.createElement('canvas') + miniCache.width = MINI_W * dpr + miniCache.height = MINI_H * dpr + const cc = miniCache.getContext('2d') + if (!cc) return + cc.setTransform(dpr, 0, 0, dpr, 0, 0) + cc.fillStyle = c('panel', 1); cc.fillRect(0, 0, MINI_W, MINI_H) + for (const n of nodes) { + const p = posOf(n), h = nodeH(n.data) + const isFocus = !!focusId && n.id === focusId + cc.fillStyle = isFocus ? c('focus', 1) + : n.id === selectedId ? c('fg', 0.8) + : c('mfg', n.data?.highlighted === false ? 0.2 : 0.45) + cc.fillRect(p.x * s + ox, p.y * s + oy, NODE_W * s, h * s) + } + miniKey = key } + if (!miniXf) return + + mctx.setTransform(1, 0, 0, 1, 0, 0) + mctx.clearRect(0, 0, mini.width, mini.height) + mctx.drawImage(miniCache, 0, 0) + mctx.setTransform(dpr, 0, 0, dpr, 0, 0) // Viewport rectangle. + const { s, ox, oy } = miniXf const { panX, panY, zoom } = cam const rx = (-panX / zoom) * s + ox, ry = (-panY / zoom) * s + oy const rw = (cssW / zoom) * s, rh = (cssH / zoom) * s @@ -856,6 +1220,14 @@
+ + {#if routePct >= 0} +
+ + Routing lines {routePct}% +
+ {/if} + {#if nodes.length} (pluginsDir = d)); + + async function installPlugin() { + installing = true; + try { + const { open } = await import("@tauri-apps/plugin-dialog"); + const picked = await open({ directory: true, multiple: false, title: "Pick a plugin folder" }); + if (typeof picked !== "string") return; + const installed = await installExternalPlugin(picked); + toast.success(`Installed ${installed.name}`, { description: "Turn it on to start using it." }); + } catch (e) { + toast.error("Could not install that folder", { description: String(e) }); + } finally { + installing = false; + } + } + + /** @param {import('$lib/plugins/external/types.js').ExternalPluginInfo} p */ + async function reloadPlugin(p) { + const next = await reloadExternalPlugin(p.id); + if (next?.loadable) toast.success(`Reloaded ${next.name}`); + else toast.error(`${p.name} did not load`, { description: next?.error || "Check its manifest and entry file." }); + } + + /** @param {import('$lib/plugins/external/types.js').ExternalPluginInfo} p */ + async function removePlugin(p) { + try { + await uninstallExternalPlugin(p.id); + toast.success(`Removed ${p.name}`); + } catch (e) { + toast.error(`Could not remove ${p.name}`, { description: String(e) }); + } + } + const CONFIGURABLE = new Set([ "better-time", "number-format", "money-format", "duration-format", "boolean-glyph", "mask-sensitive", "heatmap", "linkify", @@ -235,6 +292,97 @@ Display formatters, linkifiers and cell tools for the data grid. Click a card to configure it.

+ +
+

Installed

+ {#if $externalPlugins.length > 0} + {$externalPlugins.length} + {/if} + + +
+ + {#if $externalPlugins.length === 0} +
+

Nothing installed yet.

+

+ A plugin is a folder holding a manifest.json and one + .js file. Install one above, or drop the folder in + {#if pluginsDir}{pluginsDir}{:else}the app's plugins folder{/if} + and hit rescan. See docs/PLUGIN_API.md for the contract. +

+
+ {:else} +
+ {#each $externalPlugins as p (p.id)} + {@const on = pluginEnabledIn($pluginState, pluginKey(p.id))} + {@const err = $externalPluginErrors[p.id] ?? ""} +
+ + + {p.name} + + {p.version ? `v${p.version}` : p.id}{p.author ? ` · ${p.author}` : ""} + + + {#if p.error || err} +

{p.error || err}

+ {/if} +
+ + + {#if p.permissions.length > 0} + + {p.permissions.length} permission{p.permissions.length === 1 ? "" : "s"} + + {/if} +
+
+ {#if p.loadable} + {@render toggle(on, () => void setExternalEnabled(p.id, !on), `Toggle ${p.name}`)} + {:else} + Broken + {/if} +
+
+ {/each} +
+ {/if} + {#each SECTIONS as section (section.title)} {@const items = EXTENSIONS.filter((e) => section.kinds.includes(e.kind))}

{section.title}

diff --git a/src/lib/components/ExtensionsSidebarPanel.svelte b/src/lib/components/ExtensionsSidebarPanel.svelte index f4121720..d95707cf 100644 --- a/src/lib/components/ExtensionsSidebarPanel.svelte +++ b/src/lib/components/ExtensionsSidebarPanel.svelte @@ -1,11 +1,25 @@ + +
+ + +
diff --git a/src/lib/components/PinChallengeDialog.svelte b/src/lib/components/PinChallengeDialog.svelte new file mode 100644 index 00000000..b8ccc817 --- /dev/null +++ b/src/lib/components/PinChallengeDialog.svelte @@ -0,0 +1,87 @@ + + + { if (!next) cancel() }}> + + + +
+
+ +
+ + Confirm your PIN + + {$pinChallenge?.reason ?? 'Unlock to continue'} + + +
+ +
+ +

+ {#if checking}Checking…{:else if wrong}Wrong PIN. Try again.{/if} +

+ + +
+
+
+
diff --git a/src/lib/components/PinField.svelte b/src/lib/components/PinField.svelte new file mode 100644 index 00000000..0e99d1c2 --- /dev/null +++ b/src/lib/components/PinField.svelte @@ -0,0 +1,90 @@ + + +
+ {#each Array.from({ length }) as _, i (i)} +
+ {#if value.length > i} + + {/if} +
+ {/each} + + (focused = true)} + onblur={() => (focused = false)} + type="password" + inputmode="numeric" + autocomplete="off" + autocorrect="off" + autocapitalize="off" + spellcheck="false" + maxlength={length} + aria-label={ariaLabel} + class="absolute inset-0 h-full w-full cursor-default rounded-lg bg-transparent text-transparent opacity-0 outline-none caret-transparent" + /> +
diff --git a/src/lib/components/PinSetupDialog.svelte b/src/lib/components/PinSetupDialog.svelte new file mode 100644 index 00000000..68faa2e1 --- /dev/null +++ b/src/lib/components/PinSetupDialog.svelte @@ -0,0 +1,183 @@ + + + + + + +
+
+ +
+ + {title} + {prompt} + +
+ {#key step} + + {/key} +
+ +

+ {#if busy}Saving…{:else if error}{error}{/if} +

+ + {#if step === 'new' && !isRemove} +

+ Kept as a salted hash in your OS keychain, never as the digits. There is + no reset link, so pick one you will remember. +

+ {/if} +
+ +
+ +

+ {#if !isRemove}Step {step === 'current' ? 1 : step === 'new' ? (needsCurrent ? 2 : 1) : needsCurrent ? 3 : 2} of {needsCurrent ? 3 : 2}{/if} +

+
+
+
+
diff --git a/src/lib/components/ProviderAuthPanel.svelte b/src/lib/components/ProviderAuthPanel.svelte index 691b6997..d19510af 100644 --- a/src/lib/components/ProviderAuthPanel.svelte +++ b/src/lib/components/ProviderAuthPanel.svelte @@ -9,7 +9,7 @@ * * This is one block with four tones, and deliberately NOT a card: a bordered * box floating above the form it belongs to reads as an advert, not as the - * first step of the form. It is a section — icon, copy, one action, a rule — + * first step of the form. It is a section - icon, copy, one action, a rule - * flush with the fields below it and the same width as them. The rule doubles * as the progress track, so a long wait can show life without a bar appearing * out of nowhere and pushing everything down. @@ -22,7 +22,7 @@ tone = /** @type {'idle' | 'busy' | 'error'} */ ('idle'), title = '', subtitle = '', - /** Quiet third line — the reassurance, not the instruction. */ + /** Quiet third line - the reassurance, not the instruction. */ hint = '', /** Animate the rule, for a wait with no percentage to report. */ progress = false, diff --git a/src/lib/components/ProviderConnect.svelte b/src/lib/components/ProviderConnect.svelte index 0dc47136..6c098103 100644 --- a/src/lib/components/ProviderConnect.svelte +++ b/src/lib/components/ProviderConnect.svelte @@ -279,7 +279,7 @@ {:else} {#snippet mark()}{/snippet} diff --git a/src/lib/components/RedisKeyspacePage.svelte b/src/lib/components/RedisKeyspacePage.svelte index d23daeed..19abea27 100644 --- a/src/lib/components/RedisKeyspacePage.svelte +++ b/src/lib/components/RedisKeyspacePage.svelte @@ -267,7 +267,7 @@ void loadInfo() }) - // ── Logical DB switching (0–15) ───────────────────────────────────────────── + // ── Logical DB switching (0-15) ───────────────────────────────────────────── // Redis exposes 16 numbered logical DBs. The backend opens a fresh connection // per command at cfg.db, so switching = reconnect the active connection at the // new db (reusing connect_redis_db), then reload the keyspace. diff --git a/src/lib/components/RowExpandViewer.svelte b/src/lib/components/RowExpandViewer.svelte index c64e7793..4f3ce18c 100644 --- a/src/lib/components/RowExpandViewer.svelte +++ b/src/lib/components/RowExpandViewer.svelte @@ -250,7 +250,7 @@ It lines up with the grid rather than fighting it: content starts where the first column starts, and the frozen gutter continues down the left as a filled strip (matching the grid's row-number column) so its right edge reads - as the gutter rule — not a hairline floating in empty space. --> + as the gutter rule - not a hairline floating in empty space. -->
{#if indent > 0}
diff --git a/src/lib/components/SettingsDialog.svelte b/src/lib/components/SettingsDialog.svelte index 7edaccda..0636c3a6 100644 --- a/src/lib/components/SettingsDialog.svelte +++ b/src/lib/components/SettingsDialog.svelte @@ -25,6 +25,7 @@ ICON_SETS, TABLE_STYLES, TABLE_ALIGN_OPTIONS, + ROW_SPACINGS, DEFAULT_MAX_QUERY_HISTORY, DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_SOCKET_TIMEOUT_MS, @@ -33,6 +34,12 @@ AGENT_FONT_SIZES, THINKING_STYLES, } from "$lib/stores/settings.js"; + import { + SQL_CASE_OPTIONS, + SQL_FORMAT_FIELDS, + SQL_TAB_WIDTHS, + normalizeSqlFormat, + } from "$lib/sql-format-options.js"; import { aiProfiles, activeProfileId, setActiveProfile } from "$lib/stores/ai-settings.js"; import PenTool from "@lucide/svelte/icons/pen-tool"; import LucideSparkles from "@lucide/svelte/icons/sparkles"; @@ -46,6 +53,14 @@ disableAutostart, getAutostartStatus, } from "$lib/api.js"; + import PinSetupDialog from "./PinSetupDialog.svelte"; + import { toast } from "$lib/components/ui/sonner/toast.svelte.js"; + import { + lockStatus, + refreshLockStatus, + setLockPrefs, + lockNow, + } from "$lib/stores/app-lock.js"; let { open = $bindable(false), @@ -93,9 +108,41 @@ settings = loadSettings(); } + // ── App PIN ────────────────────────────────────────────────────────────── + let pinDialogOpen = $state(false); + /** @type {'set' | 'change' | 'remove'} */ + let pinDialogMode = $state('set'); + + /** @param {'set' | 'change' | 'remove'} mode */ + function openPinDialog(mode) { + pinDialogMode = mode; + // Settings steps aside first, matching every other "open the real thing" + // row here - two stacked dialogs fight over the focus trap, and the PIN + // field must have it. + open = false; + pinDialogOpen = true; + } + + const AUTO_LOCK_OPTIONS = [ + { value: 0, label: 'Never' }, + { value: 1, label: '1 min' }, + { value: 5, label: '5 min' }, + { value: 15, label: '15 min' }, + { value: 60, label: '1 hr' }, + ]; + + /** @param {{ requireOnConnect?: boolean, autoLockMinutes?: number }} prefs */ + async function updateLockPrefs(prefs) { + try { + await setLockPrefs(prefs); + } catch (e) { + toast.error('Could not save the lock setting', { description: String(e) }); + } + } + /** @param {boolean} next */ function handleOpenChange(next) { - if (next) { refreshSettings(); category = 'general'; query = ''; } + if (next) { refreshSettings(); category = 'general'; query = ''; void refreshLockStatus(); } } /** @param {import('$lib/themes/registry.js').ThemeId} themeId */ @@ -171,6 +218,47 @@ settings = updateSettings({ jsonWordWrap: !settings.jsonWordWrap }); } + function toggleNativeScroll() { + settings = updateSettings({ nativeScroll: !settings.nativeScroll }); + } + + const rowSpacingEntries = Object.entries(ROW_SPACINGS); + + /** @param {string | undefined} id */ + function setRowSpacing(id) { + if (!id || id === settings.rowSpacing) return; + settings = updateSettings({ rowSpacing: /** @type {any} */ (id) }); + } + + function toggleZebraRows() { + settings = updateSettings({ zebraRows: !settings.zebraRows }); + } + + function toggleAutoSaveQueries() { + settings = updateSettings({ autoSaveQueries: !settings.autoSaveQueries }); + } + + // ── SQL formatting ──────────────────────────────────────────────────────── + // Collapsed by default - see the section comment in databaseContent. + let sqlFmtOpen = $state(false); + // Read through the normalizer, never straight off `settings`. Settings persisted + // by a build that predates this option set can be partial (or absent), and one + // `undefined[key]` in a row would throw the whole pane into the error boundary. + const sqlFmt = $derived(/** @type {any} */ (normalizeSqlFormat(settings.sqlFormat))); + + /** @param {string} key @param {unknown} value */ + function setSqlFormat(key, value) { + const next = { ...sqlFmt, [key]: value }; + settings = updateSettings({ sqlFormat: normalizeSqlFormat(next) }); + } + /** @param {{ key: string, min?: number, max?: number, step?: number }} field @param {number} dir */ + function bumpSqlFormat(field, dir) { + const step = field.step ?? 1; + const cur = Number(sqlFmt[field.key]); + const next = Math.min(field.max ?? 999, Math.max(field.min ?? 0, cur + dir * step)); + if (next !== cur) setSqlFormat(field.key, next); + } + function toggleCmdkAi() { settings = updateSettings({ cmdkAiEnabled: !settings.cmdkAiEnabled }); } @@ -388,6 +476,8 @@ + + {#snippet secLabel(/** @type {string} */ text)} {#if !searching} @@ -556,6 +646,102 @@ {#if show($t('settings.timezone'), $t('settings.timezone.desc'))} {@render textRow($t('settings.timezone'), $t('settings.timezone.desc'), 'sessionTimezone', DEFAULT_SESSION_TIMEZONE)} {/if} + + {#if show('Auto-save executed queries', 'File every successful run under Saved Queries, not just Query History')} + {@render switchRow( + 'Auto-save executed queries', + 'File every successful run under Saved Queries as well as Query History, deduplicated by its SQL so re-running the same statement adds one entry, not fifty.', + settings.autoSaveQueries, + toggleAutoSaveQueries, + )} + {/if} + + + {@render secLabel('SQL formatting')} + {#if !searching} + + {/if} + {#if sqlFmtOpen || searching} + + {#each SQL_FORMAT_FIELDS as field (field.key)} + {#if show(field.label, field.desc)} + {#if field.kind === 'bool'} + {@render switchRow(field.label, field.desc, sqlFmt[field.key] === true, () => setSqlFormat(field.key, !sqlFmt[field.key]))} + {:else} +
+
+

{field.label}

+

{field.desc}

+
+ {#if field.kind === 'case'} + {@render segmented(field.label, SQL_CASE_OPTIONS.map((o) => ({ value: o.id, label: o.label })), sqlFmt[field.key], (v) => setSqlFormat(field.key, v))} + {:else if field.kind === 'tabWidth'} + {@render segmented(field.label, SQL_TAB_WIDTHS.map((n) => ({ value: n, label: String(n) })), sqlFmt.tabWidth, (v) => setSqlFormat('tabWidth', v))} + {:else if field.kind === 'operatorNewline'} + {@render segmented(field.label, [{ value: 'before', label: 'Before' }, { value: 'after', label: 'After' }], sqlFmt.logicalOperatorNewline, (v) => setSqlFormat('logicalOperatorNewline', v))} + {:else} +
+ + {sqlFmt[field.key]} + +
+ {/if} +
+ {/if} + {/if} + {/each} + {/if} +{/snippet} + + +{#snippet segmented( + /** @type {string} */ ariaLabel, + /** @type {Array<{ value: any, label: string }>} */ options, + /** @type {any} */ value, + /** @type {(v: any) => void} */ onpick, +)} +
+ {#each options as o, i (o.value)} + + {/each} +
{/snippet} {#snippet agentContent()} @@ -630,7 +816,7 @@ {#if show('Web access', 'Let the agent search the web and read pages')} {@render switchRow( 'Web access', - 'Let the agent search the web and read pages for things your database cannot answer — error codes, function syntax, current docs. Your search terms leave your machine when it does.', + 'Let the agent search the web and read pages for things your database cannot answer: error codes, function syntax, current docs. Your search terms leave your machine when it does.', settings.agentWebAccess, toggleAgentWebAccess, )} @@ -657,7 +843,7 @@ {@render switchRow($t('settings.previewSql'), $t('settings.previewSql.desc'), settings.previewDmlBeforeApply, togglePreviewDml)} {/if} {#if show('Wrap JSON', 'Soft-wrap long values in every JSON viewer instead of scrolling sideways')} - {@render switchRow('Wrap JSON', 'Soft-wrap long values — embeddings, document chunks — in every JSON view instead of running off the right edge. Off keeps the structure easier to scan.', settings.jsonWordWrap, toggleJsonWordWrap)} + {@render switchRow('Wrap JSON', 'Soft-wrap long values (embeddings, document chunks) in every JSON view instead of running off the right edge. Off keeps the structure easier to scan.', settings.jsonWordWrap, toggleJsonWordWrap)} {/if} {#if show('Vim mode', 'Experimental modal keyboard navigation across the app, the data grid, and the SQL editor')} {@render switchRow('Vim mode', 'Experimental: modal keyboard navigation (hjkl, gg/G, i/Esc) across the grid, the SQL editor, and tabs', settings.vimMode, toggleVimMode)} @@ -672,13 +858,64 @@ {@render switchRow($t('settings.mcpAutostart'), $t('settings.mcpAutostart.desc'), settings.mcpAutoStart, toggleMcpAutoStart)} {/if} + {@render secLabel('Security')} + {#if show('App PIN', 'Require a PIN to open Stroke and to connect to a database')} +
+
+

+ App PIN + + {$lockStatus.enabled ? 'On' : 'Off'} + +

+

+ A {$lockStatus.pinLength}-digit PIN that locks Stroke on launch. Stored as a salted + hash in your OS keychain - there is no reset link, so pick one you will remember. +

+
+
+ {#if $lockStatus.enabled} + + + {:else} + + {/if} +
+
+ {/if} + {#if $lockStatus.enabled} + {#if show('Ask when connecting', 'Confirm the PIN before opening or reconnecting to a database')} + {@render switchRow( + 'Ask when connecting', + 'Confirm the PIN before opening or reconnecting to a database. The reconnect right after you unlock the app is exempt - you just proved who you are.', + $lockStatus.requireOnConnect, + () => void updateLockPrefs({ requireOnConnect: !$lockStatus.requireOnConnect }), + )} + {/if} + {#if show('Auto-lock', 'Lock again after a stretch of inactivity')} +
+
+

Auto-lock

+

+ Lock again after this much inactivity. Open tabs and queries are kept - the + screen goes over the session, not through it. +

+
+ {@render segmented('Auto-lock after', AUTO_LOCK_OPTIONS, $lockStatus.autoLockMinutes, (v) => void updateLockPrefs({ autoLockMinutes: v }))} +
+ {/if} + {#if show('Lock now', 'Lock Stroke immediately')} + {@render actionRow('Lock now', 'Lock Stroke immediately, without waiting for the auto-lock.', 'Lock', () => { open = false; lockNow(); })} + {/if} + {/if} + {@render secLabel('Privacy')} {#if show('Anonymous usage data', 'Help decide what to build next')} {@render switchRow( 'Anonymous usage data', - 'Sends which features you use, how often, the app version and your OS — nothing else. No queries, no table or database names, no connection details, and nothing about the data you browse. Turning it off takes effect immediately.', + 'Sends which features you use, how often, the app version and your OS. Nothing else. No queries, no table or database names, no connection details, and nothing about the data you browse. Turning it off takes effect immediately.', settings.telemetry, toggleTelemetry, )} @@ -830,6 +1067,30 @@
{/if} + {#if show('Row spacing', 'Vertical space each row of the data grid takes')} +
+
+

Row spacing

+

+ Vertical space each row of the data grid takes. Compact fits about a third more rows on screen; relaxed is easier to track across a wide table. +

+
+ ({ value: id, label: s.label, keywords: [s.label] }))} + /> +
+ {/if} + {#if show('Alternating row colors', 'Shade every other grid row')} + {@render switchRow( + 'Alternating row colors', + 'Shade every other row so a long row stays readable across the full width. The Striped and Dots grid styles already do this as part of their look.', + settings.zebraRows, + toggleZebraRows, + )} + {/if} {#if show('Cell alignment', 'Which side grid cell text sits on')}
@@ -847,6 +1108,15 @@
{/if} + {#if show('Native scrolling', 'Let the OS scroll the grid and the sidebar instead of the app\'s eased scrolling')} + {@render switchRow( + 'Native scrolling', + "Hand the data grid and the sidebar back to the OS scroller. Off (the default) the app eases each wheel tick to its destination, which reads as smoother on mice that scroll in big jumps; on, scrolling behaves exactly like the rest of your desktop (including its own momentum) and nothing of ours sits in front of the wheel.", + settings.nativeScroll, + toggleNativeScroll, + )} + {/if} + {@render secLabel($t('settings.sec.display'))} {#if show($t('settings.zoom'), $t('settings.zoom.desc'))}
diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte index 5ce1c454..581030d5 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -4,9 +4,12 @@ import Icon from "./Icon.svelte"; import SearchableMenu from "./SearchableMenu.svelte"; import { listDatabases, canSwitchDatabase, currentDatabaseKey } from "$lib/databases.js"; + import { dbAdminKind, dbActionBlocker } from "$lib/database-admin.js"; import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js"; import DangerousActionDialog from "./DangerousActionDialog.svelte"; import { readOnlyMode, guardWrite, READ_ONLY_HINT } from "$lib/stores/read-only.js"; + import { appNativeScroll } from "$lib/stores/settings.js"; + import { smoothScroll } from "$lib/smooth-scroll.js"; import * as Select from "$lib/components/ui/select/index.js"; import * as ContextMenu from "$lib/components/ui/context-menu/index.js"; import PanelRight from "@lucide/svelte/icons/panel-right"; @@ -17,6 +20,7 @@ import { cn } from "$lib/utils.js"; import { t } from "$lib/i18n.js"; import { formatTableRowCount } from "$lib/table-list.js"; + import { virtualWindow, offsetWithin, measureRowStride, VIRT_THRESHOLD, VIRT_BUFFER } from "$lib/virtual-window.js"; import { clampNavSidebarWidth, loadLayout, @@ -74,6 +78,22 @@ /** Switch the live connection to another database on the same server. * @type {(entry: { key: string, label: string }) => void} */ onswitchdatabase = () => {}, + /** Open the Create database dialog. */ + onnewdatabase = () => {}, + /** Server-level database actions. Each takes the row's name, plus the full + * list so the dialogs can check the new name against it. + * @type {(args: { name: string, existing: string[] }) => void} */ + onrenamedatabase = () => {}, + /** @type {(args: { name: string, existing: string[] }) => void} */ + onduplicatedatabase = () => {}, + /** @type {(args: { name: string }) => void} */ + ondropdatabase = () => {}, + /** @type {(args: { name: string }) => void} */ + ondatabaseinfo = () => {}, + /** Close every other session on a database (Postgres). @type {(args: { name: string }) => void} */ + onterminatedbsessions = () => {}, + /** Bumped by the shell to force a refetch of the database list. */ + databasesRefreshKey = 0, onviewddl = /** @type {(table: string) => void} */ (() => {}), onexportsql = /** @type {(table: string) => void} */ (() => {}), onexportdata = /** @type {(table: string) => void} */ (() => {}), @@ -183,8 +203,28 @@ dbEntriesError = '' }) + // The shell bumps this after a create/rename/duplicate/drop, since the list it + // invalidated lives here. Refetch rather than patch: the statement may have + // failed halfway, and the server is the only honest source. + $effect(() => { + if (databasesRefreshKey === 0) return + databasesRefreshKey + untrack(() => { void loadDatabases() }) + }) + const canSwitchDb = $derived(canSwitchDatabase(connection)) const activeDbKey = $derived(currentDatabaseKey(connection)) + const dbAdmin = $derived(dbAdminKind(connection)) + /** Names of every listed database, for the dialogs' collision checks. */ + const dbNames = $derived(dbEntries.map((d) => d.label)) + + /** Menu item state for one database row: enabled, or disabled with a reason. + * @param {import('$lib/database-admin.js').AdminAction} action @param {boolean} isCurrent */ + function dbItem(action, isCurrent) { + if ($readOnlyMode) return { disabled: true, title: READ_ONLY_HINT } + const blocker = dbActionBlocker(action, connection, { isCurrent }) + return { disabled: !!blocker, title: blocker || undefined } + } $effect(() => { saveSidebarSection('tables', tablesOpen) }) $effect(() => { saveSidebarSection('views', viewsOpen) }) $effect(() => { saveSidebarSection('matViews', matViewsOpen) }) @@ -456,13 +496,10 @@ const filteredMatViews = $derived( lf ? sortedMatViewsBase.filter((t) => t.name.toLowerCase().includes(lf)) : sortedMatViewsBase, ); - // The views / materialized-views lists aren't windowed (unlike the tables - // list), so a schema with thousands of views would instantiate thousands of - // context-menu components. Render at most VIEW_RENDER_CAP and hint to search - // for the rest - only pathological schemas ever hit this. - const VIEW_RENDER_CAP = 500; - const viewsToRender = $derived(filteredViews.length > VIEW_RENDER_CAP ? filteredViews.slice(0, VIEW_RENDER_CAP) : filteredViews); - const matViewsToRender = $derived(filteredMatViews.length > VIEW_RENDER_CAP ? filteredMatViews.slice(0, VIEW_RENDER_CAP) : filteredMatViews); + // Views, materialized views and databases are windowed on the same maths as + // the tables list (see the virtual-list block below). They used to render a + // capped 500 rows instead, which both instantiated 500 context menus and hid + // whatever came after row 500 without saying so. // ── Counts for section badges ────────────────────────────────────────────── // The TABLES list draws from regular tables minus pins; use that as the "total" @@ -492,14 +529,17 @@ function setAllSections(/** @type {boolean} */ open) { recentOpen = open; tablesOpen = open; viewsOpen = open; matViewsOpen = open; } - // ── Virtual list (tables only) ─────────────────────────────────────────── - const VIRT_THRESHOLD = 40 // kick in early - 40+ tables already benefits from virtualization - const VIRT_BUFFER = 12 // extra rows rendered above and below the viewport - // Row stride is MEASURED from the DOM, not assumed: row height scales with the - // app zoom / font-size (Linux even uses a 15px base), and any drift between an - // assumed constant and reality × hundreds of rows = phantom scroll space below - // the last table (the "keeps scrolling past the end" gutter). 27px is only the - // pre-measure fallback. + // ── Virtual lists (tables, views, materialized views, databases) ───────── + // The window maths lives in $lib/virtual-window.js; this block owns the two + // measurements only the DOM can answer - the row stride and where each list + // sits inside the scrolled content. + // + // Row stride is MEASURED, not assumed: row height scales with the app zoom / + // font-size (Linux even uses a 15px base), and any drift between an assumed + // constant and reality × hundreds of rows = phantom scroll space below the + // last row (the "keeps scrolling past the end" gutter). 27px is only the + // pre-measure fallback. Every list uses the same row chrome, so one stride + // covers all four. let rowH = $state(27) $effect(() => { const el = tableListEl @@ -507,15 +547,14 @@ if (!el || typeof ResizeObserver === 'undefined') return const measure = () => { // Spacer
  • s are aria-hidden - measure the stride between two real rows. - const rows = /** @type {NodeListOf} */ (el.querySelectorAll('li:not([aria-hidden])')) - if (rows.length >= 2) { - const stride = rows[1].offsetTop - rows[0].offsetTop + const stride = measureRowStride(el) + if (stride !== null) { // Read rowH untracked: this effect must NOT depend on the value it writes, // or setting rowH re-runs it, and a stride that doesn't settle in one pass // spins until Svelte's infinite-loop guard trips. The ResizeObserver still // re-measures on real layout changes. const cur = untrack(() => rowH) - if (stride > 10 && Math.abs(stride - cur) > 0.5) rowH = stride + if (Math.abs(stride - cur) > 0.5) rowH = stride } // Same observer covers the other half of the window maths: a layout change // in the list also means its offset in the scroll container may have moved. @@ -530,10 +569,11 @@ if (content) ro.observe(content) return () => ro.disconnect() }) - // The window shifts one row at a time: `virtStart` is floored to ROW_H, so the - // derived already short-circuits (returns the same value) for every scroll event - // within a row - no re-render, no spacer resize until you actually cross a row - // boundary. That keeps each update tiny (±1 row) instead of a batched chunk hitch. + // The window shifts one row at a time: the start index is floored to the row + // stride, so the derived short-circuits (same value) for every scroll event + // within a row - no re-render, no spacer resize until a row boundary is + // actually crossed. Each update stays tiny (±1 row) instead of arriving as a + // batched chunk hitch. /** @type {HTMLElement | null} */ let scrollContainerEl = $state(null) @@ -550,27 +590,42 @@ if (!scrollContainerEl) return sidebarScrollTop = scrollContainerEl.scrollTop } - /** Offset of the tables
      from the top of the scroll container. Re-measured - * whenever sections above it open/close (recent, pinned) or refs change. */ + /** Offset of each windowed
        from the top of the scroll container. + * Re-measured whenever anything above one of them opens, closes or filters. */ let tableListOffsetTop = $state(0) + let viewListOffsetTop = $state(0) + let matViewListOffsetTop = $state(0) + let dbListOffsetTop = $state(0) + + /** @type {HTMLElement | null} */ + let viewListEl = $state(null) + /** @type {HTMLElement | null} */ + let matViewListEl = $state(null) + /** @type {HTMLElement | null} */ + let dbListEl = $state(null) /** - * Distance from the top of the scrolled content to the tables
          . + * Re-measure where every windowed list sits in the scrolled content. * - * Measured from live rects rather than walked through `offsetParent`: that walk + * Read from live rects rather than walked through `offsetParent`: that walk * only terminated when an ancestor happened to be the scroll container, and it * ran off an enumerated list of "things above the list". Anything else that * grew above it - expanding the databases section, a filter that changes the - * pinned block - left the offset stale and small, which pushed `virtStart` far + * pinned block - left the offset stale and small, which pushed the window far * past the real first visible row: the list rendered its tail behind a giant * empty spacer (the black gap). */ function measureListOffset() { - const el = tableListEl const container = scrollContainerEl - if (!el || !container) return - const off = el.getBoundingClientRect().top - container.getBoundingClientRect().top + container.scrollTop - if (Math.abs(off - tableListOffsetTop) > 0.5) tableListOffsetTop = off + if (!container) return + const tables = offsetWithin(tableListEl, container) + if (tables !== null && Math.abs(tables - tableListOffsetTop) > 0.5) tableListOffsetTop = tables + const views = offsetWithin(viewListEl, container) + if (views !== null && Math.abs(views - viewListOffsetTop) > 0.5) viewListOffsetTop = views + const matViews = offsetWithin(matViewListEl, container) + if (matViews !== null && Math.abs(matViews - matViewListOffsetTop) > 0.5) matViewListOffsetTop = matViews + const dbs = offsetWithin(dbListEl, container) + if (dbs !== null && Math.abs(dbs - dbListOffsetTop) > 0.5) dbListOffsetTop = dbs } // Re-measure whenever anything that can move the list re-renders. Cheap: two @@ -580,25 +635,40 @@ void visiblePinnedTables.length void showRecent void filteredRegularTables.length + void filteredViews.length + void filteredMatViews.length + void filteredDbEntries.length void tablesOpen + void viewsOpen + void matViewsOpen + void databasesOpen void tableListEl + void viewListEl + void matViewListEl + void dbListEl void scrollContainerEl measureListOffset() }) - const shouldVirtualize = $derived(filteredRegularTables.length > VIRT_THRESHOLD) + /** @param {number} count @param {number} offsetTop */ + function windowFor(count, offsetTop) { + return virtualWindow({ + count, + scrollTop: sidebarScrollTop, + viewportHeight: sidebarHeight, + offsetTop, + rowH, + }) + } - const virtStart = $derived.by(() => { - if (!shouldVirtualize) return 0 - return Math.max(0, Math.floor((sidebarScrollTop - tableListOffsetTop) / rowH) - VIRT_BUFFER) - }) - const virtEnd = $derived.by(() => { - if (!shouldVirtualize) return filteredRegularTables.length - const end = Math.ceil((sidebarScrollTop + sidebarHeight - tableListOffsetTop) / rowH) + VIRT_BUFFER - return Math.min(filteredRegularTables.length, end) - }) - const virtTopPad = $derived(shouldVirtualize ? virtStart * rowH : 0) - const virtBotPad = $derived(shouldVirtualize ? Math.max(0, (filteredRegularTables.length - virtEnd) * rowH) : 0) + const tableWin = $derived(windowFor(filteredRegularTables.length, tableListOffsetTop)) + const viewWin = $derived(windowFor(filteredViews.length, viewListOffsetTop)) + const matViewWin = $derived(windowFor(filteredMatViews.length, matViewListOffsetTop)) + const dbWin = $derived(windowFor(filteredDbEntries.length, dbListOffsetTop)) + + const viewsToRender = $derived(filteredViews.slice(viewWin.start, viewWin.end)) + const matViewsToRender = $derived(filteredMatViews.slice(matViewWin.start, matViewWin.end)) + const dbEntriesToRender = $derived(filteredDbEntries.slice(dbWin.start, dbWin.end)) /** Shared field chrome for schema select + table filter (aligned in sidebar grid) */ const sidebarFieldClass = @@ -865,6 +935,7 @@ bind:clientHeight={sidebarHeight} class="app-scroll min-h-0 w-full flex-1 overflow-y-auto overscroll-y-contain [will-change:scroll-position]" role="none" + use:smoothScroll={{ enabled: !$appNativeScroll }} onscroll={onSidebarScroll} onclick={(e) => { if (selectedItems.size > 0 && !/** @type {Element} */(e.target).closest?.('li')) { @@ -921,9 +992,20 @@ {/if} {#if databasesOpen} + {#if dbAdmin} + + {/if} + + + + + + {#if !isCurrent} + onswitchdatabase(db)}> + + Switch to this database + + {/if} + { navigator.clipboard.writeText(db.label) }}> + + Copy name + + {#if dbAdmin} + ondatabaseinfo({ name: db.label })}> + + Database info + + + {@const ren = dbItem('rename', isCurrent)} + onrenamedatabase({ name: db.label, existing: dbNames })}> + + Rename… + + {@const dup = dbItem('duplicate', isCurrent)} + onduplicatedatabase({ name: db.label, existing: dbNames })}> + + Duplicate… + + {@const term = dbItem('terminate', isCurrent)} + {#if !term.disabled} + onterminatedbsessions({ name: db.label })}> + + Close other sessions + + {/if} + + {@const drp = dbItem('drop', isCurrent)} + ondropdatabase({ name: db.label })}> + + Drop database… + + {/if} + + {/each} + {#if dbWin.botPad > 0}{/if}
        {/if} {/if} @@ -1245,8 +1375,8 @@ No tables match {:else} - {#if virtTopPad > 0}{/if} - {#each filteredRegularTables.slice(virtStart, virtEnd) as table (table.name)} + {#if tableWin.topPad > 0}{/if} + {#each filteredRegularTables.slice(tableWin.start, tableWin.end) as table (table.name)} {@const isSelected = selectedItems.has(table.name)}
      • @@ -1268,7 +1398,7 @@ }} > { e.stopPropagation(); selectItem(table.name, e.shiftKey) }} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); selectItem(table.name, e.shiftKey); } }} role="checkbox" @@ -1276,10 +1406,10 @@ tabindex="-1" > {#if isSelected} - + {:else} - - @@ -1289,26 +1419,16 @@ {/if} {#if showRowCount} - + - {#if table.rowCount == null} - - - {:else} - {formatTableRowCount(table.rowCount)} - {/if} + {#if table.rowCount != null}{formatTableRowCount(table.rowCount)}{/if} {/if} @@ -1431,7 +1551,7 @@
      • {/each} - {#if virtBotPad > 0}{/if} + {#if tableWin.botPad > 0}{/if} {/if}
  • @@ -1462,7 +1582,7 @@ {/if} {#if viewsOpen} -
      +
        {#if filteredViews.length === 0}
      • {:else} + {#if viewWin.topPad > 0}
      • {/if} {#each viewsToRender as view (view.name)} {@const isSelected = selectedItems.has(view.name)}
      • @@ -1527,11 +1648,7 @@
      • {/each} - {#if filteredViews.length > VIEW_RENDER_CAP} -
      • - +{filteredViews.length - VIEW_RENDER_CAP} more, search to narrow -
      • - {/if} + {#if viewWin.botPad > 0}{/if} {/if}
      {/if} @@ -1590,7 +1707,7 @@ {/if} {#if matViewsOpen} -
        +
          {#if filteredMatViews.length === 0}
        • {:else} + {#if matViewWin.topPad > 0}
        • {/if} {#each matViewsToRender as mv (mv.name)} {@const isSelected = selectedItems.has(mv.name)}
        • @@ -1657,11 +1775,7 @@
        • {/each} - {#if filteredMatViews.length > VIEW_RENDER_CAP} -
        • - +{filteredMatViews.length - VIEW_RENDER_CAP} more, search to narrow -
        • - {/if} + {#if matViewWin.botPad > 0}{/if} {/if}
        {/if} diff --git a/src/lib/components/SqlConsole.svelte b/src/lib/components/SqlConsole.svelte index c9e0e60e..06881f74 100644 --- a/src/lib/components/SqlConsole.svelte +++ b/src/lib/components/SqlConsole.svelte @@ -67,6 +67,9 @@ queryMs = 0, message = "", loading = false, + /** Cancel handle for the run in flight in THIS tab - so Stop can't cancel + * a query another tab happens to be running at the same time. */ + runningQueryId = /** @type {string | null} */ (null), error = "", /** @type {any[]} */ multiResults = [], @@ -364,7 +367,7 @@ }) // The JSON view materialises a real object per row AND a formatted string of - // all of them — both O(rows × columns) — while the table beside it is canvas- + // all of them - both O(rows × columns) - while the table beside it is canvas- // virtualised and pays for neither. Unbounded, a `SELECT *` over a million // rows spent its time building a string far too large to read, and often ran // the webview out of memory before it could show any of it. @@ -506,7 +509,7 @@ variant="destructive" size="sm" class="h-7 shrink-0 gap-2 pl-2.5 pr-2 font-medium shadow-sm" - onclick={() => void cancelQuery()} + onclick={() => void cancelQuery(runningQueryId ?? undefined)} title={tipText('Stop', 'Cancel the running query.')} > diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index 0fa67443..518fb711 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -24,7 +24,8 @@ import { createHotkey, createHotkeySequence } from '@tanstack/svelte-hotkeys' import { IS_MAC } from '$lib/shortcuts.js' import { findSearchInput, isTypingTarget } from '$lib/focus-search.js' - import { cycleTheme, restorePreviousTheme, isCurrentThemeDark, loadSettings, appPaginationMode, appVimMode } from '$lib/stores/settings.js' + import { cycleTheme, restorePreviousTheme, isCurrentThemeDark, loadSettings, appPaginationMode, appVimMode, appAutoSaveQueries } 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' import { @@ -78,12 +79,26 @@ import QueryLogConsole from './QueryLogConsole.svelte' import DisconnectDialog from './DisconnectDialog.svelte' import SwitchDatabaseDialog from './SwitchDatabaseDialog.svelte' + import CreateDatabaseDialog from './CreateDatabaseDialog.svelte' + import DatabaseNameDialog from './DatabaseNameDialog.svelte' + import DropDatabaseDialog from './DropDatabaseDialog.svelte' + import DatabaseInfoDialog from './DatabaseInfoDialog.svelte' + import { CatalogCache, catalogKey, connectionPrefix } from '$lib/catalog-cache.js' + import { refreshExternalPlugins, stopAllExternalPlugins } from '$lib/plugins/external/host.js' + import { + dbAdminKind, + createDatabaseSql, + dropDatabaseSql, + terminateSessionsSql, + databaseInfoSql, + databaseInfoRows, + } from '$lib/database-admin.js' import McpPanel from './McpPanel.svelte' // NotebookEditor (pulls Monaco via SqlCell + marked via MarkdownCell) is lazy-loaded // at its render site so notebooks don't drag those into the startup bundle. // - // The keep-alive tab pages — SearchPage, SchemaTimelinePage, SchemaPage, - // BackupPage, LogsPage, InstanceInsightsPage, ObjectsPage, RedisKeyspacePage — + // The keep-alive tab pages - SearchPage, SchemaTimelinePage, SchemaPage, + // BackupPage, LogsPage, InstanceInsightsPage, ObjectsPage, RedisKeyspacePage - // are lazy-loaded at their render sites for the same reason. Each is already // behind an `{#if …EverOpened}` guard, so a static import only ever meant // "ship this page's code to every user at boot whether or not they open it". @@ -136,7 +151,9 @@ createSecurityTab, createLogsTab, createInsightsTab, + createAdvisorTab, findInsightsTab, + findAdvisorTab, createObjectsTab, findObjectsTab, createRedisTab, @@ -196,6 +213,7 @@ import { humanizeDbError } from '$lib/ai.js' import { MAX_PAGE_SIZE, + fetchLimitFor, PAGE_SIZE_ALL, DEFAULT_PAGE_SIZE, saveDefaultPageSize, @@ -207,6 +225,18 @@ buildSelectSql, readRowsResponse, } from '$lib/table-query.js' + import { + WINDOW_PROBE, + WINDOW_ROWS_DEFAULT, + WINDOW_THRESHOLD, + measureRowBytes, + pickWindowRows, + seekKeyFor, + shouldWindow, + stableWindowOrder, + windowKeepCount, + windowsFullyCovered, + } from '$lib/row-window.js' import { buildForeignKeyFilters, buildReverseForeignKeyFilters, @@ -260,6 +290,7 @@ listQueryHistory, listSavedQueries, createSavedQuery, + saveQueryOnce, } from '$lib/stores/query-history.js' import { recordActivity } from '$lib/stores/activity-log.js' import { loadRecentTabs, pushRecentTab, removeRecentTab, clearRecentTabs } from '$lib/stores/recent-tabs.js' @@ -389,7 +420,7 @@ /** Security (RLS, policies, roles) is PostgreSQL-only. */ const hasSecurity = $derived(dbType === 'postgres' && !isRedis) /** - * PostGIS present on this connection — gates the Map view, which has nothing + * PostGIS present on this connection - gates the Map view, which has nothing * to show without it. Asked once per connection: it is a single indexed * catalog row, and the alternative (offering Map everywhere and dead-ending on * a non-spatial database) is worse than one cheap query. @@ -532,7 +563,7 @@ * Without it, moving around inside one table almost never records anything: the * row-gap threshold only fires past NAV_ROW_GAP rows, so clicking between two * nearby cells was unrecoverable and back/forward looked like it only worked - * across tabs. A click is aimed, so distance shouldn't decide — that threshold + * across tabs. A click is aimed, so distance shouldn't decide - that threshold * exists to stop *arrow-key roaming* filling the stack, and roaming still * refreshes in place. */ @@ -551,8 +582,8 @@ let activeSchema = $state('public') // $state.raw: a large schema is thousands of table objects, and deep $state // proxies every one of them plus every field. Nothing mutates a table in - // place — the list is always replaced wholesale (including the rowCount - // backfill, which rebuilds it with .map) — so the proxies bought nothing and + // place - the list is always replaced wholesale (including the rowCount + // backfill, which rebuilds it with .map) - so the proxies bought nothing and // cost a walk of the whole list on load plus a proxy hop on every read from // Sidebar's and CommandPalette's filter/map passes. let tables = $state.raw([]) @@ -672,6 +703,7 @@ let securityEverOpened = $state(false) let logsEverOpened = $state(false) let insightsEverOpened = $state(false) + let advisorEverOpened = $state(false) let objectsEverOpened = $state(false) let redisEverOpened = $state(false) let extensionsEverOpened = $state(false) @@ -929,6 +961,7 @@ if (activeTab?.kind === 'security') securityEverOpened = true if (activeTab?.kind === 'logs') logsEverOpened = true if (activeTab?.kind === 'insights') insightsEverOpened = true + if (activeTab?.kind === 'advisor') advisorEverOpened = true if (activeTab?.kind === 'objects') objectsEverOpened = true if (activeTab?.kind === 'redis') redisEverOpened = true if (activeTab?.kind === 'extensions') extensionsEverOpened = true @@ -963,6 +996,7 @@ { kind: 'security', get: () => securityEverOpened, set: (/** @type {boolean} */ v) => (securityEverOpened = v) }, { kind: 'backup', get: () => backupEverOpened, set: (/** @type {boolean} */ v) => (backupEverOpened = v) }, { kind: 'insights', get: () => insightsEverOpened, set: (/** @type {boolean} */ v) => (insightsEverOpened = v) }, + { kind: 'advisor', get: () => advisorEverOpened, set: (/** @type {boolean} */ v) => (advisorEverOpened = v) }, { kind: 'charts', get: () => chartsEverOpened, set: (/** @type {boolean} */ v) => (chartsEverOpened = v) }, { kind: 'dashboard', get: () => dashboardEverOpened, set: (/** @type {boolean} */ v) => (dashboardEverOpened = v) }, { kind: 'erd', get: () => erdEverOpened, set: (/** @type {boolean} */ v) => (erdEverOpened = v) }, @@ -1101,6 +1135,14 @@ } // Confirm before quitting the app while table edits are still unsaved. + // Installed plugins are read once, at startup: scanning a folder and starting a + // Worker per enabled plugin has no business happening on a repaint. The panel + // rescans on demand. + onMount(() => { + void refreshExternalPlugins() + return () => stopAllExternalPlugins() + }) + onMount(() => { let unlisten = () => {} ;(async () => { @@ -1205,6 +1247,11 @@ if ($appPaginationMode === 'offset') return false if (!_keysetKeyCol || !_keysetKeyType) return false if (pageSize === PAGE_SIZE_ALL) return false + // A page big enough to be windowed can't be keyset-driven: the windows are + // fetched by offset, so a keyset-ordered first fetch and offset-ordered + // windows would be two different orderings spliced into one view. Keyset + // exists to make *small* deep pages cheap, which a 1M-row page isn't. + if (Number.isFinite(pageSize) && pageSize > WINDOW_THRESHOLD) return false if ((connection?.type ?? '') !== 'postgres') return false // keyset is Postgres-only if (rowSortMore.length > 0) return false // multi-sort → offset if (rowSort && rowSort.column !== _keysetKeyCol) return false // sorted by a non-key col → offset @@ -1216,8 +1263,21 @@ // top, so a reload never leaves the view stranded mid-table. let reloadToken = $state(0) // Monotonic id so an out-of-order / superseded row fetch can't clobber a - // newer one when the user pages rapidly. - let _loadSeq = 0 + // newer one when the user pages rapidly. Kept PER TAB: a single global token + // meant starting a load in one tab silently invalidated a load still running + // in another, so a big table left fetching in the background was abandoned the + // moment you touched a second tab. + /** @type {Map} */ + const _loadSeqByTab = new Map() + /** @param {string | null} tabId */ + const loadSeqOf = (tabId) => (tabId ? _loadSeqByTab.get(tabId) ?? 0 : 0) + /** @param {string | null} tabId */ + function bumpLoadSeq(tabId) { + if (!tabId) return 0 + const n = loadSeqOf(tabId) + 1 + _loadSeqByTab.set(tabId, n) + return n + } // Infinite scroll - accumulated rows across all "load more" fetches. Plain // (non-reactive) array: handing a deep proxy to the grid would put get-traps // on every rows[r][c] read in the per-frame canvas draw (the scroll-lag @@ -1240,16 +1300,56 @@ // preserved (rows[i] is still row i), so selection / hit-testing / scroll are // untouched - only the *data* is windowed. dataVersion bumps trigger a grid // redraw after a window is spliced in (rows identity is unchanged). - const WINDOW_FETCH = 20_000 // rows per window request - const WINDOW_THRESHOLD = 200_000 // window only above this total - const WINDOW_KEEP = 4 // windows kept resident on each side of the viewport + // Window size is measured, not fixed - see $lib/row-window.js for why and how. + const WINDOW_MAX_INFLIGHT = 3 // concurrent window requests (rest queue) + const WINDOW_PREFETCH = 4 // windows fetched ahead in the scroll direction let windowed = $state(false) let dataVersion = $state(0) let _windowSeq = 0 + /** Rows per window for the current load (measured - see pickWindowRows). */ + let _windowRows = WINDOW_ROWS_DEFAULT + /** Absolute row offset this windowed view starts at (page offset; 0 for "All"). */ + let _windowBase = 0 + /** How many rows this windowed view covers (the page, or the whole table). */ + let _windowCount = 0 + /** Measured payload per row, for sizing the export chunk too. */ + let _windowBytesPerRow = 0 + /** The one total order every window of this view slices - see stableWindowOrder. + * @type {{ sortColumn: string, sortDirection: string, sorts: Array<{column:string,direction:string}> } | null} */ + let _windowOrder = null /** @type {Set} */ let _windowLoaded = new Set() /** @type {Set} */ let _windowFetching = new Set() + /** Windows wanted but not yet started, nearest-to-viewport first. @type {number[]} */ + let _windowQueue = [] + let _windowInFlight = 0 + /** Last visible window + travel direction, so prefetch runs ahead of the scroll. */ + let _lastFirstW = 0 + let _lastDir = 1 + // Set when a window fetch still comes back slow despite the sizing above (a slow + // link, a sort the server can't index). Prefetch depth and concurrency drop to 1: + // reading ahead then queues more work than the scroll can consume. + let _windowSlow = false + const WINDOW_SLOW_MS = 400 + /** Rolling mean window latency (EWMA), which is what _windowSlow now reads. + * A single measurement latched it: one deep page on a big table (a 350ms + * OFFSET scan, not a slow link) turned prefetch down to one window for the + * rest of the view - on exactly the tables that need read-ahead most. */ + let _windowMs = 0 + /** Failed windows → attempts so far, and the ones that have given up. + * A failed fetch used to be retried only by the next visible-range emit, so + * stopping the scroll left those rows shimmering forever. @type {Map} */ + let _windowAttempts = new Map() + /** @type {Set} */ + let _windowFailed = new Set() + const WINDOW_RETRIES = 3 + /** Set when a keyset window fetch errors - the view then stays on OFFSET. */ + let _windowSeekOff = false + /** What the window fetcher is doing, for the grid's loading pill. Plain state + * (not the hot-path sets, which stay non-reactive): assigned only when the + * answer actually changes. */ + let windowStatus = $state({ slow: false, failed: false }) let rowSearch = $state('') let rowSort = $state(/** @type {TableSort | null} */ (null)) /** Secondary sort keys for multi-column sort (primary is rowSort). @type {TableSort[]} */ @@ -1275,8 +1375,103 @@ let rowSearch = $state('') }) - /** Tracks which tab IDs currently have an in-flight background fetch. */ + /** Tracks which tab IDs have an in-flight background fetch (re-entry guard). */ const fetchingTabIds = new Set() + + // ── Per-tab auto-refresh ────────────────────────────────────────────────── + // An interval per tab, not one global setting: the table you're watching should + // re-poll while the one you're editing stays still. Only the tab on screen + // actually polls - a background tab re-fetches when you return to it anyway, so + // ticking there would spend queries on nothing. + /** @type {Map} */ + const _autoRefreshByTab = new Map() + let _autoRefreshTick = $state(0) + const activeAutoRefreshMs = $derived.by(() => { + void _autoRefreshTick + return activeTabId ? _autoRefreshByTab.get(activeTabId) ?? 0 : 0 + }) + /** @param {number} ms */ + function setActiveAutoRefresh(ms) { + if (!activeTabId) return + if (ms > 0) _autoRefreshByTab.set(activeTabId, ms) + else _autoRefreshByTab.delete(activeTabId) + _autoRefreshTick += 1 + } + // The timer is torn down and rebuilt whenever the interval or the active tab + // changes, so exactly one is ever running and it always belongs to what's on + // screen. Effects run after init, so the deriveds read here are live by then. + $effect(() => { + const ms = activeAutoRefreshMs + const tabId = activeTabId + if (!ms || !tabId) return + if (activeTab?.kind !== 'table') return + const timer = setInterval(() => { + // Never stack a refresh on a load still running, and never pull rows out + // from under an open cell editor or an in-flight save. + if (!activeTable || isTabBusy(tabId) || editingCell || savingCell) return + // keepScroll: this is a background update, not navigation - it must not jump + // the grid to the top or close the row inspector. + void loadRows({ keepScroll: true }) + }, ms) + return () => clearInterval(timer) + }) + /** Cancel handle of the run in flight per tab (`Stop` targets just that query). @type {Map} */ + const _sqlQueryIdByTab = new Map() + let _sqlRunSeq = 0 + /** Cancel handle for the tab on screen, handed to SqlConsole's Stop button. */ + const activeSqlQueryId = $derived.by(() => { + void _busyTick + return activeTabId ? _sqlQueryIdByTab.get(activeTabId) ?? null : null + }) + // ── Per-tab busy accounting ─────────────────────────────────────────────── + // A tab is busy while it owns an UNSETTLED PROMISE - not while a hand-written + // flag or counter says so. Both of those went wrong here: a flag let an older + // load clear a mark its successor still needed, and guarding the clear to fix + // that let a mark outlive its work, leaving the tab strip spinning forever + // after the rows had landed. A promise settles exactly once, and the runtime + // runs its continuation on every path - early return, throw, supersede - so + // the mark cannot leak or clear early. + /** @type {Map>>} */ + const _busyJobs = new Map() + // Bumped on every change so consumers (activeSqlQueryId) recompute without the + // map itself having to be a reactive proxy. The tab-strip spinner deliberately + // does NOT read this: it draws from each tab's own loadingRows/sqlLoading, which + // is written in the same patch as the rows and so can't disagree with them. + let _busyTick = $state(0) + /** + * Mark `tabId` busy until `promise` settles. Returns the SAME promise, so + * callers keep their own error handling; the bookkeeping hangs off a separate + * continuation whose rejection is already handled here. + * @template T @param {string | null} tabId @param {Promise} promise @returns {Promise} + */ + function trackBusy(tabId, promise) { + if (!tabId) return promise + let jobs = _busyJobs.get(tabId) + if (!jobs) { jobs = new Set(); _busyJobs.set(tabId, jobs) } + jobs.add(promise) + _busyTick += 1 + const settled = () => { + jobs.delete(promise) + if (jobs.size === 0) _busyJobs.delete(tabId) + _busyTick += 1 + } + promise.then(settled, settled) + return promise + } + /** Forget a tab's work entirely - the tab itself is gone. @param {string | null} tabId */ + function clearBusy(tabId) { + if (tabId && _busyJobs.delete(tabId)) _busyTick += 1 + } + /** + * Whether `tabId` really has work in flight *right now*. Snapshots store the + * loading flag so a tab you leave mid-query still reads as running, but a + * snapshot can outlive its query (duplicated tab, reconnect, restored session) + * - restoring it blindly would strand a spinner nothing ever clears. + * @param {string | null} tabId + */ + function isTabBusy(tabId) { + return !!tabId && (_busyJobs.get(tabId)?.size ?? 0) > 0 + } let error = $state('') /** Whether the error banner is showing the raw driver text. */ let showRawError = $state(false) @@ -1492,7 +1687,7 @@ let rowSearch = $state('') /** * Title-bar label for the current connection. A file-backed connection would - * otherwise show its whole path — and a D1 local database's miniflare path + * otherwise show its whole path - and a D1 local database's miniflare path * (`…/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/.sqlite`) both * overflows the bar and says nothing. Prefer the SQL database name, then the * connection's own label, and only fall back to the file's basename. @@ -1644,9 +1839,11 @@ let rowSearch = $state('') /** @returns {TableTabState} */ function captureTableSnapshot() { - // A windowed result set holds a huge sparse array - never cache it into the - // tab. Store empty columns/rows so re-activation takes the refetch path - // (fetchRowsForTab → loadRows) and re-establishes windowing cleanly. + // A windowed result set holds a sparse array as long as the whole table, so it + // never goes into `tabs` (a $state proxy would then trap every rows[r][c] read + // in the draw loop). It's parked in _liveRowsByTab instead and handed back on + // re-activation together with the window bookkeeping below - so leaving a + // million-row table and coming back no longer re-runs the fetch and the count. return { schema: activeSchema, table: activeTable, @@ -1656,13 +1853,23 @@ let rowSearch = $state('') rowSort: rowSort ? { ...rowSort } : null, rowSortMore: rowSortMore.map((s) => ({ ...s })), rowFilters: rowFilters.map((f) => ({ ...f })), - columns: windowed ? [] : columns, + columns, primaryKey, foreignKeys, rows: windowed ? [] : rows, + windowedHead: windowed, + windowedLoaded: windowed ? [..._windowLoaded] : [], + windowRows: windowed ? _windowRows : 0, + windowBase: windowed ? _windowBase : 0, + windowCount: windowed ? _windowCount : 0, + windowBytesPerRow: windowed ? _windowBytesPerRow : 0, + windowOrder: windowed ? _windowOrder : null, total, queryMs, - loadingRows: false, + // The real flag, not a hardcoded false: a table left mid-fetch has to read + // as still loading, or leaving the tab makes a 1M-row load look cancelled. + // applyTableSnapshot only trusts it while that tab is genuinely busy. + loadingRows, error, selected: new Set(selected), focusedRow, @@ -1678,8 +1885,8 @@ let rowSearch = $state('') } } - /** @param {TableTabState} s */ - function applyTableSnapshot(s) { + /** @param {TableTabState} s @param {string | null} [tabId] - owner, for the live-loading check */ + function applyTableSnapshot(s, tabId = null) { page = s.page pageSize = s.pageSize ?? loadDefaultPageSize() rowSearch = s.rowSearch ?? '' @@ -1691,8 +1898,11 @@ let rowSearch = $state('') foreignKeys = s.foreignKeys ?? [] rows = s.rows total = s.total + // The snapshot's count and its filters were taken together, so the sig for + // the state just restored above is the one this total belongs to. + _totalSig = rowPredicateSig queryMs = s.queryMs - loadingRows = s.loadingRows ?? false + loadingRows = !!s.loadingRows && isTabBusy(tabId) error = s.error selected = new Set(s.selected) focusedRow = s.focusedRow @@ -1729,14 +1939,14 @@ let rowSearch = $state('') } } - /** @param {SqlTabState} s */ - function applySqlSnapshot(s) { + /** @param {SqlTabState} s @param {string | null} [tabId] - owner, for the live-running check */ + function applySqlSnapshot(s, tabId = null) { sqlText = s.sqlText sqlColumns = s.sqlColumns sqlRows = s.sqlRows sqlQueryMs = s.sqlQueryMs sqlMessage = s.sqlMessage - sqlLoading = s.sqlLoading ?? false + sqlLoading = !!s.sqlLoading && isTabBusy(tabId) sqlError = s.sqlError } @@ -1745,7 +1955,7 @@ let rowSearch = $state('') * * A run outlives the tab being in front of it: the editor is one shared * component driven by shell-level state, so switching tabs used to swap that - * state out from under an in-flight query — the spinner stopped, the results + * state out from under an in-flight query - the spinner stopped, the results * pane showed the other tab's snapshot, and whatever came back landed in * whichever tab happened to be active by then. The query itself never * stopped; only the UI lost track of it. @@ -1762,6 +1972,22 @@ let rowSearch = $state('') tabs = next } + /** + * Write a partial update into a table tab's own state, whichever tab is in + * front. Same contract as patchSqlTab: a row fetch outlives the tab being + * visible, so its results (and its loading flag) belong to the tab that + * started it, never to whatever tab happens to be active when it lands. + * @param {string} tabId + * @param {Partial} patch + */ + function patchTableTab(tabId, patch) { + const i = tabs.findIndex((t) => t.id === tabId && t.kind === 'table') + if (i === -1) return + const next = [...tabs] + next[i] = { ...next[i], state: { .../** @type {TableTabState} */ (next[i].state), ...patch } } + tabs = next + } + function clearTableEditor() { activeTable = null page = 1 @@ -1803,8 +2029,10 @@ let rowSearch = $state('') if (t.kind === 'table') { const state = cloneTableTabState(captureTableSnapshot()) updated = { ...t, state, title: tableTabTitle(state) } - if (windowed) _liveRowsByTab.delete(activeTabId) - else _liveRowsByTab.set(activeTabId, rows) + // Windowed sets are cached the same way - the sparse array stays raw here + // and out of the reactive tree. evictColdTabRows drops it once the tab + // falls out of the recently-viewed window. + _liveRowsByTab.set(activeTabId, rows) } else if (t.kind === 'sql') { updated = { ...t, state: cloneSqlTabState(captureSqlSnapshot()) } } @@ -1817,13 +2045,18 @@ let rowSearch = $state('') /** @param {StudioTab} tab */ async function applyTabToEditor(tab) { + // Moving to a tab that isn't a SQL editor parks the editor's run state. A run + // that finishes while its own tab is in the background leaves the shared + // `sqlLoading` true (only the owning tab's state is patched), and the next + // snapshot of ANY sql tab would then capture that stale true as its own. + if (tab.kind !== 'sql') sqlLoading = false if (tab.kind === 'welcome' || tab.kind === 'ai' || tab.kind === 'schema' || tab.kind === 'orm' || tab.kind === 'ddl') { clearTableEditor() return } if (tab.kind === 'sql' && tab.state) { clearTableEditor() - applySqlSnapshot(cloneSqlTabState(/** @type {SqlTabState} */ (tab.state))) + applySqlSnapshot(cloneSqlTabState(/** @type {SqlTabState} */ (tab.state)), tab.id) return } if (tab.kind === 'table' && tab.state) { @@ -1834,8 +2067,12 @@ let rowSearch = $state('') } if (raw.columns.length === 0) { // No cached data - apply lightweight snapshot (no need to clone rows) - applyTableSnapshot(raw) - if (raw.table && !fetchingTabIds.has(tab.id)) void startTabFetch(tab.id) + if (windowed) resetWindowing() + applyTableSnapshot(raw, tab.id) + // A fetch this tab already has in flight (including a windowed loadRows + // it started before you left) keeps running and lands here on its own - + // starting a second one would duplicate the work and fight over the grid. + if (raw.table && !isTabBusy(tab.id)) void startTabFetch(tab.id) } else { // Has cached data - clone Sets so mutations don't bleed between tabs, and // restore the RAW rows reference (not the proxied tab.state.rows) so the @@ -1843,7 +2080,27 @@ let rowSearch = $state('') const snap = cloneTableTabState(raw) const rawRows = _liveRowsByTab.get(tab.id) if (rawRows) snap.rows = rawRows - applyTableSnapshot(snap) + // A windowed tab comes back with its sparse array and its resident window + // set intact, so the rows already fetched are still on screen and only new + // windows are requested. The length check is the consistency guard: without + // the cached array (evicted), fall through to a clean refetch. + if (raw.windowedHead) { + const want = raw.windowCount ?? raw.total + if (Array.isArray(rawRows) && want > 0 && rawRows.length === want) { + restoreWindowing(raw) + } else { + resetWindowing() + snap.rows = [] + snap.columns = [] + applyTableSnapshot(snap, tab.id) + if (raw.table && !isTabBusy(tab.id)) void startTabFetch(tab.id) + return + } + } else if (windowed) { + // Coming from a windowed tab into a normal one. + resetWindowing() + } + applyTableSnapshot(snap, tab.id) } } } @@ -1963,7 +2220,7 @@ let rowSearch = $state('') // ⌘B / Ctrl+B is bound in the CAPTURE phase on window, not through // createHotkey. The hotkey layer listens on `document` in the BUBBLE phase, so // anything between the focused element and the document that calls - // stopPropagation eats the chord first — and the places you most want to hide + // stopPropagation eats the chord first - and the places you most want to hide // the sidebar from are exactly those: the Monaco editors, the grid canvas, and // the bits-ui overlays. That is why SqlEditor and OrmRunner had to be handed a // manual `onmodb` callback to forward the key back out; capture makes the one @@ -1977,7 +2234,7 @@ let rowSearch = $state('') if (!modOnly || e.altKey || e.shiftKey) return if (e.key.toLowerCase() !== 'b') return // The connection dialog owns the screen while it's open, and it binds ⌘B to - // its own connections rail — toggling the workspace sidebar behind it would + // its own connections rail - toggling the workspace sidebar behind it would // be an invisible edit the user only discovers after closing the dialog. // Left un-stopped so the dialog's own handler still receives it. if (showConnectionModal) return @@ -2367,7 +2624,7 @@ let rowSearch = $state('') return () => document.removeEventListener('keydown', onFullscreenKey) }) - // "/" focuses the search box for whatever is on screen — the sidebar filter, + // "/" focuses the search box for whatever is on screen - the sidebar filter, // the settings search, a dialog's own field. It resolves its target from the // live DOM rather than being bound per field, so a search box added later is // reachable without anyone registering it. See $lib/focus-search.js. @@ -2386,7 +2643,7 @@ let rowSearch = $state('') createHotkey('?', (e) => { if (commandOpen || showConnectionModal || showSettingsModal || showShortcutsModal) return // Was a tag check, which missed contenteditable and anything Monaco routes - // through a non-textarea — so "?" in those opened the shortcuts panel + // through a non-textarea - so "?" in those opened the shortcuts panel // mid-sentence. if (isTypingTarget(document.activeElement)) return e.preventDefault() @@ -2626,6 +2883,13 @@ let rowSearch = $state('') _liveRowsByTab.clear() _tabRowsMru = [] closedTabStack = [] + _loadSeqByTab.clear() + fetchingTabIds.clear() + _sqlQueryIdByTab.clear() + _autoRefreshByTab.clear() + _autoRefreshTick += 1 + _busyJobs.clear() + _busyTick += 1 // Every tab id in the history just died with the tab list. resetNav(_nav) syncNavFlags() @@ -2712,7 +2976,7 @@ let rowSearch = $state('') function openDdlTab(ddlText, title) { const existing = tabs.find((t) => t.kind === 'ddl' && t.title === title) if (existing) { - // Refresh in place — the object may have been altered since it was opened. + // Refresh in place - the object may have been altered since it was opened. tabs = tabs.map((t) => (t.id === existing.id ? { ...t, state: { ddlText } } : t)) void activateTab(existing.id) return @@ -2781,6 +3045,10 @@ let rowSearch = $state('') openSingletonTab({ find: findInsightsTab, create: createInsightsTab }) } + function openAdvisorTab() { + openSingletonTab({ find: findAdvisorTab, create: createAdvisorTab }) + } + function openObjectsTab() { openSingletonTab({ find: findObjectsTab, create: createObjectsTab }) } @@ -2964,7 +3232,14 @@ let rowSearch = $state('') const evictable = (t) => { if (t.kind !== 'table' || t.id === activeId || keep.has(t.id)) return false const st = /** @type {TableTabState} */ (t.state) - return !!st && Array.isArray(st.rows) && st.rows.length > TAB_EVICT_ROW_THRESHOLD + if (!st) return false + if (Array.isArray(st.rows) && st.rows.length > TAB_EVICT_ROW_THRESHOLD) return true + // Windowed tabs keep their (huge, sparse) array outside the reactive tree, + // so `st.rows` is empty and the check above can't see them. Measure the + // cached array instead, or an open million-row table would be retained for + // the whole session however cold it got. + const cached = _liveRowsByTab.get(t.id) + return Array.isArray(cached) && cached.length > TAB_EVICT_ROW_THRESHOLD } // Most switches evict nothing. Test first so the common path doesn't rebuild // the tabs array - that write invalidates every consumer of `tabs` (tab strip, @@ -2974,7 +3249,9 @@ let rowSearch = $state('') if (!evictable(t)) return t _liveRowsByTab.delete(t.id) const st = /** @type {TableTabState} */ (t.state) - return { ...t, state: { ...st, rows: [], columns: [], selected: new Set() } } + // windowedHead cleared too: with the array gone the tab has to refetch, and + // the restore path must not look for a cache that no longer exists. + return { ...t, state: { ...st, rows: [], columns: [], selected: new Set(), windowedHead: false, windowedLoaded: [], windowCount: 0, loadingRows: false } } }) } @@ -3021,7 +3298,7 @@ let rowSearch = $state('') // Mid-travel: the cursor is being parked on an entry we already have. if (_navRestore > 0) return const cur = navCurrent(_nav) - // An aimed move inside the current tab is always a position worth keeping — + // An aimed move inside the current tab is always a position worth keeping - // unless it didn't actually move, which would just stack duplicates. const aimedJump = aimed && cur !== null && cur.tabId === tabId && row !== null && row !== cur.row @@ -3173,6 +3450,14 @@ let rowSearch = $state('') function rememberClosedTab(tab) { if (!tab || tab.kind === 'welcome') return _liveRowsByTab.delete(tab.id) + // Anything still in flight for this tab is now orphaned: dropping its load + // token makes the in-flight result fail its own liveness check instead of + // being applied to whatever tab took its place. + _loadSeqByTab.delete(tab.id) + fetchingTabIds.delete(tab.id) + _sqlQueryIdByTab.delete(tab.id) + if (_autoRefreshByTab.delete(tab.id)) _autoRefreshTick += 1 + clearBusy(tab.id) // Snapshot with a shallow state clone so later edits to the live tree can't // mutate what we'll restore. `id`/`pinned` are dropped - reopen mints fresh. const { id: _id, pinned: _pinned, ...rest } = tab @@ -3185,9 +3470,14 @@ let rowSearch = $state('') state.rows = [] state.columns = [] state.selected = new Set() + state.loadingRows = false + state.windowedHead = false + state.windowedLoaded = [] + state.windowCount = 0 } else if (state && tab.kind === 'sql') { state.sqlRows = [] state.sqlColumns = [] + state.sqlLoading = false } const snapshot = { ...rest, state } closedTabStack = [...closedTabStack, snapshot].slice(-CLOSED_TAB_STACK_MAX) @@ -3272,6 +3562,12 @@ let rowSearch = $state('') fresh.kind === 'table' ? cloneTableTabState(/** @type {TableTabState} */ (fresh.state)) : cloneSqlTabState(/** @type {SqlTabState} */ (fresh.state)) + // The copy owns no in-flight work, so it must not inherit the original's + // loading flags - nothing would ever clear them on the duplicate. + if (state) { + if (fresh.kind === 'table') /** @type {any} */ (state).loadingRows = false + else /** @type {any} */ (state).sqlLoading = false + } const copy = { id: crypto.randomUUID(), kind: fresh.kind, title: fresh.title, state } const idx = tabs.findIndex((t) => t.id === id) tabs = [...tabs.slice(0, idx + 1), copy, ...tabs.slice(idx + 1)] @@ -3671,11 +3967,12 @@ let rowSearch = $state('') } } - async function loadIndexes() { - if (!activeSchema) { indexes = []; return } + /** @param {string} schema */ + async function fetchIndexes(schema) { + if (!schema) return [] try { - const list = await listIndexes(activeSchema) - indexes = list + const list = await listIndexes(schema) + return list .map((i) => ({ name: i.name ?? '', tableName: i.tableName ?? i.table_name ?? '', @@ -3688,7 +3985,7 @@ let rowSearch = $state('') })) .filter((i) => i.name) } catch { - indexes = [] + return [] } } @@ -3837,24 +4134,26 @@ let rowSearch = $state('') } }) - async function loadEnums() { - if (!activeSchema || !engineSupports('enums', connection?.type)) { enums = []; return } + /** @param {string} schema */ + async function fetchEnums(schema) { + if (!schema || !engineSupports('enums', connection?.type)) return [] try { - const list = await listEnums(activeSchema) + const list = await listEnums(schema) // Dedupe values defensively: enum labels are a set, but a bad introspection // join could return repeats, and the schema pages key their {#each} on the // value - a duplicate would crash the view (each_key_duplicate). - enums = list.map((e) => ({ name: e.name ?? '', values: [...new Set(e.values ?? [])] })) + return list.map((e) => ({ name: e.name ?? '', values: [...new Set(e.values ?? [])] })) } catch { - enums = [] + return [] } } - async function loadTriggers() { - if (!activeSchema || !engineSupports('triggers', connection?.type)) { triggers = []; return } + /** @param {string} schema */ + async function fetchTriggers(schema) { + if (!schema || !engineSupports('triggers', connection?.type)) return [] try { - const list = await listTriggers(activeSchema) - triggers = list.map((t) => ({ + const list = await listTriggers(schema) + return list.map((t) => ({ name: t.name ?? '', tableName: t.tableName ?? t.table_name ?? '', timing: t.timing ?? 'AFTER', @@ -3863,15 +4162,16 @@ let rowSearch = $state('') enabled: t.enabled ?? true, })).filter((t) => t.name) } catch { - triggers = [] + return [] } } - async function loadSequences() { - if (!activeSchema || !engineSupports('sequences', connection?.type)) { sequences = []; return } + /** @param {string} schema */ + async function fetchSequences(schema) { + if (!schema || !engineSupports('sequences', connection?.type)) return [] try { - const list = await listSequences(activeSchema) - sequences = list.map((s) => ({ + const list = await listSequences(schema) + return list.map((s) => ({ name: s.name ?? '', dataType: s.dataType ?? s.data_type ?? 'bigint', startValue: s.startValue ?? s.start_value ?? 1, @@ -3882,22 +4182,82 @@ let rowSearch = $state('') ownedBy: s.ownedBy ?? s.owned_by ?? null, })).filter((s) => s.name) } catch { - sequences = [] + return [] } } + // ── Schema-level catalog ────────────────────────────────────────────────── + // Indexes, enums, triggers and sequences are four round trips that describe a + // whole schema, so they are fetched, cached and invalidated as one unit. The + // TTL is long compared with the table list's: none of these change without a + // DDL statement, and every path that runs DDL invalidates the connection. + const CATALOG_TTL_MS = 60_000 + + /** @typedef {{ indexes: any[], enums: any[], triggers: any[], sequences: any[] }} SchemaCatalog */ + + /** Push a catalog payload into the reactive state the pages read. @param {SchemaCatalog} c */ + function applySchemaCatalog(c) { + indexes = c.indexes + enums = c.enums + triggers = c.triggers + sequences = c.sequences + } + + /** @param {{ force?: boolean }} [opts] */ + async function loadSchemaCatalog({ force = false } = {}) { + const schema = activeSchema + if (!schema) { applySchemaCatalog({ indexes: [], enums: [], triggers: [], sequences: [] }); return } + const key = catalogKey(persistConnectionId, 'catalog', schema) + if (force) _catalog.invalidate(key) + const hit = /** @type {SchemaCatalog | undefined} */ (_catalog.get(key, CATALOG_TTL_MS)) + if (hit) { applySchemaCatalog(hit); return } + const [idx, enm, trg, seq] = await Promise.all([ + fetchIndexes(schema), fetchEnums(schema), fetchTriggers(schema), fetchSequences(schema), + ]) + // The schema can change while four requests are in flight; a late payload + // must not overwrite the catalog of wherever the user has since gone. + const payload = { indexes: idx, enums: enm, triggers: trg, sequences: seq } + _catalog.set(key, payload) + if (activeSchema === schema) applySchemaCatalog(payload) + } + + /** Refresh one kind on its own (a page's own refresh button) and keep the + * cached payload in step, so the next schema revisit does not undo it. + * @param {'indexes' | 'enums' | 'triggers' | 'sequences'} kind */ + async function reloadCatalogKind(kind) { + const schema = activeSchema + if (!schema) return + const fetched = kind === 'indexes' ? await fetchIndexes(schema) + : kind === 'enums' ? await fetchEnums(schema) + : kind === 'triggers' ? await fetchTriggers(schema) + : await fetchSequences(schema) + if (activeSchema !== schema) return + if (kind === 'indexes') indexes = fetched + else if (kind === 'enums') enums = fetched + else if (kind === 'triggers') triggers = fetched + else sequences = fetched + const key = catalogKey(persistConnectionId, 'catalog', schema) + const cached = /** @type {SchemaCatalog | undefined} */ (_catalog.get(key, CATALOG_TTL_MS)) + if (cached) _catalog.set(key, { ...cached, [kind]: fetched }) + } + // Table-list cache keyed by connection+schema. Rapid navigation (switching // between tabs in different schemas, reopening the sidebar) used to re-run the // full listTables round-trip - which includes a per-table row count - on every // move. A short TTL collapses those repeats while keeping counts near-live; // data-changing paths (connect, refresh, DDL) pass { force: true } to bypass it. const TABLE_LIST_TTL_MS = 3000 - /** @type {Map} */ - let _tableListCache = new Map() - // Which connection+schema the catalog sub-state (indexes/enums/triggers/ - // sequences) currently reflects - so a cached table-list hit never leaves it - // showing another schema's catalog. - let _catalogLoadedFor = '' + /** Every catalog read in one cache: table lists, schema catalogs, incoming FKs. + * Keyed connection-first so one connection's DDL can drop only its own entries. */ + const _catalog = new CatalogCache({ max: 128 }) + + /** Drop every cached catalog read for the live connection. Call after DDL, and + * on any refresh the user asked for - they are asking because they expect the + * catalog to have changed. */ + function invalidateCatalog() { + _catalog.invalidate(connectionPrefix(persistConnectionId)) + incomingFkCache = new Map() + } /** @param {{ force?: boolean }} [opts] */ async function loadTables({ force = false } = {}) { @@ -3909,10 +4269,11 @@ let rowSearch = $state('') // Captured once: activeSchema can change while the fetch is in flight, and // the background count pass must target the schema this list came from. const schemaAtCall = activeSchema - const key = `${persistConnectionId ?? ''}:${schemaAtCall}` - const cached = force ? null : _tableListCache.get(key) - if (cached && Date.now() - cached.at < TABLE_LIST_TTL_MS) { - tables = cached.tables + const key = catalogKey(persistConnectionId, 'tables', schemaAtCall) + if (force) invalidateCatalog() + const cached = force ? undefined : /** @type {any[] | undefined} */ (_catalog.get(key, TABLE_LIST_TTL_MS)) + if (cached) { + tables = cached loadingTables = false if (activeTable && !tables.find((t) => t.name === activeTable)) { activeTable = tables[0]?.name ?? null @@ -3930,9 +4291,7 @@ let rowSearch = $state('') rlsEnabled: t.rlsEnabled ?? null, })) .filter((t) => t.name) - _tableListCache.set(key, { tables, at: Date.now() }) - // Bound growth: one entry per connection:schema visited over a session. - if (_tableListCache.size > 64) _tableListCache.delete(_tableListCache.keys().next().value) + _catalog.set(key, tables) if (activeTable && !tables.find((t) => t.name === activeTable)) { activeTable = tables[0]?.name ?? null } @@ -3948,16 +4307,9 @@ let rowSearch = $state('') // Covers the cached path too - a list cached mid-resolve may still hold nulls. const unresolved = tables.filter((t) => t.rowCount === null).map((t) => t.name) if (unresolved.length > 0) void resolveRowCounts(key, schemaAtCall, unresolved) - // Reload the catalog sub-state only when it doesn't already reflect this - // connection+schema (or on a forced reload): revisiting a schema skips these - // four round-trips, but switching schemas still refreshes them. - if (force || _catalogLoadedFor !== key) { - _catalogLoadedFor = key - void loadIndexes() - void loadEnums() - void loadTriggers() - void loadSequences() - } + // The schema-level catalog rides the same cache, so returning to a schema + // costs nothing. A forced reload has already invalidated it above. + void loadSchemaCatalog() } /** @@ -3969,17 +4321,29 @@ let rowSearch = $state('') * @param {string[]} names */ async function resolveRowCounts(key, schema, names) { - try { - const counts = await getTableRowCounts(schema, names) - if (!counts?.length) return + // In chunks, in list order, so counts land in waves down the sidebar instead + // of all at the end. One request for the whole schema is all-or-nothing: on a + // 135-table production schema every COUNT(*) had to finish before a single + // number appeared, and a connection swapped or dropped part-way through threw + // the completed counts away with the rest - which is why the sidebar sat on a + // column of blanks. A chunk that fails now costs only its own tables. + const CHUNK = 12 + for (let i = 0; i < names.length; i += CHUNK) { // Stale guard: the user may have switched connection/schema meanwhile. - if (`${persistConnectionId ?? ''}:${activeSchema}` !== key) return - const byName = new Map(counts.map((c) => [c.name, normalizeTableRowCount(c.rowCount ?? c.row_count)])) - tables = tables.map((t) => (byName.has(t.name) ? { ...t, rowCount: byName.get(t.name) ?? null } : t)) - const cached = _tableListCache.get(key) - if (cached) _tableListCache.set(key, { tables, at: cached.at }) - } catch { - /* ignore - counts fill in on the next refresh instead */ + if (catalogKey(persistConnectionId, 'tables', activeSchema) !== key) return + try { + const counts = await getTableRowCounts(schema, names.slice(i, i + CHUNK)) + if (!counts?.length) continue + if (catalogKey(persistConnectionId, 'tables', activeSchema) !== key) return + const byName = new Map(counts.map((c) => [c.name, normalizeTableRowCount(c.rowCount ?? c.row_count)])) + tables = tables.map((t) => (byName.has(t.name) ? { ...t, rowCount: byName.get(t.name) ?? null } : t)) + // Patch the cached list in place. Re-setting stamps a new timestamp, which + // would extend the list's freshness window on every count that lands, so + // the entry is only patched when it is still there to patch. + if (_catalog.has(key)) _catalog.set(key, tables) + } catch { + /* ignore this chunk - its counts fill in on the next refresh instead */ + } } } @@ -4039,10 +4403,25 @@ let rowSearch = $state('') await reloadTableFromQuery(true) } + /** + * Identity of the WHERE a row count was taken under. "All" has no page size of + * its own, so it borrows `total` as the fetch limit - and that is only sound + * while `total` describes the view about to be fetched. Filter a table down to + * one row and then clear the filter: the count is still 1, so "All" asked for + * LIMIT 1 and drew a one-row grid under a footer reading "1-752 of 752". + * @param {Record} search @param {TableFilter[]} filters + */ + function predicateSig(search, filters) { + return JSON.stringify([search, filtersApiSignature(filters)]) + } + /** The predicate `total` belongs to; '' until a count lands. */ + let _totalSig = $state('') + const rowPredicateSig = $derived(predicateSig(apiSearch(rowSearch), rowFilters)) + /** `total` was counted under the search + filters now on screen. */ + const totalIsForThisView = $derived(total > 0 && _totalSig === rowPredicateSig) + /** Resolve the effective fetch limit for the current pageSize. */ - const effectivePageSize = $derived( - pageSize === PAGE_SIZE_ALL ? Math.min(total > 0 ? total : MAX_PAGE_SIZE, MAX_PAGE_SIZE) : pageSize, - ) + const effectivePageSize = $derived(fetchLimitFor(pageSize, total, totalIsForThisView)) /** @param {number} nextPage */ async function handlePageChange(nextPage) { @@ -4100,7 +4479,8 @@ let rowSearch = $state('') if (_tabFetches.get(tabId) === p) _tabFetches.delete(tabId) }) _tabFetches.set(tabId, p) - return p + // The spinner lives exactly as long as this promise. + return trackBusy(tabId, p) } /** Resolve once nothing is fetching rows for `tabId`. @param {string} tabId */ @@ -4118,60 +4498,80 @@ let rowSearch = $state('') async function fetchRowsForTab(tabId) { if (fetchingTabIds.has(tabId)) return fetchingTabIds.add(tabId) + // Re-entry guard only - the tab-strip spinner is tied to this function's + // promise by startTabFetch, so `done` never has to be reached for it to stop. + let _settled = false + const done = () => { + if (_settled) return + _settled = true + fetchingTabIds.delete(tabId) + } const getTab = () => tabs.find((t) => t.id === tabId) const tab = getTab() if (!tab || tab.kind !== 'table' || !tab.state) { - fetchingTabIds.delete(tabId) + done() return } const s = /** @type {TableTabState} */ (tab.state) if (!s.table) { - fetchingTabIds.delete(tabId) + done() return } // A fresh fetch replaces the row set, so any row-index-keyed staged changes // cached for this table no longer line up - drop them. clearPendingChanges(`${s.schema}.${s.table}`) - // Single helper to patch the tab state - avoids multiple tabs.map() calls per fetch + // Patch this tab's own state (shared helper - one tabs write per call). /** @param {Partial} patch */ - function patchTab(patch) { - const i = tabs.findIndex((t) => t.id === tabId) - if (i === -1) return - const next = [...tabs] - next[i] = { ...next[i], state: { .../** @type {TableTabState} */ (next[i].state), ...patch } } - tabs = next - } + const patchTab = (patch) => patchTableTab(tabId, patch) // Mark loading - one tabs write patchTab({ loadingRows: true, error: '' }) if (tabId === activeTabId) { loadingRows = true; error = '' } - // "All" on a large table would pull the whole set into this tab. Route the - // active tab through loadRows (which windows it) and skip prefetch entirely - // for large background tabs - they window on activation instead. - if (s.pageSize === PAGE_SIZE_ALL && (s.total > WINDOW_THRESHOLD || s.total <= 0)) { - fetchingTabIds.delete(tabId) - if (tabId === activeTabId) await loadRows() + // A big fetch limit must never be pulled whole into a tab - and especially not + // into a background one. Opening three tables with the page size on 1M used to + // fire three million-row requests at once and lock the app up for the duration. + // The active tab goes through loadRows (which probes, measures and windows); + // background tabs are left for activation. + const hugeLimit = + s.pageSize === PAGE_SIZE_ALL + ? (s.total > WINDOW_THRESHOLD || s.total <= 0) + : (Number.isFinite(s.pageSize) && s.pageSize > WINDOW_THRESHOLD) + if (hugeLimit) { + if (tabId === activeTabId) { + // loadRows takes over the loading flag (and clears it in its finally). + const p = loadRows() + done() + await p + } else { + // Nothing will fetch this tab until it's activated, so don't leave it + // marked as loading - that spinner would never stop. + patchTab({ loadingRows: false }) + done() + } return } // Keyset/cursor/temporal: route the active tab's first page through loadRows // so it's ordered by the key column (page 1 and cursor pages share one order). if (tabId === activeTabId && _keysetActive) { - fetchingTabIds.delete(tabId) _keysetCursor = null - await loadRows() + const p = loadRows() + done() + await p return } try { // Resolve the "All" sentinel - and guard a corrupt/unset value - into a // real fetch limit; the backend rejects limit < 1. Mirrors effectivePageSize. + // A snapshot's count and its filters were taken together, so that count + // does describe the view being refetched here. const limit = s.pageSize === PAGE_SIZE_ALL - ? Math.min(s.total > 0 ? s.total : MAX_PAGE_SIZE, MAX_PAGE_SIZE) + ? fetchLimitFor(PAGE_SIZE_ALL, s.total, true) : (Number.isFinite(s.pageSize) && s.pageSize > 0 ? s.pageSize : DEFAULT_PAGE_SIZE) const offset = s.pageSize === PAGE_SIZE_ALL ? 0 : (s.page - 1) * limit const { sortColumn, sortDirection, sorts } = sortForApi(s.rowSort, s.rowSortMore) @@ -4234,7 +4634,10 @@ let rowSearch = $state('') }) if (typeof n === 'number' && n >= 0) { patchTab({ total: n }) - if (tabId === activeTabId) total = n + if (tabId === activeTabId) { + total = n + _totalSig = predicateSig(apiSearch(s.rowSearch), s.rowFilters ?? []) + } } } catch { /* best-effort */ } })() @@ -4252,7 +4655,7 @@ let rowSearch = $state('') total = 0 } } finally { - fetchingTabIds.delete(tabId) + done() } } @@ -4262,44 +4665,245 @@ let rowSearch = $state('') _windowSeq++ _windowLoaded = new Set() _windowFetching = new Set() + _windowQueue = [] + _windowInFlight = 0 + _windowSlow = false + _windowBase = 0 + _windowCount = 0 + _windowRows = WINDOW_ROWS_DEFAULT + _windowBytesPerRow = 0 + _windowOrder = null + forgetWindowFetchState() + } + + /** Per-view fetch bookkeeping (latency, retries, seek fallback) - reset with + * the view it describes, so a new table doesn't inherit the last one's. */ + function forgetWindowFetchState() { + _windowMs = 0 + _windowSeekOff = false + _windowAttempts.clear() + _windowFailed.clear() + syncWindowStatus() } - /** Build the shared row-query options for the current view (search/sort/filter). */ + /** + * Re-establish windowing for a tab being restored from its snapshot: the sparse + * `rows` array is handed back by applyTabToEditor, and this restores the + * bookkeeping that says which windows it already holds, at the same window size + * the rows were fetched with. Bumping _windowSeq abandons any window still in + * flight for the tab we just left. + * @param {TableTabState} s + */ + function restoreWindowing(s) { + _windowSeq++ + _windowLoaded = new Set(s.windowedLoaded ?? []) + _windowFetching = new Set() + _windowQueue = [] + _windowInFlight = 0 + _windowSlow = false + _windowRows = s.windowRows && s.windowRows > 0 ? s.windowRows : WINDOW_ROWS_DEFAULT + _windowBase = s.windowBase ?? 0 + _windowCount = s.windowCount ?? (Array.isArray(s.rows) ? s.rows.length : 0) + _windowBytesPerRow = s.windowBytesPerRow ?? 0 + // Same total order as before, so windows fetched after the switch back still + // line up with the rows already resident. + _windowOrder = s.windowOrder ? { ...s.windowOrder, sorts: (s.windowOrder.sorts ?? []).map((k) => ({ ...k })) } : null + forgetWindowFetchState() + windowed = true + dataVersion++ + } + + /** Install a fresh windowed view over `count` rows starting at absolute `base`. */ + function beginWindowing(base, count, rowsPerWindow, bytesPerRow = 0, order = null) { + _windowSeq++ + _windowLoaded = new Set() + _windowFetching = new Set() + _windowQueue = [] + _windowInFlight = 0 + _windowSlow = false + _windowRows = rowsPerWindow + _windowBase = base + _windowCount = count + _windowBytesPerRow = bytesPerRow + _windowOrder = order + _lastFirstW = 0 + _lastDir = 1 + forgetWindowFetchState() + windowed = true + } + + /** Build the shared row-query options for the current view (search/sort/filter). + * A windowed view substitutes its stable order, so every window - and an export + * reading the same view - slices one and the same total ordering. */ function currentRowQuery(includeCount = false) { - const { sortColumn, sortDirection, sorts } = sortForApi(rowSort, rowSortMore) - return { ...apiSearch(rowSearch), sortColumn, sortDirection, sorts, filters: filtersForApi(rowFilters, columns), includeMeta: false, includeCount } + const order = (windowed && _windowOrder) ? _windowOrder : sortForApi(rowSort, rowSortMore) + return { ...apiSearch(rowSearch), ...order, filters: filtersForApi(rowFilters, columns), includeMeta: false, includeCount } } - /** Fetch one window and splice it into the sparse `rows` array in place. */ + /** The key this view can seek by instead of paging with OFFSET (see + * seekKeyFor), or null while the view has none / after one was refused. */ + function windowSeek() { + if (!windowed || _windowSeekOff) return null + return seekKeyFor({ + order: _windowOrder, + columns, + primaryKey, + dialect: connection?.type ?? '', + }) + } + + /** The row value window `w` can seek from (last row of the window before it), + * or null when that row isn't resident and the window must use OFFSET. */ + function windowAnchor(w, seek = windowSeek()) { + if (!seek || w <= 0) return null + const prev = rows[w * _windowRows - 1] + const v = prev ? prev[seek.index] : undefined + return v === undefined || v === null ? null : v + } + + /** Fetch one window and splice it into the sparse `rows` array in place. + * Window `w` covers view rows [w*_windowRows, …), which live at absolute table + * offset _windowBase + that - the view can be a page, not just the whole table. */ async function fetchWindow(w) { if (!windowed || w < 0 || _windowLoaded.has(w) || _windowFetching.has(w)) return - const offset = w * WINDOW_FETCH - if (offset >= total) return + const start = w * _windowRows + if (start >= _windowCount) return + const limit = Math.min(_windowRows, _windowCount - start) const seq = _windowSeq + const seek = windowSeek() + const anchor = windowAnchor(w, seek) + const keyset = anchor === null || !seek + ? null + : { column: seek.column, value: String(anchor), sqlType: seek.sqlType, after: true, desc: seek.desc } _windowFetching.add(w) + _windowInFlight++ + // Re-queued after giving up (a scroll, or the pill's Retry) - it's loading + // again, so the grid should say so rather than keep showing the error. + if (_windowFailed.delete(w)) syncWindowStatus() + const t0 = performance.now() try { - const data = await getTableRows(activeSchema, activeTable, WINDOW_FETCH, offset, currentRowQuery(false)) + const data = await getTableRows(activeSchema, activeTable, limit, _windowBase + start, { ...currentRowQuery(false), keyset }) + // Rolling, so the measure tracks the connection rather than latching on the + // first deep page. Recorded even for a superseded fetch - the round-trip + // was just as slow whether or not its rows are still wanted. + const ms = performance.now() - t0 + _windowMs = _windowMs ? _windowMs * 0.65 + ms * 0.35 : ms + _windowSlow = _windowMs > WINDOW_SLOW_MS if (seq !== _windowSeq) return // table / query changed while in flight const fetched = data.rows ?? [] - for (let i = 0; i < fetched.length; i++) rows[offset + i] = fetched[i] + for (let i = 0; i < fetched.length; i++) rows[start + i] = fetched[i] _windowLoaded.add(w) + _windowAttempts.delete(w) + _windowFailed.delete(w) dataVersion++ } catch { - // leave unloaded - the next visible-range emit retries + if (seq !== _windowSeq) return + if (keyset) { + // The cursor cast can be rejected for an exotic key type. Fall back for + // the whole view and retry this window on the offset path immediately - + // that path is always correct, just slower. + _windowSeekOff = true + _windowQueue.unshift(w) + return + } + // Retry with backoff. Without this a dropped connection left the rows + // shimmering until the user happened to scroll, since only a *change* of + // visible range re-queues anything. + const tries = (_windowAttempts.get(w) ?? 0) + 1 + _windowAttempts.set(w, tries) + if (tries > WINDOW_RETRIES) { + _windowFailed.add(w) + } else { + setTimeout(() => { + if (!windowed || seq !== _windowSeq || _windowLoaded.has(w)) return + _windowQueue.unshift(w) + pumpWindowQueue() + }, 300 * 2 ** (tries - 1)) + } } finally { _windowFetching.delete(w) + // Clamped: a reset (new query) zeroes the counter while requests are still + // in flight, and their finallys must not drive it negative. + _windowInFlight = Math.max(0, _windowInFlight - 1) + syncWindowStatus() + pumpWindowQueue() + } + } + + /** + * Start queued window fetches up to the concurrency cap. Bounded on purpose: a + * fast flick crosses many windows, and firing a request for each one at once + * both floods the pool and puts the rows the user is actually looking at behind + * a queue of windows already scrolled past. + */ + function pumpWindowQueue() { + const cap = _windowSlow ? 1 : WINDOW_MAX_INFLIGHT + while (windowed && _windowInFlight < cap && _windowQueue.length > 0) { + const i = nextStartableWindow() + if (i < 0) break + const w = _windowQueue.splice(i, 1)[0] + void fetchWindow(w) + } + } + + /** + * Which queued window to start next, or -1 to wait for one in flight. + * + * The queue is built viewport-first, so its head is what the user is looking + * at and always starts. The rest is read-ahead, and on a seekable view (see + * windowSeek) read-ahead is worth *waiting* for: a window whose predecessor + * has landed is an index seek, while firing four deep-OFFSET queries in + * parallel is four full index walks for the same rows. Nothing can wait + * forever - with nothing in flight to produce an anchor, the head starts on + * the offset path. + */ + function nextStartableWindow() { + const seek = windowSeek() + let head = -1 + for (let i = 0; i < _windowQueue.length; i++) { + const w = _windowQueue[i] + if (_windowLoaded.has(w) || _windowFetching.has(w)) continue + if (head < 0) { + if (!seek || _windowInFlight === 0) return i + head = i + continue + } + if (windowAnchor(w, seek) !== null) return i + } + // head >= 0 here only while something is in flight, so waiting costs at most + // one round-trip - after which that fetch's anchor may make read-ahead cheap. + return seek ? -1 : head + } + + /** Publish fetcher state for the grid's loading pill, only on a real change. */ + function syncWindowStatus() { + const failed = _windowFailed.size > 0 + if (windowStatus.slow !== _windowSlow || windowStatus.failed !== failed) { + windowStatus = { slow: _windowSlow, failed } } } + /** "Retry" in the grid's loading pill: re-queue every window that gave up. */ + function retryFailedWindows() { + if (!windowed || _windowFailed.size === 0) return + const again = [..._windowFailed] + _windowFailed.clear() + for (const w of again) _windowAttempts.delete(w) + _windowQueue = [...again, ..._windowQueue] + syncWindowStatus() + pumpWindowQueue() + } + /** Evict resident windows far from the viewport so memory stays bounded. */ function evictFarWindows(firstW, lastW) { if (!windowed) return let evicted = false + const keep = windowKeepCount(_windowRows) for (const w of _windowLoaded) { - if (w < firstW - WINDOW_KEEP || w > lastW + WINDOW_KEEP) { - const offset = w * WINDOW_FETCH - const endI = Math.min(offset + WINDOW_FETCH, total) - for (let i = offset; i < endI; i++) rows[i] = undefined + if (w < firstW - keep || w > lastW + keep) { + const start = w * _windowRows + const endI = Math.min(start + _windowRows, _windowCount) + for (let i = start; i < endI; i++) rows[i] = undefined _windowLoaded.delete(w) evicted = true } @@ -4308,31 +4912,61 @@ let rowSearch = $state('') } let _visRangeTimer = /** @type {ReturnType | null} */ (null) - /** DataTable reports its visible row range → load nearby windows, evict far ones. - * Debounced so fast scrolling doesn't fire a fetch for every window flown past. */ + /** + * DataTable reports its visible row range → load nearby windows, evict far ones. + * + * The windows under the viewport are requested immediately: those are the rows + * being drawn as skeletons right now, and the old 100ms debounce added that + * delay to every one of them. Only eviction waits for the scroll to settle - + * dropping windows mid-flick just re-fetches them a moment later. + */ function handleVisibleRange(start, end) { if (!windowed) return + const firstW = Math.floor(start / _windowRows) + const lastW = Math.floor(end / _windowRows) + if (firstW !== _lastFirstW) { + _lastDir = firstW > _lastFirstW ? 1 : -1 + _lastFirstW = firstW + } + // Priority order: what's on screen, then ahead in the direction of travel, + // then the window just behind (so a small scroll-back is already resident). + /** @type {number[]} */ + const want = [] + const ahead = _windowSlow ? 1 : WINDOW_PREFETCH + for (let w = firstW; w <= lastW; w++) want.push(w) + for (let i = 1; i <= ahead; i++) want.push(_lastDir >= 0 ? lastW + i : firstW - i) + want.push(_lastDir >= 0 ? firstW - 1 : lastW + 1) + // Rebuilt (not appended) every emit, so windows already scrolled past drop out + // of the queue instead of being fetched after the user has left them. + _windowQueue = want.filter( + (w) => w >= 0 && w * _windowRows < _windowCount && !_windowLoaded.has(w) && !_windowFetching.has(w), + ) + pumpWindowQueue() if (_visRangeTimer) clearTimeout(_visRangeTimer) _visRangeTimer = setTimeout(() => { _visRangeTimer = null - if (!windowed) return - const firstW = Math.floor(start / WINDOW_FETCH) - const lastW = Math.floor(end / WINDOW_FETCH) - for (let w = firstW - 1; w <= lastW + 1; w++) void fetchWindow(w) - evictFarWindows(firstW, lastW) - }, 100) + if (windowed) evictFarWindows(firstW, lastW) + }, 300) } - /** Fetch the full ordered result set in chunks (windowed export/copy path). */ + /** Fetch the full ordered result set in chunks (windowed export/copy path). + * Chunked coarser than the scroll windows - nothing is being rendered meanwhile, + * so throughput matters more than latency - but still by BYTES, since 50k rows + * of a table with an embedding column is most of a gigabyte in one response. */ + const EXPORT_CHUNK_MAX = 50_000 + const EXPORT_TARGET_BYTES = 20_000_000 async function fetchAllRows(onProgress) { + const chunk = _windowBytesPerRow > 0 + ? Math.max(500, Math.min(EXPORT_CHUNK_MAX, Math.round(EXPORT_TARGET_BYTES / _windowBytesPerRow))) + : EXPORT_CHUNK_MAX /** @type {any[]} */ const out = [] - for (let off = 0; off < total; off += WINDOW_FETCH) { - const data = await getTableRows(activeSchema, activeTable, WINDOW_FETCH, off, currentRowQuery(false)) + for (let off = 0; off < total; off += chunk) { + const data = await getTableRows(activeSchema, activeTable, chunk, off, currentRowQuery(false)) const r = data.rows ?? [] for (let i = 0; i < r.length; i++) out.push(r[i]) onProgress?.(out.length) - if (r.length < WINDOW_FETCH) break + if (r.length < chunk) break } return out } @@ -4343,7 +4977,16 @@ let rowSearch = $state('') * sort, search, page from global state) but update rows in place without * jumping the grid to the top or closing the row inspector. */ - async function loadRows({ keepScroll = false } = {}) { + function loadRows(opts = {}) { + // The tab that owns this load, captured before anything can await. Every + // write inside is routed through it, so switching or closing tabs mid-fetch + // neither cancels the query nor drops its rows into whichever tab happens to + // be in front when they arrive - and the tab strip spins until it settles. + return trackBusy(activeTabId, runLoadRows(opts, activeTabId)) + } + + /** @param {{ keepScroll?: boolean }} opts @param {string | null} ownerTabId */ + async function runLoadRows({ keepScroll = false } = {}, ownerTabId = null) { if (!activeTable) { columns = [] rows = [] @@ -4351,7 +4994,14 @@ let rowSearch = $state('') total = 0 return } - const seq = ++_loadSeq + const ownerSchema = activeSchema + const ownerTable = activeTable + const seq = bumpLoadSeq(ownerTabId) + /** Still the newest load for its tab, and that tab still exists. */ + const live = () => seq === loadSeqOf(ownerTabId) && tabs.some((t) => t.id === ownerTabId) + /** Owner is on screen - only then may this load touch the shared editor state. */ + const isActive = () => ownerTabId === activeTabId + patchTableTab(ownerTabId ?? '', { loadingRows: true, error: '' }) _windowSeq++ // discard any window fetches in flight from a prior query loadingRows = true _infiniteRows = [] @@ -4390,70 +5040,206 @@ let rowSearch = $state('') // fetches (pagination, sort, filter, live) reuse what we already hold, // skipping several round-trips per fetch. const includeMeta = columns.length === 0 - // Window only in "All" mode on a large set - fixed page sizes still - // paginate exactly as before. When windowing we cap the first fetch to one - // window and ask for the count up front (needed to size the sparse array). - const wantsWindow = pageSize === PAGE_SIZE_ALL && effectivePageSize > WINDOW_THRESHOLD + // A large fetch limit gets probed instead of pulled: 200 rows plus the count, + // then the rows are measured and either the rest is loaded normally (small, + // light result) or a windowed view is installed. + // + // Note this is NOT gated on the "All" sentinel. Page size 1M is an ordinary + // option in the toolbar, and taking it literally meant one request for a + // million rows - ~130MB of JSON decoded on the main thread, per tab, which + // froze the whole app. Any limit past the threshold is treated the same way + // now, whichever control produced it. + const wantsWindow = effectivePageSize > WINDOW_THRESHOLD + // A windowed view is assembled from many separate LIMIT/OFFSET queries, and + // those only line up if they all slice ONE total order - so the probe is + // ordered by the primary key too, whenever we already know it. On the very + // first load of a table the key arrives with this response, so the probe + // can't be ordered yet; its rows are then treated as provisional below. + const probeOrder = wantsWindow ? stableWindowOrder(rowSort, rowSortMore, primaryKey) : null + // Query shape frozen at call time. The remainder fetch below must not read + // it back off the globals - by then they may describe another tab. + const ownerQuery = { + ...apiSearch(rowSearch), + ...(probeOrder ?? { sortColumn, sortDirection, sorts }), + filters: filtersForApi(rowFilters, columns), + } + // Frozen with the query: the count this load produces belongs to THIS + // predicate, even if the user edits the filters while it is in flight. + const ownerSig = predicateSig(apiSearch(rowSearch), rowFilters) const data = await getTableRows( - activeSchema, activeTable, - wantsWindow ? WINDOW_FETCH : effectivePageSize, - wantsWindow ? 0 : offset, - { - ...apiSearch(rowSearch), - sortColumn, - sortDirection, - sorts, - filters: filtersForApi(rowFilters, columns), + ownerSchema, ownerTable, + wantsWindow ? WINDOW_PROBE : effectivePageSize, + offset, + { ...ownerQuery, keyset: keysetArg, includeMeta, // Don't wait on COUNT(*) - paint rows now, count streams in below. // Windowed loads need the total immediately to size the sparse array. includeCount: wantsWindow, }) - if (seq !== _loadSeq) return + if (!live()) return const nextColumns = data.columns ?? [] + const fetched = data.rows ?? [] + const windowTotal = wantsWindow ? Number(data.total ?? 0) : 0 + const ranMs = Number(data.queryMs ?? data.query_ms ?? 0) + // How long this view is, and how heavy: both decide whether to window. + // `articles` at 100k rows is small enough to load whole; `openai_docs` at + // 10k rows is not, because each row carries a ~17KB embedding. + const viewBase = wantsWindow ? offset : 0 + // A windowed view has to know exactly how long it is - a sparse array sized + // from a guess would leave skeleton rows past the end of the table forever. + // Engines that can't count (total ≤ 0) therefore keep the old behaviour: load + // the requested limit in full. + const countKnown = windowTotal > 0 + const viewCount = wantsWindow + ? (countKnown ? Math.max(0, Math.min(effectivePageSize, windowTotal - viewBase)) : effectivePageSize) + : fetched.length + const bytesPerRow = wantsWindow ? measureRowBytes(fetched) : 0 + const probeRows = wantsWindow ? pickWindowRows(bytesPerRow) : WINDOW_ROWS_DEFAULT + // The order every window will slice. Recomputed here because the primary key + // may only just have arrived with this response. + const pkNow = includeMeta ? (data.primaryKey ?? data.primary_key ?? []) : primaryKey + const windowOrder = wantsWindow ? stableWindowOrder(rowSort, rowSortMore, pkNow) : null + // No stable order (a table with no primary key) means no windowing: a single + // query is internally consistent, many unordered ones are not. Such a table + // loads whole, as it did before. + const useWindow = + wantsWindow && !!windowOrder && shouldWindow({ rowCount: viewCount, bytesPerRow, countKnown }) + // Probe rows belong in the sparse array only if they came from that same + // order. Otherwise they're a different slice of the table, and keeping them + // is exactly the "row 108 shows a different id each time" problem - so they + // are dropped and window 0 is fetched properly. + const probeUsable = !!probeOrder + if (includeMeta && nextColumns.length) { + lruSet(tableColumnsCache, `${ownerSchema}.${ownerTable}`, nextColumns) + } + + // ── The owner moved to the background while this ran ──────────────────── + // Commit into its own tab state and leave the visible editor untouched. + // (Before this branch existed, a 1M-row load landing after a tab switch + // overwrote whatever table was now on screen.) + if (!isActive()) { + const meta = includeMeta + ? { + primaryKey: data.primaryKey ?? data.primary_key ?? [], + foreignKeys: normalizeForeignKeys(data.foreignKeys ?? data.foreign_keys), + } + : {} + // Keep the columns the tab already holds when the shape is unchanged - + // they carry enum/nullable metadata a includeMeta:false fetch doesn't. + const prevCols = /** @type {TableTabState | null} */ (tabs.find((t) => t.id === ownerTabId)?.state ?? null)?.columns ?? [] + const cols = nextColumns.length && !sameColumnShape(prevCols, nextColumns) + ? nextColumns + : (prevCols.length ? prevCols : nextColumns) + if (useWindow) { + // Finish the windowed load into the tab that started it: the sparse array + // (raw, outside the reactive tree) plus the window bookkeeping. Switching + // back then shows the rows immediately instead of re-running the fetch and + // the count - which is the whole point of leaving a huge table loading. + const arr = new Array(viewCount) + if (probeUsable) { + for (let i = 0; i < fetched.length && i < viewCount; i++) arr[i] = fetched[i] + } + _liveRowsByTab.set(ownerTabId, arr) + patchTableTab(ownerTabId ?? '', { + columns: cols, + ...meta, + rows: [], + windowedHead: true, + // The probe covers only whole windows it filled completely; a partly + // covered one is refetched so no row is left silently missing. + windowedLoaded: probeUsable ? windowsFullyCovered(fetched.length, probeRows) : [], + windowRows: probeRows, + windowBase: viewBase, + windowCount: viewCount, + windowBytesPerRow: bytesPerRow, + windowOrder, + total: windowTotal, + queryMs: ranMs, + loadingRows: false, + error: '', + }) + return + } + let landed = fetched + if (wantsWindow && viewCount > fetched.length) { + const restData = await getTableRows(ownerSchema, ownerTable, Math.min(viewCount - fetched.length, MAX_PAGE_SIZE), viewBase + fetched.length, { ...ownerQuery, includeMeta: false, includeCount: false }) + if (!live()) return + landed = [...fetched, ...(restData.rows ?? [])] + } + _liveRowsByTab.set(ownerTabId, landed) + patchTableTab(ownerTabId ?? '', { + columns: cols, + ...meta, + rows: landed, + windowedHead: false, + windowedLoaded: [], + total: wantsWindow ? windowTotal : Number(data.total ?? 0), + queryMs: ranMs, + loadingRows: false, + error: '', + }) + return + } + // Update column shape whenever it actually changes (e.g. a column was // added/dropped) even on a metadata-skipping fetch; otherwise keep the // richer existing columns (which carry enum/nullable info). if (nextColumns.length && !sameColumnShape(columns, nextColumns)) columns = nextColumns if (includeMeta) { - if (activeTable) { - lruSet(tableColumnsCache, `${activeSchema}.${activeTable}`, columns) - } primaryKey = data.primaryKey ?? data.primary_key ?? [] foreignKeys = normalizeForeignKeys(data.foreignKeys ?? data.foreign_keys) } - const fetched = data.rows ?? [] - const windowTotal = wantsWindow ? Number(data.total ?? 0) : 0 - if (wantsWindow && windowTotal > WINDOW_THRESHOLD) { - // Sparse windowed array: length = total, only window 0 loaded so far. - _windowSeq++ - _windowLoaded = new Set([0]) - _windowFetching = new Set() - windowed = true - const arr = new Array(windowTotal) - for (let i = 0; i < fetched.length; i++) arr[i] = fetched[i] + if (useWindow) { + // Sparse windowed array: one slot per row of this view, holding only the + // probe's rows so far. Window size came from measuring those rows. + beginWindowing(viewBase, viewCount, probeRows, bytesPerRow, windowOrder) + const arr = new Array(viewCount) + // Only rows that came from the view's own ordering may stay - see probeUsable. + if (probeUsable) { + for (let i = 0; i < fetched.length && i < viewCount; i++) arr[i] = fetched[i] + for (const w of windowsFullyCovered(fetched.length, probeRows)) _windowLoaded.add(w) + } rows = arr _infiniteRows = [] total = windowTotal + _totalSig = ownerSig dataVersion++ + // Warm the windows the probe didn't cover, before the first scroll asks. + // Only for a load that starts at the top: on a refresh that keeps the + // scroll position the viewport is somewhere else entirely, and queuing + // the head would put five windows the user cannot see in front of the + // one they are looking at. The grid re-emits its visible range as soon + // as the new array lands, which asks for the right ones. + if (!keepScroll) { + const firstGap = _windowLoaded.size + for (let w = firstGap; w <= firstGap + WINDOW_PREFETCH; w++) _windowQueue.push(w) + pumpWindowQueue() + } } else if (wantsWindow) { - // Below the windowing bar after counting - load the remainder in full. + // Past the limit bar but small and light after measuring - load the rest. resetWindowing() total = windowTotal - // Paint the first window NOW, before going back for the rest. The - // remainder is a second round-trip over tens of thousands of rows, and - // holding `rows` empty until it lands leaves the grid blank for all of - // it — with the columns already drawn, which reads as "this table is - // empty" rather than "this is still loading". Every table between - // WINDOW_FETCH and WINDOW_THRESHOLD rows opened on "All" comes through - // here, so that was the common case, not the rare one. + _totalSig = ownerSig + // Paint the probe NOW, before going back for the rest. The remainder is a + // second round-trip over tens of thousands of rows, and holding `rows` + // empty until it lands leaves the grid blank for all of it - with the + // columns already drawn, which reads as "this table is empty" rather than + // "this is still loading". rows = fetched _infiniteRows = fetched - if (windowTotal > fetched.length) { - const restData = await getTableRows(activeSchema, activeTable, Math.min(windowTotal - fetched.length, MAX_PAGE_SIZE), fetched.length, currentRowQuery(false)) - if (seq !== _loadSeq) return - rows = [...fetched, ...(restData.rows ?? [])] + if (viewCount > fetched.length) { + const restData = await getTableRows(ownerSchema, ownerTable, Math.min(viewCount - fetched.length, MAX_PAGE_SIZE), viewBase + fetched.length, { ...ownerQuery, includeMeta: false, includeCount: false }) + if (!live()) return + const all = [...fetched, ...(restData.rows ?? [])] + // The tab may have gone to the background during this second trip - + // then the rows belong to its own state, not to the visible grid. + if (!isActive()) { + _liveRowsByTab.set(ownerTabId, all) + patchTableTab(ownerTabId ?? '', { columns: nextColumns, rows: all, total: windowTotal, queryMs: ranMs, loadingRows: false, error: '', windowedHead: false, windowedLoaded: [] }) + return + } + rows = all _infiniteRows = rows } } else { @@ -4463,8 +5249,9 @@ let rowSearch = $state('') // total = -1 means "counting" (Postgres, non-blocking). Other engines // return a real total here; refreshRowCount() then no-ops for them. total = Number(data.total ?? 0) + _totalSig = ownerSig } - queryMs = Number(data.queryMs ?? data.query_ms ?? 0) + queryMs = ranMs // Record this page's boundary key values so next/prev can build cursors. if (ksActive && _keyColIndex >= 0) { _pageFirstKey = rows.length ? rows[0]?.[_keyColIndex] : null @@ -4479,43 +5266,59 @@ let rowSearch = $state('') } // Fire-and-forget: fill the total in the background so the count never // delays the rows. Windowed loads already have a real total from the fetch. - if (!windowed) void refreshRowCount(seq) + if (!windowed) void refreshRowCount(ownerTabId, seq, ownerSchema, ownerTable, ownerQuery, ownerSig) + // Mirror the landed result into the owning tab so the spinner in the tab + // strip stops and a later switch away/back doesn't refetch it. + patchTableTab(ownerTabId ?? '', { loadingRows: false, error: '' }) } catch (e) { - if (seq !== _loadSeq) return + if (!live()) return const errStr = String(e) if (isNetworkError(errStr)) { connectionLost = true; void silentReconnect() } - error = errStr - resetWindowing() - columns = [] - primaryKey = [] - foreignKeys = [] - rows = [] - _infiniteRows = [] - total = 0 - recordActivity({ type: 'row_fetch', title: `Failed to load ${activeTable}`, schema: activeSchema, table: activeTable ?? undefined, success: false, error: errStr }) + patchTableTab(ownerTabId ?? '', { loadingRows: false, error: errStr, columns: [], rows: [], total: 0 }) + if (isActive()) { + error = errStr + resetWindowing() + columns = [] + primaryKey = [] + foreignKeys = [] + rows = [] + _infiniteRows = [] + total = 0 + } + recordActivity({ type: 'row_fetch', title: `Failed to load ${ownerTable}`, schema: ownerSchema, table: ownerTable ?? undefined, success: false, error: errStr }) } finally { - if (seq === _loadSeq) loadingRows = false + // The newest load for a tab owns its loading flag - in the tab's own state + // (which is what the tab strip draws) and, while that tab is on screen, in + // the grid too. A superseded load clears neither: the load that replaced it + // will, when it reaches this same block. + if (seq === loadSeqOf(ownerTabId)) { + patchTableTab(ownerTabId ?? '', { loadingRows: false }) + if (isActive()) loadingRows = false + } } } /** * Background row-count pass for the main grid. Runs after loadRows() has * already painted the rows, so COUNT(*) never blocks the initial view. The - * _loadSeq token drops results from a superseded load (fast tab/filter + * per-tab load token drops results from a superseded load (fast tab/filter * switches). Returns -1 on non-Postgres engines / failure - in which case the - * total set by loadRows() is kept untouched. - * @param {number} seq + * total set by loadRows() is kept untouched. Like the load itself, the count + * belongs to the tab that asked for it: it lands in that tab's state and only + * touches the visible total while that tab is still in front. + * @param {string | null} tabId @param {number} seq @param {string} schema + * @param {string} table @param {{ search?: string, searchIsRegex?: boolean, filters?: any[] }} query */ - async function refreshRowCount(seq) { - if (!activeTable) return + async function refreshRowCount(tabId, seq, schema, table, query, sig = '') { + if (!table) return try { - const n = await countTableRows(activeSchema, activeTable, { - ...apiSearch(rowSearch), - filters: filtersForApi(rowFilters, columns), - }) - if (seq !== _loadSeq) return + const n = await countTableRows(schema, table, query) + if (seq !== loadSeqOf(tabId)) return if (typeof n === 'number' && n >= 0) { + patchTableTab(tabId ?? '', { total: n }) + if (tabId !== activeTabId) return total = n + _totalSig = sig const maxPage = Math.max(1, Math.ceil(total / effectivePageSize) || 1) if (page > maxPage) page = maxPage } @@ -4528,7 +5331,8 @@ let rowSearch = $state('') if (total >= 0 && _infiniteRows.length >= total) return if (_infiniteRows.length >= INFINITE_ROW_CAP) return loadingMore = true - const seq = _loadSeq + const ownerTabId = activeTabId + const seq = loadSeqOf(ownerTabId) try { const offset = _infiniteRows.length const { sortColumn, sortDirection, sorts } = sortForApi(rowSort, rowSortMore) @@ -4539,7 +5343,9 @@ let rowSearch = $state('') sorts, filters: filtersForApi(rowFilters, columns), }) - if (seq !== _loadSeq) return + // A superseded load, or a tab switch, means these rows no longer belong to + // what's on screen - appending them would splice one table into another. + if (seq !== loadSeqOf(ownerTabId) || ownerTabId !== activeTabId) return const fetched = data.rows ?? [] if (!fetched.length) return // Append in place - spreading the whole accumulated array on every page was @@ -4549,6 +5355,7 @@ let rowSearch = $state('') for (let i = 0; i < fetched.length; i++) _infiniteRows.push(fetched[i]) rows = _infiniteRows.slice() total = Number(data.total ?? total) + _totalSig = rowPredicateSig } catch (e) { error = String(e) } finally { @@ -4665,7 +5472,14 @@ let rowSearch = $state('') * ⌘R), only that statement runs; otherwise the whole editor buffer runs. * @param {string} [overrideSql] */ - async function runSql(overrideSql) { + function runSql(overrideSql) { + // The owning tab spins until the run settles, wherever the user has navigated + // to in the meantime. + return trackBusy(activeTabId, runSqlOnTab(overrideSql)) + } + + /** @param {string} [overrideSql] */ + async function runSqlOnTab(overrideSql) { track('sql_run') const sqlRan = typeof overrideSql === 'string' && overrideSql.trim() ? overrideSql : sqlText if (!connection || !sqlRan.trim()) return @@ -4678,6 +5492,10 @@ let rowSearch = $state('') // nor drops the answer into whatever tab is in front when it arrives. const runTabId = activeTabId const stillHere = () => activeTabId === runTabId + // Cancel handle for this run, keyed to its tab: several tabs can be running + // at once, and Stop has to reach the query belonging to the tab you're on. + const queryId = `sql-${runTabId ?? 'none'}-${++_sqlRunSeq}` + if (runTabId) _sqlQueryIdByTab.set(runTabId, queryId) sqlLoading = true sqlError = '' sqlMessage = '' @@ -4691,7 +5509,7 @@ let rowSearch = $state('') let ranError = '' let ranRowCount = 0 try { - const results = await executeSqlMulti(sqlRan) + const results = await executeSqlMulti(sqlRan, queryId) const data = results.length > 0 ? results[results.length - 1] : {} const cols = data.columns ?? [] const rws = data.rows ?? [] @@ -4718,6 +5536,7 @@ let rowSearch = $state('') } if (isNetworkError(ranError)) { connectionLost = true; void silentReconnect() } } finally { + if (runTabId && _sqlQueryIdByTab.get(runTabId) === queryId) _sqlQueryIdByTab.delete(runTabId) patchSqlTab(runTabId, { sqlLoading: false }) if (stillHere()) sqlLoading = false recordActivity({ type: 'sql_exec', title: sqlRan.trim().slice(0, 80) + (sqlRan.trim().length > 80 ? '…' : ''), detail: sqlRan, durationMs: ranMs, rowCount: ranRowCount || undefined, success: !ranError, error: ranError || undefined }) @@ -4726,6 +5545,12 @@ let rowSearch = $state('') success: true, queryMs: ranMs, }) + // Settings → Database → Auto-save executed queries. Only successful runs, + // and deduplicated by SQL, so re-running the statement you're iterating on + // doesn't push out the ones you saved deliberately. + if (get(appAutoSaveQueries)) { + await saveQueryOnce(persistConnectionId, sqlRan).catch(() => {}) + } await refreshQueryStores() } } @@ -4755,6 +5580,7 @@ let rowSearch = $state('') // going through clearConnectionState, so reset them in both places. tableColumnsCache = new Map() incomingFkCache = new Map() + _catalog.clear() // The connection is live: render the shell NOW (setting `connection` above // dropped the reconnect overlay) with the welcome tab open and the sidebar // in its skeleton state, and let the catalog stream in below. This is what @@ -4763,6 +5589,13 @@ let rowSearch = $state('') tabs = [] _liveRowsByTab.clear() _tabRowsMru = [] + _loadSeqByTab.clear() + fetchingTabIds.clear() + _sqlQueryIdByTab.clear() + _autoRefreshByTab.clear() + _autoRefreshTick += 1 + _busyJobs.clear() + _busyTick += 1 resetNav(_nav) syncNavFlags() // Redis has no relational catalog: skip schema/table loading entirely and @@ -4921,8 +5754,8 @@ let rowSearch = $state('') // fails with a message the user can act on. This race is only a last-resort guard // for a command that never returns AT ALL, so it has to sit above those deadlines. // At 5s it fired first and turned every slow-but-healthy wake-up into "not - // connected" — serverless Postgres (Neon, and Prisma/Supabase pooler cold starts) - // autosuspends and takes 3–15s to come back, which is why resuming one meant + // connected" - serverless Postgres (Neon, and Prisma/Supabase pooler cold starts) + // autosuspends and takes 3-15s to come back, which is why resuming one meant // reconnecting by hand on nearly every launch. /** @param {Promise} p */ const withTimeout = (p) => Promise.race([ @@ -5061,6 +5894,118 @@ let rowSearch = $state('') switchToDb(entry.label) } + // ── Manage databases (sidebar) ──────────────────────────────────────────── + // Create, rename, duplicate, drop, and the read-only info panel. Every + // statement runs on the current pool, which is why none of them can target the + // database this session is attached to - `dbActionBlocker` in database-admin.js + // is what greys those items out, and the handlers below only ever see targets + // that passed it. + let showCreateDbDialog = $state(false) + /** Bumped after any change, so the sidebar refetches its list. */ + let databasesRefreshKey = $state(0) + /** @type {{ mode: 'rename' | 'duplicate', source: string, existing: string[] } | null} */ + let dbNameDialog = $state(null) + let dbNameDialogOpen = $state(false) + let dropDbName = $state('') + let dropDbSessions = $state('') + let showDropDbDialog = $state(false) + let dbInfoName = $state('') + let showDbInfoDialog = $state(false) + let dbInfoLoading = $state(false) + let dbInfoError = $state('') + /** @type {import('$lib/database-admin.js').DbInfoRow[]} */ + let dbInfoRows = $state([]) + + const dbAdmin = $derived(dbAdminKind(connection)) + + /** @param {import('./CreateDatabaseDialog.svelte').CreateDbOptions} opts */ + async function createDatabase(opts) { + const kind = dbAdmin ?? 'postgres' + await executeDdl(createDatabaseSql(kind, opts)) + toast.success(`Database "${opts.name}" created`) + databasesRefreshKey++ + } + + /** @param {{ mode: 'rename' | 'duplicate', name: string, existing: string[] }} args */ + function openDbNameDialog({ mode, name, existing }) { + dbNameDialog = { mode, source: name, existing } + dbNameDialogOpen = true + } + + /** Shared by rename and duplicate - the dialog has already built the SQL. + * Throwing keeps the dialog open with the server's message. + * @param {{ sql: string, name: string }} args */ + async function runDbNameStatement({ sql, name }) { + const mode = dbNameDialog?.mode + const source = dbNameDialog?.source ?? '' + await executeDdl(sql) + toast.success(mode === 'rename' ? `Renamed "${source}" to "${name}"` : `Copied "${source}" to "${name}"`) + databasesRefreshKey++ + } + + /** Look up how many sessions are on a database, for the drop dialog's warning. + * Best effort: a failed count must not block the dialog. @param {string} name */ + async function countDbSessions(name) { + if (dbAdmin !== 'postgres') return '' + try { + const r = await executeSql(`SELECT count(*) FROM pg_stat_activity WHERE datname = '${name.replace(/'/g, "''")}'`) + const n = Number(r?.rows?.[0]?.[0] ?? 0) + return n > 0 ? String(n) : '' + } catch { + return '' + } + } + + /** @param {{ name: string }} args */ + async function requestDropDatabase({ name }) { + dropDbName = name + dropDbSessions = '' + showDropDbDialog = true + dropDbSessions = await countDbSessions(name) + } + + /** @param {{ sql: string, force: boolean }} args */ + async function commitDropDatabase({ sql }) { + const name = dropDbName + try { + await executeDdl(sql) + toast.success(`Database "${name}" dropped`) + databasesRefreshKey++ + } catch (e) { + toast.error(`Could not drop "${name}"`, { description: String(e) }) + } + } + + /** @param {{ name: string }} args */ + async function terminateDbSessions({ name }) { + if (!dbAdmin) return + try { + const r = await executeSql(terminateSessionsSql(dbAdmin, name)) + const closed = r?.rows?.length ?? 0 + toast.success(closed === 0 ? `No other sessions on "${name}"` : `Closed ${closed} session(s) on "${name}"`) + } catch (e) { + toast.error(`Could not close sessions on "${name}"`, { description: String(e) }) + } + } + + /** @param {{ name: string }} args */ + async function openDatabaseInfo({ name }) { + if (!dbAdmin) return + dbInfoName = name + dbInfoRows = [] + dbInfoError = '' + dbInfoLoading = true + showDbInfoDialog = true + try { + const result = await executeSql(databaseInfoSql(dbAdmin, name)) + dbInfoRows = databaseInfoRows(dbAdmin, result) + } catch (e) { + dbInfoError = String(e) + } finally { + dbInfoLoading = false + } + } + /** Reset all connection-scoped UI state to blank. */ function clearConnectionState() { schemas = [] @@ -5071,6 +6016,7 @@ let rowSearch = $state('') sequences = [] tableColumnsCache = new Map() incomingFkCache = new Map() + _catalog.clear() _sqlHintsLoadedFor = '' activeSchema = 'public' activeTable = null @@ -5157,7 +6103,7 @@ let rowSearch = $state('') */ async function connectByType(conn) { // Which engines get used, never which servers. The event name is the whole - // payload — there is no field here that could carry a host or a database. + // payload - there is no field here that could carry a host or a database. track(`connect_${conn.type === 'cockroachdb' ? 'cockroachdb' : conn.type}`) if (conn.type === 'sqlite') await connectSqlite(conn) else if (conn.type === 'd1') await connectD1(conn) @@ -5172,6 +6118,9 @@ let rowSearch = $state('') /** @param {import('$lib/stores/connections.js').SavedConnection} conn */ async function handleSwitchDatabase(conn) { + // A deliberate switch is the other half of "PIN to open or reconnect". + // Resolves true immediately unless a PIN is set with the connect prompt on. + if (!(await requireUnlock('Unlock to connect to a database'))) return // Disconnect current before connecting to the new one to avoid the race // where set_conn(None) could fire after connect_* sets the new connection. await disconnectPostgres().catch(() => {}) @@ -5234,10 +6183,23 @@ let rowSearch = $state('') // A manual refresh should also recover a dropped connection. if (connectionLost) await reconnectPool() _sqlHintsLoadedFor = '' // re-fetch enum/function hints on next SQL view + // Where the user was reading, captured before anything can move it. A + // refresh re-runs the SAME query in the same order on the same page, so the + // position is still meaningful - unlike a page / sort / filter change, which + // is a different view and rightly starts at the top. + const onTable = activeTab?.kind === 'table' && !!activeTable + const at = onTable ? (tableGetScroll?.() ?? { left: 0, top: 0 }) : null await loadSchemas() await loadTables({ force: true }) - if (activeTab?.kind === 'table' && activeTable) { - await loadRows() + if (onTable) { + await loadRows({ keepScroll: true }) + // Reasserted rather than merely left alone: reloading the schema and table + // lists on the way through re-applies the active tab's snapshot, and that + // restore lands whenever its tick fires - after this point, with whatever + // scroll the snapshot happened to hold. Setting it last is what makes the + // position survive the whole refresh, both axes. + await tick() + tableApplyScroll?.({ left: at?.left ?? 0, top: at?.top ?? 0 }) } } @@ -5570,6 +6532,35 @@ let rowSearch = $state('') currentName={connection?.database ?? connection?.name ?? ''} onconfirm={commitDatabaseSwitch} /> + +{#if dbNameDialog} + +{/if} + void commitDropDatabase(args)} +/> + { if (aiMode) exitAiMode(); openSecurityTab() }} onopenlogs={() => { if (aiMode) exitAiMode(); openLogsTab() }} onopeninsights={() => { if (aiMode) exitAiMode(); openInsightsTab() }} + onopenadvisor={() => { if (aiMode) exitAiMode(); openAdvisorTab() }} onopenobjects={() => { if (aiMode) exitAiMode(); openObjectsTab() }} onopenormschema={() => { if (aiMode) exitAiMode(); openOrmSchemaTab() }} ontogglequerylog={() => { commandOpen = false; queryLogOpen = !queryLogOpen }} @@ -5730,7 +6722,7 @@ let rowSearch = $state('') /> - + {#if autoConnecting && !connection} @@ -5839,6 +6831,13 @@ let rowSearch = $state('') onrefresh={handleRefresh} {connection} onswitchdatabase={requestDatabaseSwitch} + onnewdatabase={() => (showCreateDbDialog = true)} + onrenamedatabase={({ name, existing }) => openDbNameDialog({ mode: 'rename', name, existing })} + onduplicatedatabase={({ name, existing }) => openDbNameDialog({ mode: 'duplicate', name, existing })} + ondropdatabase={(args) => void requestDropDatabase(args)} + ondatabaseinfo={(args) => void openDatabaseInfo(args)} + onterminatedbsessions={(args) => void terminateDbSessions(args)} + {databasesRefreshKey} onnewtable={() => (showCreateTableDialog = true)} onnewschema={() => (showCreateSchemaDialog = true)} ontruncatetable={handleTruncateTable} @@ -6124,6 +7123,21 @@ let rowSearch = $state('')
    {/if} + + {#if advisorEverOpened} +
    + + {#await import('./AdvisorPage.svelte')}{:then { default: AdvisorPage }} + + {/await} + +
    + {/if} + {#if objectsEverOpened}
    -

    @@ -6565,7 +7580,7 @@ let rowSearch = $state('') {enums} columnSearch={structureSearch} loading={loadingStructure} - onrefresh={() => { void loadStructure(); void loadTriggers() }} + onrefresh={() => { void loadStructure(); void reloadCatalogKind('triggers') }} /> {/await} {:else} @@ -6594,6 +7609,8 @@ let rowSearch = $state('') hasPrimaryKey={primaryKey.length > 0} deleting={deletingRows} onrefresh={loadRows} + autoRefreshMs={activeAutoRefreshMs} + onautorefreshchange={setActiveAutoRefresh} onsearchchange={handleRowSearchChange} onfilterschange={(f) => void handleRowFiltersChange(f)} onsortchange={(s) => void handleRowSortChange(s)} @@ -6677,7 +7694,9 @@ let rowSearch = $state('') {reloadToken} {dataVersion} {windowed} + {windowStatus} onvisiblerange={handleVisibleRange} + onretrywindows={retryFailedWindows} columnWidthsKey={activeTable ? `${persistConnectionId}\x00${activeSchema}.${activeTable}` : undefined} loading={loadingRows} {loadingMore} @@ -6827,12 +7846,15 @@ let rowSearch = $state('') shared baseline whether or not a tile carries a chord or wraps to two lines. Colors use solid tokens (not fractional alpha) - thinned strokes read as fuzzy against the dark panel. --> - {@const cell = 'group relative flex h-[5.25rem] flex-col justify-between rounded-lg border border-border/60 bg-card/50 p-2.5 text-left transition-[background-color,border-color,box-shadow,transform] duration-150 ease-[var(--ease-out)] hover:border-border hover:bg-accent/40 hover:shadow-sm active:scale-[0.98]'} - {@const proCell = 'group relative flex h-[5.25rem] cursor-not-allowed flex-col justify-between rounded-lg border border-border/40 bg-card/30 p-2.5 text-left transition-[background-color,border-color] duration-150 hover:border-warning/30 hover:bg-warning/[0.04]'} + + {@const cell = 'group relative flex min-h-[5.25rem] min-w-0 flex-col justify-between overflow-hidden rounded-lg border border-border/60 bg-card/50 p-2.5 text-left transition-[background-color,border-color,box-shadow,transform] duration-150 ease-[var(--ease-out)] hover:border-border hover:bg-accent/40 hover:shadow-sm active:scale-[0.98]'} + {@const proCell = 'group relative flex min-h-[5.25rem] min-w-0 cursor-not-allowed flex-col justify-between overflow-hidden rounded-lg border border-border/40 bg-card/30 p-2.5 text-left transition-[background-color,border-color] duration-150 hover:border-warning/30 hover:bg-warning/[0.04]'} {@const iconCls = 'size-4 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground'} {@const proIconCls = 'size-4 shrink-0 text-muted-foreground/50'} - {@const labelCls = 'text-ui-2xs font-medium leading-[1.25] text-foreground/85 transition-colors group-hover:text-foreground'} - {@const proLabelCls = 'text-ui-2xs font-medium leading-[1.25] text-muted-foreground/60'} + {@const labelCls = 'text-ui-2xs font-medium leading-[1.25] text-foreground/85 transition-colors group-hover:text-foreground [overflow-wrap:anywhere]'} + {@const proLabelCls = 'text-ui-2xs font-medium leading-[1.25] text-muted-foreground/60 [overflow-wrap:anywhere]'} - + {label} - + + {#if opts.keys && !locked}{@render chord(opts.keys)}{/if} @@ -6896,8 +7921,13 @@ let rowSearch = $state('') {/if}

    - -
    + +
    {#if isRedis} {@render tile(KeyRound, 'Keyspace', openRedisTab, {})} @@ -6924,8 +7954,10 @@ let rowSearch = $state('') {#if !isRedis} {@render tile(Database, 'Insights', openInsightsTab, {})} + {@render tile(Boxes, 'Objects', openObjectsTab, {})} - {@render tile(FileCode2, 'Codegen', openOrmSchemaTab, { pro: true, hint: 'Codegen — schema as Prisma or Drizzle code' })} + {@render tile(FileCode2, 'Codegen', openOrmSchemaTab, { pro: true, hint: 'Codegen - schema as Prisma or Drizzle code' })} {@render tile(BarChart2, 'Charts', openChartsTab, { pro: true })} {@render tile(GitBranch, 'Diagrams', openDiagramsTab, { pro: true })} {@render tile(History, 'Timeline', openSchemaTimelineTab, { pro: true })} @@ -7066,25 +8098,7 @@ let rowSearch = $state('') onopenpages={() => { commandPage = 'pages'; commandOpen = true }} bind:readonly={tableReadonly} ondisconnect={requestDisconnect} - oncreatedatabase={async ({ name, owner, encoding, lcCollate, lcCtype, template, connectionLimit }) => { - const escaped = name.replace(/"/g, '""') - let sql - if (dbType === 'mysql') { - sql = `CREATE DATABASE \`${name.replace(/`/g, '``')}\`` - if (encoding) sql += ` CHARACTER SET ${encoding}` - if (lcCollate) sql += ` COLLATE ${lcCollate}` - } else { - sql = `CREATE DATABASE "${escaped}"` - if (encoding) sql += `\n ENCODING '${encoding}'` - if (template) sql += `\n TEMPLATE ${template}` - if (lcCollate) sql += `\n LC_COLLATE '${lcCollate}'` - if (lcCtype) sql += `\n LC_CTYPE '${lcCtype}'` - if (owner) sql += `\n OWNER "${owner.replace(/"/g, '""')}"` - if (connectionLimit != null && connectionLimit !== -1) sql += `\n CONNECTION LIMIT ${connectionLimit}` - } - await executeDdl(sql) - toast.success(`Database "${name}" created`) - }} + oncreatedatabase={createDatabase} /> {/if} diff --git a/src/lib/components/TableToolbar.svelte b/src/lib/components/TableToolbar.svelte index 70cd311c..b482ab19 100644 --- a/src/lib/components/TableToolbar.svelte +++ b/src/lib/components/TableToolbar.svelte @@ -49,6 +49,9 @@ rowSort = null, rowFilters = [], onrefresh = () => {}, + /** Auto-refresh interval for THIS tab, in ms (0 = off). */ + autoRefreshMs = 0, + onautorefreshchange = /** @type {(ms: number) => void} */ (() => {}), onprev = () => {}, onnext = () => {}, /** Keyset/cursor/temporal pagination: next/prev only, no page-jump. */ @@ -289,10 +292,7 @@ /** Open the filter bar, seeding an empty filter row (hotkey from the parent). */ export function openFilterMenu() { if (!columns.length) return; - if (!filterBarOpen) { - filterBarOpen = true; - if (rowFilters.length === 0) addFilter(); - } + if (!filterBarOpen) openFilterBar(); } /** Clear the row search and focus the input (Ctrl+T shortcut). */ @@ -301,20 +301,31 @@ searchInputRef?.focus() } - /** Focus the value input of the last filter row (called after "Filter by this column"). */ + /** + * Focus the value field of the last filter row. Scoped to that row rather than + * to the whole bar: a row whose value is a date picker or a true/false pair has + * no text input, and a flat query would then focus some earlier row's field. + */ export function focusLastFilter() { // Defer so the filter bar DOM has rendered setTimeout(() => { - const inputs = document.querySelectorAll( - '.studio-filter-bar input[data-filter-value], .studio-filter-bar input:not([type="date"])', - ); - const last = /** @type {HTMLInputElement|null} */ ( - inputs[inputs.length - 1] + const rows = document.querySelectorAll('.studio-filter-bar [data-filter-row]'); + const row = rows[rows.length - 1]; + const el = /** @type {HTMLInputElement | HTMLButtonElement | null} */ ( + row?.querySelector('input[data-filter-value], [data-filter-value]') ?? null ); - last?.focus(); + el?.focus(); + if (el instanceof HTMLInputElement) el.select(); }, 30); } + /** Open the filter bar (seeding a row if empty) and land the caret in its value. */ + function openFilterBar() { + filterBarOpen = true; + if (rowFilters.length === 0) addFilter(); + focusLastFilter(); + } + /** Page numbers shown in the page dropdown (windowed when many pages). */ const pageMenuItems = $derived.by(() => { const n = pageCount; @@ -327,6 +338,37 @@ const iconBtn = "inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-30"; + // ── Filter row chrome ────────────────────────────────────────────────────── + // One border, one surface, one hover for every control in a filter row. Column, + // condition and value each had their own pairing before, so a clause that reads + // as one sentence was drawn as three unrelated boxes. `dark:` is repeated on the + // surface because the Input primitive sets its own dark background. + const fCtl = + "h-7 rounded-md border border-border/60 bg-background dark:bg-background text-ui-sm font-normal text-foreground shadow-none outline-none transition-[color,background-color,border-color,box-shadow]"; + const fTrigger = cn( + fCtl, + "inline-flex items-center gap-1 px-2.5 hover:border-border hover:bg-muted/40 focus-visible:border-ring/60 focus-visible:ring-2 focus-visible:ring-ring/15 data-[state=open]:border-ring/60 data-[state=open]:bg-muted/40", + ); + // Capped, not free-running: a value field stretched across the whole toolbar is + // an empty box the width of the window, and it dwarfs the two controls that say + // what it means. + const fValue = cn(fCtl, "min-w-[9rem] max-w-[22rem] flex-1 px-2.5"); + + /** Auto-refresh intervals. Nothing shorter than 5s: a refresh re-runs the page + * query and its count, and a faster cadence would spend more time fetching than + * showing. */ + const AUTO_REFRESH_OPTIONS = [ + { ms: 0, label: "Off" }, + { ms: 5_000, label: "5 seconds" }, + { ms: 10_000, label: "10 seconds" }, + { ms: 30_000, label: "30 seconds" }, + { ms: 60_000, label: "1 minute" }, + { ms: 300_000, label: "5 minutes" }, + ]; + const autoRefreshLabel = $derived( + AUTO_REFRESH_OPTIONS.find((o) => o.ms === autoRefreshMs)?.label ?? "off", + ); + /** @type {Array<{ id: 'table' | 'json' | 'record' | 'text' | 'chart' | 'erd' | 'map', icon: string, label: string, title?: string }>} */ const DATA_VIEW_MODES = [ { id: "table", icon: "table-2", label: "Table view" }, @@ -547,6 +589,12 @@ onfilterschange([...rowFilters, createFilter(col, op)]); } + /** "+ Add filter": a new row is only useful with the caret already in it. */ + function addFilterAndFocus() { + addFilter(); + focusLastFilter(); + } + /** @param {string} id */ function removeFilter(id) { onfilterschange(rowFilters.filter((f) => f.id !== id)); @@ -610,7 +658,7 @@ diff --git a/src/lib/components/VirtualColumnsPanel.svelte b/src/lib/components/VirtualColumnsPanel.svelte index 8440dfef..52dce7ef 100644 --- a/src/lib/components/VirtualColumnsPanel.svelte +++ b/src/lib/components/VirtualColumnsPanel.svelte @@ -306,7 +306,7 @@

    No virtual columns yet

    - Build a column from the ones you already have — join a name, format a date, do the arithmetic once. + Build a column from the ones you already have - join a name, format a date, do the arithmetic once.

    @@ -367,7 +367,7 @@
    {#if vcols.length > 0} -