perf: cut startup 61%, and a round of platform fixes - #80
Merged
Conversation
The saved rail was a plain scroll: past a dozen connections, finding one meant reading every row. A filter appears once the list is long enough to need one (>5) and matches name, engine, database, host, file path and libSQL URL, so any of the things shown on a row will find it. Escape clears the filter before it closes the dialog. Cmd/Ctrl+R was already bound but never fired. It listened on the bubble via svelte:window, and the dialog is full of inputs and a bits-ui overlay that stop propagation before it gets there. Moved to the capture phase, which also guarantees the preventDefault lands before the webview treats Cmd+R as 'reload the app'.
It sat under Agent, between two AI switches, which read as if it were about the AI features. It covers the whole app, so it belongs in General under its own Privacy heading.
Disconnect was on Cmd+Alt+D — a three-finger stretch for something reached often, while Cmd+Shift+D went to the data view. The two swap: disconnect takes the easier chord, the data view takes Alt. Both are rebound in the SQL and ORM editors too, so the key means the same thing wherever focus is, and the shortcuts reference is updated to match. Enter now presses the confirm button, which needs nothing to be focused when the dialog opens — otherwise focus lands on Cancel and Enter would click it. Both are behind a confirmOnEnter prop, off by default and set only on the disconnect prompt: reconnecting is one click, whereas arming Enter on the shared prompt would put DROP TABLE one stray Return away. The confirm button shows a ↵ so the shortcut is discoverable.
🦋 Changeset detectedLatest commit: 30feeb5 The changes in this PR will be included in the next version bump. Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Both inherited the app's 14/13px UI scale, which is the right size for dense chrome and the wrong one for prose and code you sit and read. Existing installs have the old value written into localStorage — not because anyone chose it, but because saving any unrelated setting persists the whole settings object. A stored size that still equals the old default is treated as unset and moves up with it; anything else is a real choice and is left alone.
…ider Cloudflare and the OAuth providers each drew their own idle CTA, waiting card, loading row and error card — eight hand-rolled layouts, no two the same height, so every step of a sign-in resized the pane under the cursor. They now share ProviderAuthPanel: one frame, four tones. The mark, the two lines of copy and the footer hold their positions from the first frame to the last; only the contents change, and the progress track is reserved whether or not it is running so it cannot nudge the footer down. The idle step was a full-bleed black slab with an arrow — a landing-page CTA, not a control in a desktop app. It is now the shared <Button>, sized like every other button in Stroke and sitting in the card footer where a dialog's action belongs. Both flows also stop inheriting the manual form's 880px measure: a sign-in is one column of short lines, and stretched that wide it read as a banner.
A bordered box hovering above the form it belongs to reads as an advert rather than as the first step of that form, and capping it at 520px made it worse: the Advanced grid inside is six columns, so the read-only label got a 173px cell and wrapped onto three lines. Now it is a plain section — icon, copy, one action, a rule — full width and flush with the fields under it. The rule doubles as the progress track, so a long wait shows life without a bar appearing from nowhere and pushing the rest of the pane down, and the copy is capped at a readable measure instead of running the full width of the pane.
A GUI process on Windows has no console, so the OS allocates one for any console program it starts and shows it as a cmd.exe window in front of the app. Every process this app runs is a background detail nobody asked to watch, so they all go through here. CREATE_NO_WINDOW rather than SW_HIDE via STARTUPINFO: the latter still allocates the console and only starts it hidden, leaving a taskbar entry and a window anything can show again.
scan_docker_databases runs on the connection screen, so the flash landed at app start. All five docker invocations now go through one helper.
netstat is spawned for every candidate process while the connection screen is deciding what is running on this machine.
The registry read runs at startup for the licence check, so this one appeared before the window did.
The tunnel is a background process with all three streams already closed — there is nothing for a console to show and no way to type into it.
`npm install -g` writes to a prefix most people cannot write to: a default
nodejs.org install owns /usr/local and a distribution package owns
/usr/lib. So the install failed with EACCES on macOS and Linux for exactly
the users who followed the app's own advice to get Node from nodejs.org,
and all it could do was tell them to go run npm themselves.
Stroke now keeps its own copy under its data directory. That needs no
elevation anywhere, leaves the global npm prefix alone, and — because the
entry script then sits at a known absolute path — lets the gateway start
as `node <entry>`, with no PATH search and no shell in between. On Windows
that means there is no console to suppress rather than one being hidden,
and killing the child actually kills the server: under `cmd /C` the node
grandchild outlived the shell that was killed.
Also here:
• the version check is a package.json read, not a spawned process
• --loglevel http, because the install pulls ~1200 packages over several
minutes and npm's default output is nothing at all until it finishes,
which left the panel reading as hung
• a 10s timeout on the environment probe, which had none, and which both
Install and Start begin by calling
A pre-existing global install still works — every lookup falls back to it.
The label said "npm i -g omniroute", which is no longer what the button does and was the thing that needed a password.
pg_stat_activity and pg_prepared_xacts had no LIMIT while the lock query beside them had one, so a busy server could hand back an unbounded result. The session query also calls pg_blocking_pids once per row, which is documented as too expensive for frequent monitoring. Same for MySQL's PROCESSLIST and ClickHouse's system.processes.
Three things, all the same shape — work done per render that the render never needed. $state.raw for the server payloads. The settings list, session list and replication stats are replaced whole by their refresh functions and only ever read, so wrapping every row and every field in a reactive proxy cost more to build than the render it fed. Config alone is 350+ rows on Postgres and 600+ on MySQL. The column list is computed once per grid, not once per row. A 200-session table allocated 200 throwaway Object.keys() arrays on every render, and read each cell twice over — once for the title attribute, once for the text. The config list builds a screenful at a time. Every row is a ~20-node subtree and they were all constructed the instant the tab opened, for a list where about fifteen fit on screen. content-visibility already skipped their layout and paint; this skips their construction. Nothing ends up hidden: the budget refills during idle until the whole list is in the DOM, so ctrl-F and assistive tech still see every row a moment later, and a scroll sentinel covers anyone faster than that.
notMerge tears a chart down and builds every series again. That is the right default — it is the only update that can express a property going away — and pure waste for a panel on a refresh timer whose option keeps the same shape and only moves its numbers, which is the insights page every five seconds. Opt-in, deliberately. The generic builders in chart-utils emit keys conditionally: a scatter series picks up `large: true` past 2000 points, and merging a later small result over it would leave the chart stuck in large mode. Those callers keep the rebuild.
CommandPalette is in the boot chunk, it imports AiMarkdown, and AiMarkdown imported this module, which imported Shiki — so the highlighter and the grammar graph behind it were compiled at app start for everyone, including the majority who never render a fenced code block. The one exported function is already async, so the dynamic import costs nothing at the call site, and a message with no <pre> never touches the chunk at all.
Onboarding mounts this component, so the whole library sat in the boot chunk for a one-off burst most sessions never fire. A failed import now skips the celebration rather than failing the activation.
This component imported TableJsonView and TableTextView, both of which reach monaco-editor, so a background pane was enough to pull ~3.7 MB of editor into the boot chunk. Both call sites were already behind dataViewMode guards; they now load at those guards.
Every shortcut is a single combo string in the grammar createHotkey already parses, and both platforms come out of that one string: Mod prints as ⌘ on macOS and Ctrl everywhere else, Alt as ⌥ or Alt. The two spellings used to be written out separately in the help dialog, which is exactly what let them drift from what the app actually binds. Adding a shortcut now gets both platforms for free. Keys that print as a symbol are named rather than typed as the symbol, so a combo can always be split on '+' without Mod++ becoming nonsense.
The dialog held its own hand-written list of per-platform keycap arrays — a second source of truth that could, and did, describe bindings the app no longer had. It now renders from $lib/shortcuts.js, and takes its platform flag from there too, so it and the bindings agree on what machine they are running on instead of sniffing navigator.platform separately.
The binding went through the hotkey layer, which listens on the document
in the bubble phase — so anything between the focused element and the
document that calls stopPropagation swallowed it first. That is every
Monaco editor, the grid canvas, and the bits-ui overlays: precisely the
places you most want to hide the sidebar from. The manual `onmodb`
callbacks threaded into SqlEditor and OrmRunner were a workaround for the
same thing, forwarding the key back out by hand.
Now bound in the capture phase on window, ahead of all of them, so one
global binding reaches every context.
The modifier is matched per platform rather than accepting either: Ctrl+B
is the emacs "move backward" binding macOS text fields still honour, and
swallowing it would break caret movement in every input.
Also in this commit, since they touch the same file:
• Monaco is off the boot path. TableJsonView and TableTextView reach
monaco-editor statically, so ~3.7 MB of editor plus 143 KB of its CSS
was compiled before first paint whether or not a SQL tab was ever
opened. Both are behind dataViewMode guards and now load there.
• Eight keep-alive pages are off the boot path too — search, schema,
schema timeline, backup, logs, insights, objects, redis — along with
the structure view. All were behind `{#if …EverOpened}` guards while
still being statically imported, which only ever meant shipping their
code to every user at boot. Each is warmed during idle, so opening one
is no slower than before.
• `tables` is $state.raw. A large schema is thousands of table objects,
deep-proxied field by field, which the sidebar and command palette
then run several filter and map passes over. Nothing mutates a table
in place — even the row-count backfill rebuilds the list with .map.
Startup payload: 6.80 MB → 2.62 MB.
The editor took an onmodb callback purely to hand the chord back out to the shell, because the global binding could not reach inside Monaco. The capture-phase binding does, so this is now dead weight — and a second handler for the same key would have toggled the sidebar back.
Same as the SQL editor: the shell's capture-phase binding reaches Monaco now, so forwarding the chord by hand would double-toggle.
The prop existed only to reach SqlEditor, which no longer takes it.
"Local file-based database" under a heading that says SQLite is describing a decision you have already made. The description still earns its place in the picker tooltip and the search index, so filtering by "file-based" still finds it.
The saved-connections filter only appeared past five connections, so it materialised out of nowhere as the list grew and the people most likely to reach for it had never seen it. It shows from two now — the point at which there is something to tell apart — matching the engine panel opposite, which always shows its search. Rebuilt on the shared Input so the focus and border states come from the same primitive as the engine search instead of being approximated a second time, at h-8 for the rail's scale. The field was 28px and the clear button about 16px, both under the 32px a target needs; the button now expands to 32px through a pseudo-element while staying visually small, and refocuses the field instead of dropping focus. Enter takes the top match, the same way clicking it would. Down arrow hands off into the list, arrows walk it, up off the top row returns to the filter. The empty state gained a way out — the filter that emptied the list was the only thing standing between there and the list. And the entrance stagger stops once it would be replaying. Rows carried up to 480ms of accumulated delay, which is a first impression and reads as lag when it fires on every keystroke. Both panels were affected: the local targets in the engine picker are filtered by its search too. The stagger now retires on the first keystroke in each panel and re-arms on open.
Folded into CHANGELOG.md under the new version when the PR merges with a release label.
Four things, all in the inline Ask AI page. Selection. The palette is portaled out of #app, so the app-wide grant of `user-select: text` for dialog content — which is scoped to `#app` — never reached it, and it inherited nothing. An answer you cannot select is an answer you cannot quote, which is most of what a quick ask is for. Layout shift. The list had a max-height and no height, so the box grew as the answer streamed and as tool rows appeared, moving everything already on screen while a follow-up was being typed into it. A transcript wants a viewport that scrolls, not one that resizes. Following the stream. The scroll was driven by an effect on askTurns, but AiMarkdown renders on a 120ms debounce — so every measurement was taken before the DOM it was measuring existed. It scrolled to a bottom that was not there yet, fell a little further behind on each token, and once the gap passed its 120px "near the bottom" gate it stopped following for the rest of the answer, which is exactly when there was most left to read. A ResizeObserver on the transcript scrolls after the content it is scrolling to, and a scroll listener decides whether to keep following, so scrolling up to read something no longer fights the stream. Lag. That effect also re-ran the scroller lookup on every token — walking ancestors and calling getComputedStyle on each one, which forces a style recalculation, once per token for the length of the answer. It now runs once when the page opens.
A NOT NULL column now carries a red asterisk ahead of its name — the same mark a required form field carries, so it reads without a legend. The constraint was only discoverable by trying: clearing a cell and being told "Cannot set NULL", or opening the column menu. It belongs where you decide what to type. Driven by the same colMeta.nullable the editor already rejects NULL writes on, so the header cannot disagree with what the grid will accept. The marker's width comes out of the name's budget rather than being painted over it, so a long name still truncates against the right edge, and the type label and sort glyph keep their positions.
Fixed in the shared primitive rather than at the enum cell editor, so every select in the app gets it. Concentric radius. The panel is rounded-xl (12px) and its viewport pads by 4px, so items are rounded-lg (8px) — outer minus padding. Item corners now sit inside the panel's curve instead of leaving a crescent of gap at each one. Row height. py-1 alone left a ~22px row: small enough to mis-click, and cramped enough to read as an afterthought. min-h-7 is the design system's compact control height, so the menu matches every other control rather than inventing a size. The highlight moves instead of snapping. data-highlighted:bg-accent had no transition, so running the pointer down a list strobed. transition-colors at 120ms — the property list, never `all`. The selected row carries weight. It was distinguished only by a tick 32px away at the far right edge, which is not where the eye is when reading a label. data-selected now takes full foreground and medium weight, so the current value is legible before you go looking for the check. The exit is quicker than the enter (150ms in, 100ms out). A menu should get out of the way faster than it arrives. No scale-on-press here: a full-width list row that shrinks reads as the whole menu flinching. Pressed state is a background step instead.
Nine `rounded-md` overrides across the chart axis pickers, extensions and map panels pinned items to 6px inside a 12px panel, so exactly the menus that set their own styling were the ones that broke the concentric relationship. They inherit it now.
The Prisma/Drizzle switch was two plain buttons: no tablist role, no aria-selected, no arrow-key movement — invisible to assistive tech and unreachable from the keyboard, for the primary control on the page. Styling with it. The pills were 24px against 28px buttons beside them, so the switch sat visibly short of its own toolbar. h-7 is the design system's compact height. The track is rounded-lg (8px) against p-0.5, so the pills are rounded-md (6px) — outer radius minus padding, which puts their corners on the track's curve instead of inside it. The active pill takes elevate-1-rim rather than a generic shadow-sm, and press gets the 0.96 scale every other button in the app has. And $state.raw for the introspected model: it is every table, column and index of the schema, replaced wholesale by load() and never mutated, so deep proxying it was a per-object cost on a hundred-table database for a value only the two renderers ever read.
Opening the JSON tab on a query result walked the payload three times and
held two full copies of it, unbounded:
rows (arrays)
→ rowsToObjects() one object per row, one property per column
→ JSON.stringify() one string of all of them
→ <JsonViewer json>
→ JSON.parse(json) parsed straight back into the same objects
The last step existed only because JSONPath needed a structure to walk and
the component was handed a string — so it rebuilt what the caller was
already holding. `data` passes the objects through and that parse is gone.
The rest is a cap. The table beside this view is canvas-virtualised and
pays for none of the above; the JSON view paid all of it for every row.
`SELECT * FROM events` over a million rows spent sixteen seconds building
a string too large to read, and often ran the webview out of memory before
it could show any of it. Bounded to 1000 rows, with the count stated
rather than a prefix quietly presented as the whole answer — Export still
carries the full result, and it streams from Rust instead of assembling
the string in the webview.
A third tab beside Prisma and Drizzle: the schema as the statements that would rebuild it. CREATE TYPE for enums, CREATE TABLE with nullability, defaults and primary keys, the indexes, and the foreign keys. Foreign keys go out as ALTER TABLE after every table exists rather than as inline REFERENCES. Inline only works when the parent is created first, and no ordering can promise that once the schema has a cycle. Unlike the ORM targets it describes every engine, because it is the engine's own language — identifiers quoted the way each one quotes them. And a scope switch, SQL only: the whole database as one script. That is safe here in a way it is not for the ORM targets — a Prisma model or a Drizzle export named `users` can exist once per file, while a CREATE TABLE is schema-qualified, so two schemas owning `users` produce two distinct statements instead of a collision. Only Postgres and SQL Server are qualified; MySQL's "schema" IS the database and SQLite has none, so a prefix there would name something that does not exist. Every schema is read only once the scope is actually picked — it is N times the introspection of one schema — and sequentially, because four calls per schema fired at once across twenty schemas is eighty concurrent round trips at the pool. 9 tests cover the DDL shape, per-engine quoting, the ALTER ordering, and that same-named tables from two schemas stay apart.
The editor is one shared component driven by shell-level state, so switching between two Query Editor tabs swapped that state out from under an in-flight query. Two lines did it: captureSqlSnapshot() saved every tab as `sqlLoading: false` applySqlSnapshot() restored it as `false` regardless So leaving a tab mid-query stopped its spinner and showed the other tab's empty result pane, and coming back showed a finished-looking tab with no rows — while the elapsed time from the run was still sitting in the toolbar. Whatever came back landed in whichever tab happened to be active by then. The query never stopped. Rust kept executing it and the answer arrived on time; only the UI lost track of which tab had asked. Snapshots now carry the real flag, and a run records the tab that started it: results are written back through `patchSqlTab` into that tab's state, and into the live editor only if it is still the one in front. The activity log and query history record what the run actually produced rather than reading shell state that may since have changed hands. Known gap: the multi-statement result tabs are not part of a tab snapshot, so a multi-statement run that finishes in the background restores its last result rather than all of them. Previously it restored nothing at all.
Svelte flagged the target switch: an element with an interactive role needs a tabindex. -1 on the container is the roving-tabindex answer — the selected tab is the tab stop, the strip around it is not. The scope switch was the worse half of the same mistake. It carried role="tablist" with no arrow-key handling at all, which tells assistive tech to expect movement that is not there — worse than never claiming the role. Both now share one roving handler. Caught from the running app's dev log, not from the build: vite-plugin- svelte reports a11y problems as warnings, and grepping the build for "error" stepped straight over them.
The viewer was rendering correctly-indented monochrome: structure right, nothing to read by. Every Monaco preset in this app was written for SQL — `keyword`, `string`, `number`, `identifier`. Monaco's JSON tokenizer emits its own scopes: `string.key.json` for a property name, `string.value.json` for a string value, `keyword.json` for true/false/null, `delimiter.*.json` for the punctuation. No preset named any of them, so nearly every token fell through to the editor foreground and the whole document came out one colour. Derived from each theme's existing palette rather than hand-written per preset, so all 28 get it and none can be forgotten later. Keys deliberately take the keyword colour, not the string colour. In a result set the property names are the structure and the values are the data; painting both with the same hue is what made a page of rows impossible to scan.
An embedding or a document chunk runs off the right edge and there was no way to see the rest of it short of copying the row out. Off by default, and deliberately so: unwrapped keeps the structure scannable down the left edge, and a single embedding can run to tens of thousands of characters — wrapped, it buries every row around it. But a value you cannot see at all is worse than one that costs you some alignment, so this is a switch rather than a decision made on your behalf. Remembered in the layout store, since it is the kind of preference you set once. Toggling calls updateOptions on the live editor rather than recreating it, so the scroll position and any folds survive.
Wrap was on one of the five places this app shows JSON, and the row detail panel had its own switch on its own localStorage key — so the same preference existed twice and applied to two of five views. It is a setting now. Settings → General offers it, and all five read the same store: the SQL output, the ORM output, the JSON tab, a JSON cell, and the row detail panel. One shared JsonWrapToggle sits in each toolbar and writes that setting, so flipping it in any of them moves the switch in Settings and reflows every open view at once, rather than leaving each editor on whatever it was created with. Still off by default. Unwrapped keeps the structure scannable down the left edge, and one embedding value can run to tens of thousands of characters — wrapped, it buries every row around it. Moved out of the layout store on the way: this is a preference, not a layout dimension, and layout has no reactive store for views to follow. Four tests in settings-consistency, which exists for exactly this failure: a preference wired into some of its consumers and not the others. They check the setting is complete (default, parse, store, sync), that all five surfaces read it, that neither of the two old homes has come back, and that Settings still offers it.
There were three of these. The JSON tab had a good one — a colour dot per
kind, the typed fragment highlighted, the type and a value preview. The
SQL output and the table's JSON view had a much poorer one, and the reason
is worth stating: it called `getCompletions`, which maps the completion
engine's rich items down to their insert strings and throws away `kind`,
`detail` and `preview`. The widget then rebuilt a worse version from the
insert alone, which is how a row ended up reading
.key auth_name .auth_name
— the same identifier twice, a generic badge, and no sign of which
characters matched what you typed.
Now one shared JsonPathSuggest, used by all three:
• the colour dot says what shape the value is before you read its name
• the matched fragment is emphasised, and matched anywhere rather than
only at the start, because the filter already accepts an infix match —
highlighting only prefixes hid the reason half the rows were offered
• the type sits right, and the value preview shows on the armed row,
so a key can be chosen by what is in the data and not by name alone
• the top match is armed on open. Enter and Tab take it immediately;
a filtered list where Enter does nothing has to be arrowed into first
• the armed row scrolls itself into view
• a footer states the keys, which a list that simply appears teaches
nobody, and the match count
splitPath is exported for the highlighting. Two tests pin all three
viewers to the shared widget and off the lossy call.
The app has upwards of thirty search and filter fields — the sidebar's table filter, settings, logs, the connection rail, the object browser, and each dialog's own. Binding a key per field would be thirty bindings to keep in step with thirty components, and would still miss the next one somebody adds. So the key resolves its target. It reads what is actually on screen and picks the field that most plausibly IS the search box there: an explicit `data-search-input` beats everything, `type="search"` beats wording, and wording is the fallback that already covers every field written before this existed. A search box added tomorrow is reachable without anyone remembering to register it. Scoped to the topmost open dialog when there is one — a dialog owns the screen, and focusing a field on the page behind it would type into something you cannot see. Hidden fields cannot win, which matters here because inactive tabs stay mounted under display:none. Focusing selects what is already there, so "/" then typing replaces a stale query rather than appending to it. Also fixes the "?" guard while nearby. It tested activeElement.tagName for INPUT/TEXTAREA, which misses contenteditable and anything Monaco routes through a non-textarea — so "?" mid-sentence in those opened the shortcuts panel. Both keys now share one isTypingTarget check. The ranking is separated from the DOM and tested: it is the part with judgement in it, and the part that will want tuning. 9 tests cover the precedence order, the placeholders the app actually uses, and that "Researcher name" is not a search box.
The svelte-ignore named a11y_no_noninteractive_element_interactions; the rule that fires is a11y_no_static_element_interactions, and the directive has to be its own comment directly above the node rather than the last line of a longer one. Both wrong, so the warning stood. The handler is not an interaction — it swallows mousedown so pressing inside the panel cannot blur the path input and close the list before the click lands. The interactive elements are the option buttons inside.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Startup
Stroke compiled 6.8 MB of JavaScript and CSS before it could paint a pixel. About 3.9 MB of that was Monaco, loaded whether or not you ever opened a SQL tab.
The cause was one static import four components deep —
StudioShell -> PaneSnapshot -> TableJsonView -> MonacoTextView -> monaco-editor. Being in a separate chunk file does not make code lazy; only being unreachable from a plainimportdoes. Monaco, Shiki and canvas-confetti are off that path now, along with eight tab pages that shipped to every user despite sitting behind "only if opened" guards.6.80 MB -> 2.62 MB eager. Every chunk is still warmed during idle, so opening a tab is no slower — the work just no longer happens between launch and first paint.
Platform
npm install -g, which writes to a root-owned prefix on a default nodejs.org or distro install — so it failed for exactly the people who followed the app's own advice to install Node from nodejs.org. It keeps its own copy under the app data dir now; no elevation anywhere.CREATE_NO_WINDOW. The gateway runs through Node directly, so there is no console to suppress and stopping it actually stops it (undercmd /Cthe Node grandchild outlived the shell).Correctness
pg_stat_activity, per-row column rebuilds, deep-proxied payloads, full chart teardowns every 5s.Also
A SQL DDL codegen target (whole database in one script), JSON syntax colouring across all 28 themes, a shared JSONPath suggestion widget,
/to focus the search box on screen, a wrap setting for JSON, required-column asterisks in the grid, and a round of dropdown/tablist/connection-filter work.Full detail in the changeset.
Verification
npm run buildclean · 301 tests (from 276) ·cargo checkclean ·npm run tauri:buildproduced a working dmg · Windows-only code compile-checked againstx86_64-pc-windows-msvc· the OmniRoute managed install run end-to-end on macOS.Not verified: nothing here has been exercised in a running app beyond a dev session.
vitestis node-env pure-logic, so no test in this repo can catch a broken tab, and this branch converts 11 components to lazy loading. Worth a pass over: table view modes, the Schema/Insights/Codegen tabs, two SQL tabs with a slow query, and the JSON views.The Windows console fix has only been compile-checked —
git push origin HEAD:refs/heads/dev-build/windows-consolebuilds an NSIS installer as a CI artifact if you want to confirm before releasing.