Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
3 changes: 2 additions & 1 deletion packages/core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 / `<img>` / CSS output |
Expand Down
60 changes: 60 additions & 0 deletions packages/core/src/json/fold.test.ts
Original file line number Diff line number Diff line change
@@ -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<number>((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<number>((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)
}),
)
})
})
67 changes: 67 additions & 0 deletions packages/core/src/json/fold.ts
Original file line number Diff line number Diff line change
@@ -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<FoldRange | null> = []
// 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
}
}
1 change: 1 addition & 0 deletions packages/core/src/json/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions src/components/ui/IssueCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>
Expand All @@ -31,6 +33,7 @@ export function IssueCard({ issue }: { issue: Issue }) {
<div className="font-mono text-[11px] text-faint">
line {issue.line}
{issue.column !== null && `, column ${issue.column}`}
{issue.offset !== undefined && ` · offset ${issue.offset}`}
</div>
)}
</div>
Expand Down
107 changes: 81 additions & 26 deletions src/tools/json/FoldableOutput.tsx
Original file line number Diff line number Diff line change
@@ -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'

/**
Expand All @@ -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. */
Expand Down Expand Up @@ -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 },
)
Expand Down Expand Up @@ -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<unknown>[] = []
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<unknown>[] = 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
Expand Down Expand Up @@ -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),
Expand All @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions src/tools/json/JsonTool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
ChevronsDownUp,
ChevronsUpDown,
Download,
Search,
FileType,
GitCompare,
ListFilter,
Expand Down Expand Up @@ -830,6 +831,10 @@ export default function JsonTool() {
<ChevronsUpDown size={14} aria-hidden />
Unfold all
</Button>
<Button variant="ghost" onClick={foldApi.find} title="Find in the output (Ctrl/Cmd+F in the output)">
<Search size={14} aria-hidden />
Find
</Button>
</>
)}
<span className="flex-1" />
Expand Down
Loading