Skip to content

Deepen JSON formatter: tree view, copy path, per-field expansion, auto-fixes, lossless numbers - #7

Merged
fadeltd merged 3 commits into
mainfrom
feat/json-tree
Sep 28, 2026
Merged

fadeltd merged 3 commits into
mainfrom
feat/json-tree

Conversation

@fadeltd

@fadeltd fadeltd commented Sep 28, 2026 •

Copy link
Copy Markdown
Owner

Covers every "deepen JSON" task in TODO (A-1 to A-5), plus an existing bug found along the way. There are three commits: the tree view, the bug fix, and the other four tasks.

A-1 · Tree view

A Text / Tree switch in Format mode. The tree shows the same value as the text output (sorted keys, expanded embedded JSON), because both read from one shared memo.

  • Virtualised with @tanstack/react-virtual, so only rows on screen are in the DOM.
  • Opens the root and one level of containers, and stops early on very wide documents. Expand all is capped at 200k rows, and the header says so when the cap is hit.
  • Keyboard: ↑/↓, ←/→ (collapse or go to parent / expand or go to first child), Home/End, Enter/Space. Uses role="tree" and aria-activedescendant.
  • Rows are 22px on desktop (the code-table grid in DESIGN.md) and 44px below md (touch targets).
  • Colours use the existing greys only. The accent appears only on the selected row. TODO asked for "type-coloured values", but that would break the one-accent rule.

A-2 · Copy path

Selecting a row shows its path in a header strip, with three copy buttons: Pointer (/items/0/id), Path (items[0].id) and jq (.items[0].id). They sit in the header rather than on the row, so they work on touch and aren't hover-only.

  • core/paths.ts adds toJqPath and parsers for all three formats. The tests check that every row of a test document resolves back to the same node in each format, plus fast-check round-trip properties.
  • Fixed along the way: toDotPath(['$']) printed $, the same as the root, so a copied path pointed at the wrong node.
  • Rows no longer store their own path array. pathOf rebuilds the path from parent links, and only for the selected row.

A-3 · Expand embedded JSON one field at a time

Each embedded-JSON chip is now a toggle (aria-pressed). Expand all / Show original and the toolbar toggle still switch all of them.

  • A field that only exists inside another embedded payload is disabled until its parent is expanded, with a tooltip saying why.
  • New state field expandPointers. It's additive (saved state is merged over the defaults), so no stateVersion bump.

A-4 · Auto-fixes

core/fixes.ts has one pure function per fix, each returning TextEdit[]:

  • BOM
  • )]}' prefix
  • JSONP wrapper
  • comments (a comment alone on its line takes the line with it)
  • single quotes → double quotes
  • bare keys
  • trailing commas

They run as a pipeline, so each fix sees the previous one's output. That keeps edits from overlapping and makes the counts exact.

When the input doesn't parse, a panel lists what it can fix and whether the result will be valid JSON. It offers:

  • Apply fixes
  • Review in Diff, the option chosen for the preview. It seeds Diff's saved state (original on the left, fixed on the right) through the new lib/persist/handoff.ts, then opens /diff. Embedding CodeMirror's MergeView would have pulled ~92 kB into the JSON chunk.
  • Allow comments / trailing commas, when that alone would help

This replaces the old "Strip them" bar.

Properties: valid JSON is never changed, and a sloppy JS-style version of any JSON value repairs to the same value.

A-5 · Lossless numbers

When the existing big-number check fires, the document is re-parsed with native JSON.parse source access and JSON.rawJSON. Format, minify and sort then keep the literals exactly: 12345678901234567890 stays exactly as written. This also covers numbers inside expanded embedded JSON.

  • Why not lossless-json: no dependency to vet, and its LosslessNumber objects would need the same special-casing in every walker anyway.
  • Every walker (sort keys, embedded expansion, tree, path lookup) treats a raw number as a scalar.
  • On browsers without the API (Safari < 18.4), the old "not safe to use" warning is shown instead.
  • The tool does no arithmetic on values, so nothing needs switching off in this mode. The banner says numbers are passed through as written.

Fixed: __proto__ keys dropped by embedded expansion

An existing bug. expandEmbedded rebuilt objects with out[key] = …. For a "__proto__" key, that sets the object's prototype instead of adding a key, so the key vanished. Objects are now built with Object.fromEntries. There's a regression test, checked failing first.

Size

  • Entry bundle unchanged at 83.6 kB gzip.
  • The JsonTool chunk goes from 9.2 to 21.5 kB gzip: about 5–6 kB is the virtualizer, the rest is the new code.
  • One line turns off oxlint's react/incompatible-library rule, which is about the React Compiler, and this build doesn't use it.

Checks

  • tsc --noEmit, lint (only the existing MergePane warning), test (445 passing), build.
  • Headless Chrome against the production build:
    • Tree: 8,001-node document, 46–66 rows in the DOM while scrolling; keyboard nav, expand/collapse all, and the 390px layout all work.
    • Copy path: items[0]["id-x"] shown, all three buttons present.
    • Per field: one field expands alone; the nested field unlocks after its parent.
    • Lossless: big integer, 1.0, and a big number inside an embedded payload are all kept.
    • Fixes: a JSONP + JS-literal paste lists 5 fixes; Review in Diff opens /diff with both sides filled; Apply gives valid JSON.
  • Performance: fixes take 0.5 s on a 2.5 MB broken document, versus about 1 s for the existing error analysis. Moving both off the main thread is C-3 (worker plumbing).

A Text / Tree switch in format mode. The tree shows the same value as the
text output (sorted keys, expanded embedded JSON), flattened by a pure
core/tree.ts that only walks expanded containers, and virtualised with
@tanstack/react-virtual so only viewport rows are in the DOM.

Rows are keyed by RFC 6901 pointer, which A-2 (copy-path) will build on.
expandEmbedded rebuilt objects with `out[key] = v`, which for a
"__proto__" key invokes the prototype setter instead of defining a
property, so the key vanished from the output. Build with
Object.fromEntries, which defines own properties.
…ssless numbers

- A-2: the tree header copies the selected node as an RFC 6901 pointer,
  dot path or jq path. core/paths.ts adds toJqPath plus parsers for all
  three, and tests prove every flavour resolves back to the same node.
  toDotPath no longer prints a top-level "$" key as the root marker.
- A-3: embedded JSON chips toggle per field. A field nested in another
  payload is disabled until its parent is expanded.
- A-4: pure fixes returning TextEdit[] (BOM, )]}' prefix, JSONP,
  comments, single quotes, bare keys, trailing commas), run as a
  pipeline so edits never overlap. "Review in Diff" seeds Diff's saved
  state via lib/persist/handoff.ts instead of embedding MergeView.
- A-5: when the number gate trips, re-parse with native JSON source
  access and JSON.rawJSON so format/minify keep literals exactly,
  including inside expanded embedded JSON. Falls back to the warning
  where unsupported.

Tree rows no longer carry a path array; pathOf rebuilds it from parent
links for the selected row only.
@fadeltd fadeltd changed the title Add virtualised tree view to JSON formatter Deepen JSON formatter: tree view, copy path, per-field expansion, auto-fixes, lossless numbers Sep 28, 2026
@fadeltd
fadeltd merged commit b4007ef into main Sep 28, 2026
1 check passed
@fadeltd
fadeltd deleted the feat/json-tree branch September 28, 2026 03:11
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