From 31bf6e8d026b8ce627b8ff1c26833f5c42e88850 Mon Sep 17 00:00:00 2001 From: fadeltd Date: Fri, 30 Oct 2026 09:54:00 +0700 Subject: [PATCH] JSON: instant Fold all, Find in the fold view, offset back on the error card Fold all no longer builds the whole syntax tree (~1 s on a megabyte). It folds the ranges from a new core foldRanges, a linear bracket-and-string scan with the same bounds as lang-json's folds: ~13 ms for 2 MB in Node, ~0.15 s end to end for 1.4 MB of output in the browser. The fold view gets CodeMirror's search panel (@codemirror/search 6.7.2, pinned, already in the lockfile via codemirror), opened by a Find button or Ctrl/Cmd+F in the output, because the browser's own find sees only rendered lines. It is literal only: the regexp option is hidden and an update listener turns regex mode back off, since a user pattern must never run on the main thread. Styled with the design tokens; matches use the accent. It lives in the lazy FoldableOutput chunk (~17.5 kB gzip, from ~11); the Diff route and first-load JS are unchanged. IssueCard takes an optional offset, so the JSON card shows line, column and offset again. --- CHANGELOG.md | 7 +- package.json | 1 + packages/core/CHANGELOG.md | 3 +- packages/core/README.md | 2 +- packages/core/src/json/fold.test.ts | 60 ++++++++++++++++ packages/core/src/json/fold.ts | 67 +++++++++++++++++ packages/core/src/json/index.ts | 1 + pnpm-lock.yaml | 3 + src/components/ui/IssueCard.tsx | 3 + src/tools/json/FoldableOutput.tsx | 107 +++++++++++++++++++++------- src/tools/json/JsonTool.tsx | 5 ++ 11 files changed, 230 insertions(+), 29 deletions(-) create mode 100644 packages/core/src/json/fold.test.ts create mode 100644 packages/core/src/json/fold.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 85e2264..cdc3d8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ## [Unreleased] -Nothing yet. +### Added +- JSON Formatter: Find in the formatted output (the Find button, or Ctrl/Cmd+F in the output), which searches the whole document including folded parts, where the browser's own find only sees the lines on screen. Plain-text search, with match-case and whole-word options. + +### Changed +- JSON Formatter: Fold all is near-instant on large output (about 0.15 s for 1.4 MB, from about a second). +- JSON Formatter: parse errors show their character offset again, next to line and column. ## [0.14.0] — 2026-10-30 diff --git a/package.json b/package.json index 8af5a2c..6c424d5 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@codemirror/lang-json": "6.0.2", "@codemirror/language": "6.12.4", "@codemirror/merge": "6.12.2", + "@codemirror/search": "6.7.2", "@codemirror/state": "6.7.5", "@codemirror/view": "6.43.12", "@fadeltd/devtools-core": "workspace:*", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 2070901..599d152 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -6,7 +6,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ## [Unreleased] -Nothing yet. +### Added +- `/json`: `foldRanges(text, { minDepth })` lists every multi-line object and array in a JSON text with editor fold bounds (inside the brackets) and depth, in one linear scan that only tracks strings and brackets (~13 ms for 2 MB of formatted JSON). ## [0.6.1] — 2026-10-30 diff --git a/packages/core/README.md b/packages/core/README.md index 489f45e..e2394f6 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -124,7 +124,7 @@ await formatSql('select a from t where b = 1', { dialect: 'postgresql', keywordC | Subpath | What it does | |---|---| | `/list` | Sort, natural sort, dedupe, shuffle, case, prefix/suffix, filter, number, wrap, join, split, trim, normalise, set operations, slice; chained with per-step line counts; validated `#p=` pipeline links | -| `/json` | Every error with line and column, auto-fixes for almost-JSON, embedded JSON-in-a-string expansion, lossless big numbers, tree flattening, path formats (pointer, dot, jq), size tiers for large input | +| `/json` | Every error with line and column, auto-fixes for almost-JSON, embedded JSON-in-a-string expansion, lossless big numbers, tree flattening, path formats (pointer, dot, jq), size tiers for large input, fold ranges for an editor | | `/json-types` | TypeScript, Zod or Go types inferred from sample JSON or NDJSON: optional vs nullable, shared and collision-safe type names, int vs float by literal, Go initialisms and gofmt alignment, notes for anything approximated | | `/diff` | Two-stage line → word diff, ignore whitespace/case/trim, sorted-lines and key-sorted-JSON views, exact stats, size guard, unified patch, share-link encoding | | `/base64` | UTF-8-correct encode and decode, base64url, input sanitising, magic-byte type sniffing, hex dump rows for virtualised views, SVG strip reports, `data:` URI / `` / CSS output | diff --git a/packages/core/src/json/fold.test.ts b/packages/core/src/json/fold.test.ts new file mode 100644 index 0000000..59eeab1 --- /dev/null +++ b/packages/core/src/json/fold.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import fc from 'fast-check' +import { foldRanges } from './fold.ts' + +/** Non-empty objects and arrays: the ones pretty-printing puts on several lines. */ +function countContainers(value: unknown): number { + if (Array.isArray(value)) return (value.length > 0 ? 1 : 0) + value.reduce((n, v) => n + countContainers(v), 0) + if (value !== null && typeof value === 'object') { + const members = Object.values(value) + return (members.length > 0 ? 1 : 0) + members.reduce((n, v) => n + countContainers(v), 0) + } + return 0 +} + +describe('foldRanges', () => { + it('finds each multi-line container, inside its brackets', () => { + const text = JSON.stringify({ a: [1, 2], b: { c: [] }, d: 'x' }, null, 2) + const ranges = foldRanges(text) + expect(ranges.map((r) => [text[r.from - 1], text[r.to], r.depth])).toEqual([ + ['{', '}', 0], + ['[', ']', 1], + ['{', '}', 1], + ]) + }) + + it('skips single-line containers', () => { + expect(foldRanges('{"a": [1, 2], "b": {}}')).toEqual([]) + expect(foldRanges('[]\n')).toEqual([]) + }) + + it('ignores brackets and quotes inside strings', () => { + const text = '{\n "a": "[{\\"}",\n "b": "\\\\"\n}' + expect(foldRanges(text)).toEqual([{ from: 1, to: text.length - 1, depth: 0 }]) + }) + + it('drops containers shallower than minDepth', () => { + const text = JSON.stringify({ a: { b: [1] } }, null, 2) + expect(foldRanges(text, { minDepth: 1 }).map((r) => r.depth)).toEqual([1, 2]) + }) + + it('skips a mismatched bracket instead of throwing', () => { + expect(foldRanges('{\n]\n}')).toEqual([{ from: 1, to: 4, depth: 0 }]) + expect(foldRanges('}\n{')).toEqual([]) + }) + + it('matches pretty-printed JSON: one range per non-empty container, properly nested', () => { + fc.assert( + fc.property(fc.jsonValue(), fc.integer({ min: 1, max: 4 }), (value, indent) => { + const text = JSON.stringify(value, null, indent) + const ranges = foldRanges(text) + expect(ranges).toHaveLength(countContainers(value)) + for (const r of ranges) { + expect('{['.includes(text[r.from - 1]!)).toBe(true) + expect(text[r.to]).toBe(text[r.from - 1] === '{' ? '}' : ']') + } + for (let i = 1; i < ranges.length; i++) expect(ranges[i]!.from).toBeGreaterThan(ranges[i - 1]!.from) + }), + ) + }) +}) diff --git a/packages/core/src/json/fold.ts b/packages/core/src/json/fold.ts new file mode 100644 index 0000000..664bcc8 --- /dev/null +++ b/packages/core/src/json/fold.ts @@ -0,0 +1,67 @@ +/** An object or array body that spans more than one line. */ +export interface FoldRange { + /** Just after the opening `{` or `[`. */ + from: number + /** At the closing `}` or `]`. */ + to: number + /** 0 for the root container, 1 for its members, and so on. */ + depth: number +} + +const OPEN_BRACE = 0x7b +const CLOSE_BRACE = 0x7d +const OPEN_BRACKET = 0x5b +const CLOSE_BRACKET = 0x5d +const QUOTE = 0x22 +const BACKSLASH = 0x5c +const NEWLINE = 0x0a + +/** + * Every multi-line object and array in a JSON text, ordered by `from`, with + * the same bounds an editor's JSON fold uses (inside the brackets). + * + * A single linear scan that only tracks strings and brackets, so it costs a + * few milliseconds on a megabyte where building a full syntax tree costs most + * of a second. Meant for formatted output: it does not validate, and a + * mismatched bracket is skipped rather than reported. + */ +export function foldRanges(text: string, { minDepth = 0 }: { minDepth?: number } = {}): FoldRange[] { + const ranges: Array = [] + // Per open container: its slot in `ranges`, its closing char, the offset of + // its opening bracket. + const stack: Array<{ slot: number; close: number; open: number }> = [] + let lastNewline = -1 + + for (let i = 0; i < text.length; i++) { + const c = text.charCodeAt(i) + if (c === QUOTE) { + i = endOfString(text, i) + } else if (c === NEWLINE) { + lastNewline = i + } else if (c === OPEN_BRACE || c === OPEN_BRACKET) { + stack.push({ slot: ranges.length, close: c === OPEN_BRACE ? CLOSE_BRACE : CLOSE_BRACKET, open: i }) + ranges.push(null) + } else if (c === CLOSE_BRACE || c === CLOSE_BRACKET) { + const top = stack[stack.length - 1] + if (top === undefined || top.close !== c) continue + stack.pop() + const depth = stack.length + if (lastNewline > top.open && depth >= minDepth) ranges[top.slot] = { from: top.open + 1, to: i, depth } + } + } + + return ranges.filter((r): r is FoldRange => r !== null) +} + +/** The offset of the quote closing the string opened at `start`, or the end. */ +function endOfString(text: string, start: number): number { + let i = start + 1 + for (;;) { + const q = text.indexOf('"', i) + if (q === -1) return text.length + let slashes = 0 + while (text.charCodeAt(q - 1 - slashes) === BACKSLASH) slashes++ + if (slashes % 2 === 0) return q + i = q + 1 + } +} diff --git a/packages/core/src/json/index.ts b/packages/core/src/json/index.ts index c769a52..3ea5f07 100644 --- a/packages/core/src/json/index.ts +++ b/packages/core/src/json/index.ts @@ -1,6 +1,7 @@ export * from './embedded.ts' export * from './errors.ts' export * from './fixes.ts' +export * from './fold.ts' export * from './format.ts' export * from './paths.ts' export * from './rawjson.ts' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd904d4..3a3054e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@codemirror/merge': specifier: 6.12.2 version: 6.12.2 + '@codemirror/search': + specifier: 6.7.2 + version: 6.7.2 '@codemirror/state': specifier: 6.7.5 version: 6.7.5 diff --git a/src/components/ui/IssueCard.tsx b/src/components/ui/IssueCard.tsx index 9682eaf..273c50f 100644 --- a/src/components/ui/IssueCard.tsx +++ b/src/components/ui/IssueCard.tsx @@ -8,6 +8,8 @@ export interface Issue { message: string line: number | null column: number | null + /** Character offset into the input, for issues that have one. */ + offset?: number hint: string | null snippet: { lines: ReadonlyArray<{ number: number; text: string }> @@ -31,6 +33,7 @@ export function IssueCard({ issue }: { issue: Issue }) {
line {issue.line} {issue.column !== null && `, column ${issue.column}`} + {issue.offset !== undefined && ` · offset ${issue.offset}`}
)} diff --git a/src/tools/json/FoldableOutput.tsx b/src/tools/json/FoldableOutput.tsx index 5f18531..1ca3e73 100644 --- a/src/tools/json/FoldableOutput.tsx +++ b/src/tools/json/FoldableOutput.tsx @@ -1,16 +1,10 @@ import { useEffect, useRef } from 'react' import { Compartment, EditorState, type StateEffect } from '@codemirror/state' import { EditorView, keymap, lineNumbers } from '@codemirror/view' -import { - codeFolding, - foldable, - foldEffect, - foldGutter, - foldKeymap, - forceParsing, - unfoldAll, -} from '@codemirror/language' +import { codeFolding, foldEffect, foldGutter, foldKeymap, unfoldAll } from '@codemirror/language' import { json } from '@codemirror/lang-json' +import { getSearchQuery, openSearchPanel, search, searchKeymap, SearchQuery, setSearchQuery } from '@codemirror/search' +import { foldRanges } from '@fadeltd/devtools-core/json' import { editorTheme } from '@/lib/codemirror/theme' /** @@ -29,6 +23,8 @@ export interface FoldApi { /** Fold every object and array below the root, at every depth. */ foldAll: () => void unfoldAll: () => void + /** Open the find panel: the browser's own find sees only rendered lines. */ + find: () => void } /** The fold gutter and placeholder in the app's tokens. */ @@ -59,6 +55,57 @@ const foldTheme = EditorView.theme( cursor: 'pointer', }, '.cm-foldPlaceholder:hover': { color: 'var(--color-fg)' }, + + // The find panel, as a toolbar row. Matches use the accent: they are a + // selection state, not a diff or validation colour. + '.cm-panels': { + backgroundColor: 'var(--color-surface)', + color: 'var(--color-fg)', + }, + '.cm-panels-top': { borderBottom: '1px solid var(--color-border)' }, + '.cm-panel.cm-search': { + display: 'flex', + flexWrap: 'wrap', + alignItems: 'center', + gap: '6px', + padding: '6px 32px 6px 8px', + fontFamily: 'var(--font-sans)', + fontSize: '12px', + }, + '.cm-panel.cm-search br': { display: 'none' }, + // Regex search would run a user pattern on the main thread; see the guard + // in the extensions below. + '.cm-panel.cm-search label:has(input[name=re])': { display: 'none' }, + '.cm-panel.cm-search label': { display: 'inline-flex', alignItems: 'center', gap: '4px', color: 'var(--color-muted)' }, + '.cm-textfield': { + height: '28px', + backgroundColor: 'var(--color-bg)', + color: 'var(--color-fg)', + border: '1px solid var(--color-border-strong)', + borderRadius: '4px', + padding: '0 8px', + fontFamily: 'var(--font-mono)', + fontSize: '12px', + }, + '.cm-textfield:focus': { outline: '1px solid var(--color-accent)', outlineOffset: '-1px' }, + '.cm-button': { + height: '28px', + backgroundColor: 'var(--color-surface-2)', + backgroundImage: 'none', + color: 'var(--color-fg)', + border: '1px solid var(--color-border-strong)', + borderRadius: '4px', + padding: '0 10px', + fontSize: '12px', + }, + '.cm-panel.cm-search [name=close]': { + color: 'var(--color-muted)', + fontSize: '16px', + top: '6px', + right: '8px', + }, + '.cm-searchMatch': { backgroundColor: 'var(--color-accent-dim)' }, + '.cm-searchMatch-selected': { backgroundColor: 'var(--color-accent)', color: 'var(--color-bg)' }, }, { dark: true }, ) @@ -94,28 +141,33 @@ function marker(open: boolean): HTMLElement { const wrapping = new Compartment() /** - * Fold every foldable line but the first, so the root stays open and the - * top level reads as a list of collapsed members; each one then opens a level - * at a time. CodeMirror's own `foldAll` folds only outermost ranges, which in - * a JSON document is the root itself: one `{…}` line. + * Fold every object and array below the root, so the top level reads as a + * list of collapsed members that each open a level at a time. CodeMirror's own + * `foldAll` folds only outermost ranges, which in a JSON document is the root + * itself: one `{…}` line. * - * On click only. The parser normally stops past the viewport, so this is the - * one place the whole tree is built: ~0.8 s for 1.5 MB of formatted JSON - * (Node, cold), inside a budget sized for the 2 MB the view is ever given. - * Past the budget, only the parsed part folds. + * The ranges come from a bracket scan of the text (~13 ms for 2 MB) rather + * than the syntax tree, which would mean parsing the whole document first + * (~0.8 s); they have the same bounds as lang-json's own folds. */ function foldBelowRoot(view: EditorView) { - forceParsing(view, view.state.doc.length, 2500) - const { state } = view - const effects: StateEffect[] = [] - for (let n = 2; n <= state.doc.lines; n++) { - const line = state.doc.line(n) - const range = foldable(state, line.from, line.to) - if (range) effects.push(foldEffect.of(range)) - } + const effects: StateEffect[] = foldRanges(view.state.doc.toString(), { minDepth: 1 }).map((r) => + foldEffect.of({ from: r.from, to: r.to }), + ) if (effects.length > 0) view.dispatch({ effects }) } +/** Turns regex search back off should anything switch it on. */ +const literalSearchOnly = EditorView.updateListener.of((update) => { + const query = getSearchQuery(update.state) + if (!query.regexp) return + update.view.dispatch({ + effects: setSearchQuery.of( + new SearchQuery({ search: query.search, caseSensitive: query.caseSensitive, wholeWord: query.wholeWord }), + ), + }) +}) + /** * The smallest single replacement that turns `prev` into `next`: typing in the * input changes a few lines of the output, and replacing only those keeps the @@ -191,7 +243,9 @@ export default function FoldableOutput({ json(), editorTheme, foldTheme, - keymap.of(foldKeymap), + keymap.of([...foldKeymap, ...searchKeymap]), + search({ top: true }), + literalSearchOnly, // Focusable and selectable, so keyboard folding, select-all and copy // work; readOnly is what refuses input. EditorView.editable.of(true), @@ -206,6 +260,7 @@ export default function FoldableOutput({ initial.onReady?.({ foldAll: () => foldBelowRoot(view), unfoldAll: () => void unfoldAll(view), + find: () => void openSearchPanel(view), }) return () => { latest.current.onReady?.(null) diff --git a/src/tools/json/JsonTool.tsx b/src/tools/json/JsonTool.tsx index 9f5f889..ce287aa 100644 --- a/src/tools/json/JsonTool.tsx +++ b/src/tools/json/JsonTool.tsx @@ -8,6 +8,7 @@ import { ChevronsDownUp, ChevronsUpDown, Download, + Search, FileType, GitCompare, ListFilter, @@ -830,6 +831,10 @@ export default function JsonTool() { Unfold all + )}