Skip to content

Show each captured body in a viewer for its content type - #67

Merged
Twinsen81 merged 5 commits into
mainfrom
praia/7cnv2
Oct 2, 2026
Merged

Twinsen81 merged 5 commits into
mainfrom
praia/7cnv2

Conversation

@Twinsen81

@Twinsen81 Twinsen81 commented Oct 1, 2026 •

Copy link
Copy Markdown
Owner

Summary

The Network tab sent every body through the JSON highlighter, and showed it as plain text when it was not JSON. Now that the wire carries each body's content type and the body route serves any captured body, the detail picks a viewer by the content type.

Body Default view Raw view
JSON: */json, *+json, or a valid JSON object or array under any other type A tree that folds by object and array The text as captured, with line numbers
application/x-www-form-urlencoded A table of the fields, decoded The text as captured
XML: */xml and *+xml, SVG included Indented, with whitespace added and nothing else The text as captured
text/html Indented by HTML's rules The text as captured
PNG, JPEG, GIF, WebP, kept as bytes The image, with its size and pixel dimensions A hex dump of the bytes
Another image type kept as bytes none: "has no preview" A hex dump of the bytes
Other text, and JSON cut at the capture cap The text with line numbers

Tree. A click on a key or a bracket folds the node to one line that counts its keys or items, and Alt-click also folds everything inside it. Search marks appear inside the nodes, a folded node that holds a match is highlighted, and the folds of a body survive the renders that a search or a refresh causes while that body stays the same, such as a request's while its response streams. A click that ends a text selection does not fold, so keys can still be selected.

Markup printer. Every tag, comment, CDATA section, and run of text is the exact slice of the input. An element that holds text, even in part, is shown as it arrived, because its whitespace is part of the text: <value> padded </value> and <item>Why <em>W</em> are great</item> stay as they are. An element that holds only elements gets one line for each, and the whitespace between them gives way to the printer's indentation. For HTML: void elements, the end tags HTML leaves out (li, p, dt/dd, td/th, tr, table sections, option), and the text of script, style, pre, textarea, and title, which is not markup. A search is marked across the parts of a tag, so type="all" and <title are found. A body cut inside a tag ends with that tag on its own line. Indentation stops at 32 levels, so a body full of unclosed tags cannot make the output grow with the square of its size.

Raw, Copy, and Download. Each viewer has a Raw view, and the browser remembers the view chosen for each content type (debug-network-body-views in localStorage, like the headers toggle). Each row of the line-numbered text keeps its line break, so a selection copied from it keeps its empty lines. Copy copies the body as captured in every view. Download saves the body from the body route.

Where the code is. The renderers are pure functions in shared.js, next to the JSON highlighter, so the node tests can load them and any tab can use them: debugBodyKind, debugJsonTree, debugHighlightMarkup, debugLineNumbered, debugFormTable, and debugHexDump. shared.css has their components (.dc-tree, .dc-markup, .dc-lines, .dc-kv, and .dc-seg--sm), and docs/STYLEGUIDE.md documents them. Folding is one delegated click listener in shared.js, and the view switch is a data-action in network.js, so there is no inline handler. network.js picks the viewer, builds the bar above the body, and loads the hex dump and the image size after render. The sample gets a "Body viewers" section with a request for each viewer, a PNG and a BMP that it posts as request bodies (no public fixture serves an image type the console cannot preview), and a request that posts a 250 KB JSON body.

Decisions to check

  1. Copy now copies the exact captured text for JSON too. It used to copy the text with indentation added. Raw shows the captured text, and Copy matches it in every view. The Copy button of the whole transaction still indents. If you prefer indented JSON from the body's Copy, it is a one-line change.
  2. Fold carets show on hover and on folded nodes only, as in code editors. The 250 KB body has 6,623 foldable nodes, and a caret on each cost about 20 ms of layout.
  3. The image's Raw view is a hex dump, so the image viewer also has a raw view and a Copy (which copies the dump). Preview has no Copy; Download gives the bytes.
  4. Download is on every body, not only on binary ones, because the body route also serves a text body as an attachment, which helps with a large JSON body.
  5. CSS counters do not cost nothing on very many lines. On 33,000 lines, the numbers take about 55 ms, and the element for each line about 40 ms more. A 256 KB text with 3,000 to 4,000 lines takes about 15 ms.
  6. Multipart bodies stay text with line numbers: the redactor cannot read their fields yet (SECURITY.md lists it as a known gap).

Verification

