Skip to content

perf: cut startup 61%, and a round of platform fixes - #80

Merged
broisnischal merged 49 commits into
masterfrom
fix/perf-and-ui-followups
Aug 9, 2026
Merged

perf: cut startup 61%, and a round of platform fixes#80
broisnischal merged 49 commits into
masterfrom
fix/perf-and-ui-followups

Conversation

@broisnischal

@broisnischal broisnischal commented Aug 9, 2026

Copy link
Copy Markdown
Owner

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 plain import does. 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

  • OmniRoute installs on macOS and Linux. It used 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.
  • No more console window on Windows. Every child process — npm, Docker, ssh tunnels, the port scan, the licence check — spawns with CREATE_NO_WINDOW. The gateway runs through Node directly, so there is no console to suppress and stopping it actually stops it (under cmd /C the Node grandchild outlived the shell).

Correctness

  • A running query survives leaving its tab. Two Query Editor tabs share one editor component and switching swapped its state out from under an in-flight query. The query never stopped; the UI lost track of which tab asked.
  • Cmd+B toggles the sidebar again. It went through the bubble-phase hotkey layer, so Monaco, the grid canvas and dialog overlays all swallowed it first.
  • The JSON view no longer freezes. It built an object per row, stringified all of them, then parsed that string back into objects — three passes, two copies, unbounded.
  • Instance Insights: unbounded 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 build clean · 301 tests (from 276) · cargo check clean · npm run tauri:build produced a working dmg · Windows-only code compile-checked against x86_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. vitest is 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-console builds an NSIS installer as a CI artifact if you want to confirm before releasing.

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-bot

changeset-bot Bot commented Aug 9, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest 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.
@broisnischal broisnischal changed the title Connection search, working Cmd+R, and a Cmd+Shift+D disconnect perf: cut startup 61%, and a round of platform fixes Aug 9, 2026
@broisnischal broisnischal added the release:minor Bump minor version (0.x.0) label Aug 9, 2026
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.
@broisnischal broisnischal self-assigned this Aug 9, 2026
@broisnischal
broisnischal merged commit f043955 into master Aug 9, 2026
1 check passed
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 9, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

release:minor Bump minor version (0.x.0)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant