Skip to content

Rebuild the explorer as an app shell - #8

Merged
SimonCropp merged 12 commits into
mainfrom
explorer-shell
Sep 4, 2026
Merged

Rebuild the explorer as an app shell#8
SimonCropp merged 12 commits into
mainfrom
explorer-shell

Conversation

@SimonCropp

Copy link
Copy Markdown
Member

The explorer was a scrolling document: a 1080px column, one 1200px breakpoint, and a Monaco host pinned to 360px. At rest two thirds of it was whitespace, and after a run the result table sat below a wire request nobody scrolled past.

It is now a viewport-filling shell — a rail, a collapsible schema/history pane, tabs, the editor with its commands beside it, the wire request in a strip under it, and a tabbed output column with a status line. Each split is one ratio, dragged from JS and persisted. App.razor.cs sheds its non-UI half to Scry.Explorer.Core, where Scry.Explorer.Tests can reach it: ~310 lines that were only exercisable through a 60-second Playwright run now have unit tests.

New: a schema pane over ScryIntrospection (sources by kind, member tables with the flags the contract carries, navigable types, search, per-source starter queries), tabs with derived titles and persistence, settings and short-keys dialogs, favorites/labels/search in the history, and a status line reporting the outcome, the transport, the row count and the elapsed time. The transport half had no surface before, though docs/explorer.md makes a point of it.

MonacoAssets.targets, ported from BlazorQL, drops the assets a C# editor cannot reach — the 14 locale bundles and the ts/css/html workers. That is 9.96 MiB of 15.01, and it lands on the Scry.Server.Explorer package, whose EmbedExplorerUi globs this project's publish output.