On a physical device, against the debug sample, with the console in desktop Chrome, in the dark and the light theme:

  • Each sample request got its viewer: GET /json the tree, GET /xml and GET /image/svg the XML printer, GET /html the HTML printer, GET /image/jpeg and GET /image/png the image (34.8 KB · 239 × 178 px), POST a form the table (the password field shows the redactor's [REDACTED]), GET /robots.txt line numbers. A mocked response without a Content-Type got the tree.

  • Raw worked in each viewer. Copy wrote exactly the captured body (compared with the detail's responseBody), and in the image's Raw view exactly the hex dump on screen. The JPEG's dump has 2,225 lines.

  • Switching XML to Raw was remembered for application/xml and did not change JSON.

  • The sample's new POST a PNG and POST a BMP buttons send a 96 × 64 image as a request body, and capture kept both as bytes. In the Request tab, the PNG showed its preview (1.2 KB · 96 × 64 px) and, in Raw, a hex dump that starts with the PNG signature. The BMP showed no <img>, the note "image/bmp has no preview", and a hex dump that starts with BM; its download is named lustro-<id>-request.bmp.

  • The 250 KB JSON body: Alt-click on items and then a click folded all 1,750 items to one line each (6,621 of 6,623 nodes folded). A search for one item's name marked it, kept the folds, and highlighted the folded item that holds it.

  • Render time on the same computer, best of five runs, from setting innerHTML until layout:

    250 KB JSON body Time
    Tree 118 to 138 ms
    Previous highlighter, the same body 101 to 111 ms
    Raw, minified (one line) 8 ms
    Raw, the same body indented (33,109 lines) 140 ms
  • On a physical device, with live traffic for the event stream and the XML, the review fixes held: a selection copied from the rows of a live event stream (5 events, 5 empty lines) equals the body without its final line break, and a search for type="all" marked both slides of the XML. The same checks passed on an emulator with mocked responses, where a JSON body sent as text/html got the tree; a selection copied from the rows of an event stream with three empty lines equals the body, without its final line break, as from a plain <pre>; a search for type="all" marked both slides of the XML; and the items of the small view switch take clicks 5 px above and below them.

  • node --test lustro/src/test/js/*.test.js: 86 tests pass. A mutation check removed the escaping from each renderer in turn, the prototype-free tag tables, the depth limit, the guard for a top-level scalar, and the rules that keep an element's text as it arrived, and each one failed tests.

  • ./gradlew detekt :lustro:testDebugUnitTest :sample:testDebugUnitTest :lustro:lintDebug :sample:lintDebug passes. InlineHandlerCspTest covers the changed assets.

Type of change

  • Bug fix (non-breaking)
  • New feature (non-breaking)
  • Breaking change (public API or wire protocol)
  • Documentation / tooling only

Checklist

  • Tests added or updated for the change
  • ./gradlew apiCheck passes (no unintended public API changes; dump updated if intended): no Kotlin API changes, only the sample's MainActivity
  • ./gradlew detekt passes
  • CHANGELOG.md updated under [Unreleased]
  • Public API changes are documented with KDoc: none
  • Wire-protocol or schema changes are reflected in wire-protocol/ and tab OpenAPI fragments: none
  • All commits are signed off (DCO): git commit -s

Compatibility notes

No wire-protocol or Kotlin API change. The new .dc-* components and shared.js functions add to the tab-author contract and change nothing that exists. The body's Copy button copies the exact captured text, also for JSON (see decision 1).

The Network tab sent every body through the JSON highlighter, and showed
it as plain text when it was not JSON. A form, an XML or HTML page, or a
large JSON document was hard to read, and an image showed without its
size.

The detail now picks a viewer by the body's content type. JSON gets a
tree that folds by object and array: a click on a key or a bracket folds
the node to one line that counts its keys or items, and Alt-click also
folds everything inside it. A form body is a table of its fields, with
names and values decoded; a part that is not valid percent-encoding is
shown as sent. XML and HTML are indented, with whitespace added and
nothing else, as the JSON view does. For HTML, void elements, the end
tags that HTML leaves out, and the text of script, style, pre, textarea,
and title follow HTML's rules. PNG, JPEG, GIF, and WebP show as an image
with their size and pixel dimensions. Other text has line numbers from
CSS counters. A body of no known type that scans as JSON gets the tree,
as a mocked body without a Content-Type does, and a JSON body cut at the
capture cap shows as text.

Each viewer has a Raw view: the body as captured with line numbers, or
an image's bytes as a hex dump from the body route. The browser
remembers the view chosen for each content type. Copy copies the body as
captured in every view, so for JSON it no longer adds indentation; the
Copy button of the whole transaction still does. Download saves the body
from the body route. A search renders the detail again, and the tree
keeps its folded nodes; a folded node that holds a match is highlighted.

The renderers are in shared.js, next to the JSON highlighter, so any tab
can use them, and shared.css has their .dc-tree, .dc-markup, .dc-lines,
and .dc-kv components and .dc-seg--sm, documented in
docs/STYLEGUIDE.md. Folding is one delegated click listener in
shared.js. The renderers escape all of a body's text. The node tests pin
that only their own tags come out for markup payloads in bodies and in
search queries, that the tree and the markup printer add whitespace and
nothing else, and that the markup printer stays linear on input built to
make it quadratic.

The sample has a request for each viewer, and one that posts a 250 KB
JSON body.

Verified against the debug sample on a physical device, with the console
in desktop Chrome, in the dark and the light theme. Each request got its
viewer, Raw and Copy worked in each viewer, and the view was remembered
for its content type only. On the same computer, best of five runs,
building and laying out the tree for the 250 KB JSON body took 118 to
138 ms, and the previous highlighter took 101 to 111 ms on the same
body. Raw took 8 ms for that body, and 140 ms for the same body
indented, which is 33,000 lines.

Signed-off-by: Evgenii Plokhov <plokhov@gmail.com>
The markup printer trimmed the whitespace at the ends of each run of
text, so <value>  padded  </value> showed as <value>padded</value>.
That whitespace is part of the value, and the view is meant to add
whitespace and nothing else. It also put each piece of mixed content on
its own line, so <item>Why <em>W</em> are great</item> took five lines.

The printer now builds the elements first, with the same HTML rules as
before. An element that holds text, a CDATA section, or nothing but
whitespace is printed as it arrived, on the line of its start tag. Only
an element that holds elements, comments, and the like gets a line for
each of them, and there the whitespace between them gives way to the
printer's. A tag cut off at the end of the body is a separate token
now, so it does not make the element around it look like text.

Tests pin both cases, and a generated set of documents checks that the
exact source of every element that holds text is in the output.
Verified in the console against the debug sample on a physical device:
the slideshow XML keeps each mixed-content item on one line, and the
HTML page's paragraph keeps its own line breaks.

Signed-off-by: Evgenii Plokhov <plokhov@gmail.com>
The sample got every image from a response, and only in the types that
the public fixture serves: PNG, JPEG, WebP, and SVG. So two paths of the
body viewers had no request that reaches them: an image in a request
body, and an image type that the console does not preview.

Two buttons in the Body viewers section now post a 96 x 64 image to
/anything. One is a PNG that Android encodes. The other is a 24-bit BMP
that the sample writes itself, since Android has no BMP encoder.

Verified on a physical device with the console in desktop Chrome. Both
bodies were captured as bytes, 1,238 and 18,486 bytes. In the Request
tab, the PNG showed its preview with "1.2 KB · 96 × 64 px", and in Raw a
hex dump that starts with the PNG signature. The BMP showed no image,
the note that image/bmp has no preview, and a hex dump that starts with
"BM", and its download is named for a .bmp file.

Signed-off-by: Evgenii Plokhov <plokhov@gmail.com>
…tion

A review of the body viewers found four things to fix.

JSON sent with another content type got no JSON view. Servers send JSON
as text/html, and apps send it as a form, and the highlighter that this
change replaced indented every body that was JSON. A body that is a
valid JSON object or array now gets the tree whatever its type says; the
type still decides when the body is not JSON. JSON.parse answers that
question, since it accepts the grammar debugScanJsonSource does.

A selection copied from line-numbered text lost every empty line,
because each row was a block with no line break in it. Each row now
ends with its line break. The rows render at the same height, and a
selection copies the text as from a plain <pre>, which leaves out only
the final line break.

A search that crossed the parts of a tag, such as type="all" or <title,
got no mark in the XML and HTML view. Each tag is now searched as a
whole, and the mark is split across the spans of its parts.

The items of .dc-seg--sm are 18 px tall, under the styleguide's 30 px
for a hit target. An invisible area above and below each item now takes
the clicks, as the master toggle and the rule switch do.

Tests pin the four. Verified on an emulator with mocked responses: a
JSON body sent as text/html got the tree, a selection copied from an
event stream with three empty lines equals the body without its final
line break, a search for type="all" marked both slides of an XML body,
and clicks 5 px above and below a view-switch item reached it.

Signed-off-by: Evgenii Plokhov <plokhov@gmail.com>
The detail kept the folded nodes of its JSON trees through a render
only when the request and the response body were both unchanged. While
a response streams, the detail renders again at least every 750 ms with
a longer response body, so the folds of the request's tree were lost on
each render. A POST with a JSON body and an event-stream response, as a
chat completion API sends, hit this on every request.

The folds of each direction are now kept when that direction's body is
unchanged.

Verified against the debug sample on a physical device, with the page's
fetch changed so the poll reports a POST with a JSON body as streaming
and each detail fetch returns a longer response body. Before the
change, the request's folded root was open again after three renders;
after it, the root stayed folded through the renders and a search.

Signed-off-by: Evgenii Plokhov <plokhov@gmail.com>
@Twinsen81
Twinsen81 marked this pull request as ready for review October 2, 2026 09:45
@Twinsen81
Twinsen81 merged commit 1c5aa17 into main Oct 2, 2026
8 checks passed
@Twinsen81
Twinsen81 deleted the praia/7cnv2 branch October 2, 2026 09:45
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