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 }) {
)}
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
+
>
)}