Three constraints the browser suite already asserted shaped the design: exactly one Monaco editor (so read-only panes stay

 + Highlight, and tabs swap the editor's content rather than its model), the wire request readable while the result table shows (so it is a strip, not an output tab — InnerText returns "" for a hidden element), and window.scry staying a classic global object, which InterceptDownloadsAsync patches and which the synchronous rehydrate needs.

ExplorerSideBySide is gone with the breakpoint it existed to show; ExplorerSchemaPane takes its slot. The remaining baselines are reseeded.

The explorer was a scrolling document: a 1080px column, one 1200px breakpoint,
and a Monaco host pinned to 360px. At rest two thirds of it was whitespace, and
after a run the result table sat below a wire request nobody scrolled past.

It is now a viewport-filling shell — a rail, a collapsible schema/history pane,
tabs, the editor with its commands beside it, the wire request in a strip under
it, and a tabbed output column with a status line. Each split is one ratio,
dragged from JS and persisted. App.razor.cs sheds its non-UI half to
Scry.Explorer.Core, where Scry.Explorer.Tests can reach it: ~310 lines that were
only exercisable through a 60-second Playwright run now have unit tests.

New: a schema pane over ScryIntrospection (sources by kind, member tables with
the flags the contract carries, navigable types, search, per-source starter
queries), tabs with derived titles and persistence, settings and short-keys
dialogs, favorites/labels/search in the history, and a status line reporting the
outcome, the transport, the row count and the elapsed time. The transport half
had no surface before, though docs/explorer.md makes a point of it.

MonacoAssets.targets, ported from BlazorQL, drops the assets a C# editor cannot
reach — the 14 locale bundles and the ts/css/html workers. That is 9.96 MiB of
15.01, and it lands on the Scry.Server.Explorer package, whose EmbedExplorerUi
globs this project's publish output.

Three constraints the browser suite already asserted shaped the design: exactly
one Monaco editor (so read-only panes stay <pre> + Highlight, and tabs swap the
editor's content rather than its model), the wire request readable while the
result table shows (so it is a strip, not an output tab — InnerText returns ""
for a hidden element), and window.scry staying a classic global object, which
InterceptDownloadsAsync patches and which the synchronous rehydrate needs.

ExplorerSideBySide is gone with the breakpoint it existed to show; ExplorerSchemaPane
takes its slot. The remaining baselines are reseeded.
`dotnet publish` on a single-TFM src project sets TargetFramework as a global
property, and its implicit restore carries that down every ProjectReference to
Scry.SourceGenerator, which multi-targets netstandard2.0. The generator's assets
file comes back single-TFM and the *next samples build* fails with NETSDK1005 —
far enough from the cause to be worth writing down, next to the shared-obj
warning it belongs with.

Recovery is `dotnet restore src/Scry.slnx`; a plain build will not re-restore,
the assets file existing and looking current.
MemberKind.Collection is "aggregable but neither traversable nor projectable",
and it is published with IsNavigation *false* — so filtering on navigations,
attachments and sensitive members let it through. The button on Order produced

    Query.Order.Select(_ => new { _.Amount, _.Id, _.Region, _.Tags })

which the editor compiled and the server then rejected with "Projection member
must reference a scalar value." Department was the same, through Employees.

Two test gaps let it past: the unit test built its query from a model whose
source had no collection, and the browser test used Holiday, which has neither a
collection nor an attachment. Both now cover a source that has one — the browser
test over Holiday, Order and Department.

Also corrects what the pane and the docs say about sensitive members. The server
does not refuse one: projecting it answers no-store, and comparing against it
sends the request as a body rather than a URL. It is left out of a suggested
query by choice, not by rule.
A projection's members are the result's columns, and a row of them across one
line reads as none. Both the query the schema pane offers and a new Format button
now write the chain down the page and the projection down the page after it, from
one printer — so the generated shape and the formatted shape cannot drift.

A line is broken only where breaking it says something. The chain breaks because
each operator is a step and a projection because each member is a column; a
predicate stays on the one line it reads as. The declarations ahead of the query
are left as written, comments and all.

The starter query now projects *into* navigations rather than skipping them,
which is the only way a query can carry one, one level deep so a self-navigation
terminates. Scalars come first: a nested object is several lines tall, and burying
the row's own columns between two of them makes the shorter half the harder to
read.

Text that does not parse is reported rather than rewritten — a formatter guessing
at a half-typed query produces a differently half-typed one, and ParseExpression
stops at the first token it cannot continue from, so trailing garbage would
otherwise parse "successfully" as its own prefix and be silently dropped.

The printer emits LF rather than Environment.NewLine: the snippet lives in a
Monaco model read with EndOfLinePreference.LF, and output that followed the host
would have the schema pane offer different bytes on Windows than on CI.

Note the screenshot baselines went stale without failing. UseSsimForPng compares
structurally, and a single toolbar icon in a 1400x860 frame stayed under the
threshold — so the five explorer captures were reseeded deliberately rather than
by a failing test.
A byte[] is bulk bytes whichever way it travels, and opening on one is a poor
suggestion: the sample's Department.Logo turned up base64 in the starter query
for Employee, reached through the Department navigation.

By declared type rather than by a flag, because the contract publishes none:
[BinaryTransfer] deliberately does not change the queryable surface — that is the
whole of what the attribute claims, and why an attachment moves the schema stamp
and a diverted byte[] does not. So the suggestion cannot tell a diverted byte[]
from an inline one, and has no reason to; the inline one is the worse of the two
to open with anyway.

The four exclusions now share one predicate rather than being spelled out twice,
once for the row and once for a navigation's members. The test model gains
Department.Logo so the exact-output assertions cover it both ways.
CS1573 fires on a member whose XML comment documents some parameters but not all,
and only where a documentation file is generated — which ProjectDefaults turns on
by packing the src projects on any Release build. A Debug build says nothing, so
these four sat undocumented until someone built Release.

Also picks up the collection-expression assertions in the two test files.
Two things, and the second is the smaller.

The style pass: private consts go camelCase (Step -> step, NewLine -> newLine),
patterns merge rather than nest, ProjectDefaults joins Scry.Explorer.Core so the
global usings come from the package the rest of the tree gets them from — which
retires the hand-declared Cancel/CancelSource aliases and lets
BinaryResponseReader spell its parameter Cancel like everywhere else. Tests take
collection expressions.

The fix: CS1573 fires on a member whose XML comment documents some parameters but
not all, and only where a documentation file is generated. Four in
Scry.Explorer.Core were undocumented, and a Debug build says nothing about them.
CI died with "MONO_WASM: sbrk failed to allocate", 91 tests into the suite, and
everything after it failed to even load the page. Nothing was closing the pages:
NewPageAsync opened one per test and the browser kept every one of them alive for
the whole fixture. A page holds a fully booted WASM runtime — Roslyn included,
untrimmed and interpreted, because the explorer needs the interpreter — so the
suite walked the agent out of memory, tens of tests after the ones that spent it.

That fitted at 72 tests and does not at 95, but the leak was there either way; the
new tests only found it. Pages are now closed at teardown, after the console
reporting that reads them.

Local runs cannot reproduce the exhaustion — this machine has the headroom the CI
agent does not — so the fix is reasoned from the leak rather than from a red test
going green. All 95 still pass, in the same four minutes.
Every [Test] here pays for a fresh page, and a page means booting Blazor
WebAssembly and the in-browser Roslyn: around nineteen seconds on a CI agent
against under four on a developer machine. Split one assertion per test, that
bought isolation a linear sequence of assertions against one screen does not
need, at four minutes of CI.

Tabs, chrome (panes, dialogs, shortcuts), schema browsing, the queries the schema
offers, history curation and formatting each collapse to one test. Nothing is
dropped — every assertion moves across. What keeps its own page is what needs
one: a test that reloads, one that clears the stored state, and one that drags a
pane, none of which the others should find already done to them.

83 tests where there were 95, and the same coverage.
Mirrors what src/appveyor.yml did, on the shape BlazorQL uses: windows-latest,
setup-dotnet reading global.json, a cached Playwright browser download, and the
received snapshots uploaded when something fails so a rejected baseline can be
looked at rather than reproduced locally first.

Windows for two reasons rather than one: the screenshot baselines were rasterized
there, and the SQL-backed suites need LocalDB. The runner images carry it as a VS
component (Microsoft.VisualStudio.Component.SQL.LocalDB.Runtime, on both
windows-2022 and windows-2025), which is not a guarantee — so a check step names
that as the reason up front instead of leaving it to surface fifty tests later as
a connection timeout.

The three trees build one at a time, as they must: they share the src projects'
obj directories, and a concurrent build deletes the global usings ProjectDefaults
materializes there. The suites then run with `!cancelled()` so one failing suite
does not hide the others, gated on the build having succeeded so a broken build
fails once rather than four times.
Tag-driven, on the shape BlazorQL uses: the tag is the version, the suites run
before anything is pushed, and nuget.org is reached through trusted publishing so
no API key is stored anywhere.

One difference worth knowing. BlazorQL packs explicitly after building; Scry does
not need to, because ProjectDefaults packs the src projects on any Release build —
so `dotnet build src/Scry.slnx -c Release -p:Version=x` *is* the pack, and all six
packages land in nugets/ from it. That is also why only that build carries the
version: the nupkgs are written and zipped there, so the later trees rebuilding
the src assemblies underneath them cannot reach inside one.

Verified locally against -p:Version=9.9.9-ci.1: six packages, all named for the
version passed.

A release runs the suites rather than trusting the ci run on the same commit — a
tag is the last point at which finding out is cheap.
@SimonCropp
SimonCropp merged commit 117df2b into main Sep 4, 2026
1 check passed
@SimonCropp
SimonCropp deleted the explorer-shell branch September 4, 2026 05:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant