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
2 changes: 1 addition & 1 deletion .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,6 @@
- [ ] User-facing change → an entry under `## [Unreleased]` in `CHANGELOG.md` (without one, the merge releases nothing)
- [ ] No network request, third-party resource, `innerHTML`, or dependency that needs `eval`
- [ ] Anything touching a secret declares `store: { kind: 'none' }`; nothing multi-MB goes to `localStorage`
- [ ] Entry bundle still under ~90 kB gzip (CI enforces 92160 B)
- [ ] First-load JS still under 105 KiB gzip (CI enforces 107520 B)
- [ ] Works at phone width: panes stack, 44px touch targets, no hover-only controls
- [ ] Markdown prose is not hard-wrapped
32 changes: 21 additions & 11 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,17 +72,27 @@ jobs:
exit 1
fi

# The entry bundle is what every visitor pays for on first load. If a
# heavy dependency escapes a lazy route chunk, this is where we find out
# rather than in production.
- name: Check entry bundle budget
# First-load JS is what every visitor pays for before any tool runs: the
# entry plus every chunk the HTML modulepreloads (the registry sits in
# one) and registerSW.js. If a heavy dependency escapes a lazy route
# chunk, this is where we find out rather than in production.
- name: Check first-load JS budget
run: |
BUDGET=92160
ENTRY=$(find dist/assets -name 'index-*.js' -print -quit)
SIZE=$(gzip -c "$ENTRY" | wc -c | tr -d ' ')
echo "entry: $ENTRY"
echo "gzipped: ${SIZE}B budget: ${BUDGET}B"
if [ "$SIZE" -gt "$BUDGET" ]; then
echo "::error::Entry bundle ${SIZE}B exceeds the ${BUDGET}B budget."
BUDGET=107520
LIST="$RUNNER_TEMP/first-load.txt"
grep -oE '(src|href)="/[^"]+\.js"' dist/index.html | sed -E 's/^(src|href)="\///; s/"$//' > "$LIST" || true
if ! grep -qE '^assets/index-[^/]+\.js$' "$LIST"; then
echo "::error::No entry script found in dist/index.html."
exit 1
fi
TOTAL=0
while read -r FILE; do
SIZE=$(gzip -c "dist/$FILE" | wc -c | tr -d ' ')
echo "${SIZE}B $FILE"
TOTAL=$((TOTAL + SIZE))
done < "$LIST"
echo "first-load JS, gzipped: ${TOTAL}B budget: ${BUDGET}B"
if [ "$TOTAL" -gt "$BUDGET" ]; then
echo "::error::First-load JS ${TOTAL}B exceeds the ${BUDGET}B budget."
exit 1
fi
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,18 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and

## [Unreleased]

Nothing yet.
### Added
- JSON Formatter: formatted output has line numbers and a fold gutter on desktop, so any object or array can be collapsed and expanded the way an editor does it, with Fold all (every level below the root) and Unfold all. Inputs over 1 MB and small screens keep the plain text view; on a phone, the Tree view folds.
- JSON Formatter: Generate types opens the current output (filtered, expanded and sorted as shown) in JSON to Types. Valid NDJSON can be sent straight from its banner, one sample per line.

### Changed
- Diff: Sorted lines and Key-sorted JSON are built in the background, so turning them on for a large file no longer freezes the page. While a view is being built the previous one stays on screen (or a short busy message), Copy patch waits for it, and Cancel appears after 2 seconds.
- Base64, URL Encode / Decode and String Escaper stop soft-wrapping a line longer than 100,000 characters, so a multi-MB single line stays editable instead of taking seconds per keystroke.
- JSON Formatter and YAML / JSON / TOML show parse errors in the same card as JSON to Types; the JSON card no longer shows the character offset.

### Fixed
- Diff: a share link pasted into the address bar of a Diff tab that is already open now loads without a reload.
- Diff: a large file with classic Mac line endings (a lone carriage return) is measured line by line instead of being refused as one long line.

## [0.13.0] — 2026-10-30

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ Load-bearing rules from it:
- **A plain `<textarea>` is the default**, via `components/ui/CodeArea`. It is faster on 100k lines than any editor and gives native find, select-all, copy and undo for free. CodeMirror is justified only in diff and JSON.
- **Never run a user-supplied regex on the main thread.** Anything that can go pathological — user regexes, unbounded transforms of pasted input — runs through `lib/worker/spawn.ts` (`useWorkerJob` in components). A backtracking regex never yields, so `terminate()` is the only abort; the job runner supersedes busy workers and offers Cancel after 2s.
- No `manualChunks` in `vite.config.ts`. Splitting follows the registry's dynamic imports; hand-tuning is how CodeMirror lands in the entry bundle.
- Entry bundle budget: **~90 kB gzip.** It is ~83 kB today. If it jumps, something leaked out of a lazy route. The CodeMirror merge view (~92 kB gzip) must stay inside the `diff` route chunk, and `cmdk` inside the lazy `CommandPalette` chunk.
- First-load JS budget: **105 KiB gzip** (107,520 B), enforced in CI over every script `index.html` loads: the entry, its modulepreloaded chunks (the registry among them) and `registerSW.js`. It is ~98 kB today, ~83 kB of it the entry. If it jumps, something leaked out of a lazy route. The CodeMirror merge view (~92 kB gzip) must stay inside the `diff` route chunk, and `cmdk` inside the lazy `CommandPalette` chunk.
- `MergeView` sizing lives in `global.css`, not `EditorView.theme`: the `.cm-mergeView` / `.cm-mergeViewEditors` nodes are outside the editor, and without explicit width/height the two panes collapse toward their content.

### Mobile
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ pnpm build

All four must pass. `pnpm test` includes property tests (`fast-check`); if you touch parsing, encoding or sorting, add one — they have caught real bugs here that example-based tests missed.

Keep the entry bundle under ~90 kB gzip. Heavy dependencies belong inside a lazy route chunk, never the shell.
Keep first-load JS (the entry plus the chunks it preloads) under 105 KiB gzip; CI enforces it. Heavy dependencies belong inside a lazy route chunk, never the shell.

## Scope

Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
"core:build": "pnpm --filter @fadeltd/devtools-core build"
},
"dependencies": {
"@codemirror/lang-json": "6.0.2",
"@codemirror/language": "6.12.4",
"@codemirror/merge": "6.12.2",
"@codemirror/state": "6.7.5",
"@codemirror/view": "6.43.12",
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.
### Fixed
- `/diff`: `longestLineOf`, and so `assessSize`, treat `\r\n`, `\n` and a lone `\r` as line breaks, matching CodeMirror's line splitting. Before, only `\n` counted, so a `\r`-only file was measured as one line and CRLF lines one character too long. Still linear.

## [0.6.0] — 2026-10-30

Expand Down
24 changes: 21 additions & 3 deletions packages/core/src/diff/stats.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,21 @@ describe('longestLineOf', () => {
expect(longestLineOf('\n\n')).toBe(0)
})

it('agrees with splitting on \\n', () => {
it('ends a line at \\r\\n, \\n or a lone \\r, as CodeMirror does', () => {
expect(longestLineOf('abc\r\n')).toBe(3)
expect(longestLineOf('ab\rcd\ref')).toBe(2)
expect(longestLineOf('abc\r')).toBe(3)
// \r\n is one break; \n\r is two.
expect(longestLineOf('a\r\nbb')).toBe(2)
expect(longestLineOf('aaa\n\rb')).toBe(3)
expect(longestLineOf('\r\r\n\n')).toBe(0)
expect(longestLineOf('x\r' + 'y'.repeat(10) + '\r\nz')).toBe(10)
})

it("agrees with splitting on CodeMirror's line separator", () => {
fc.assert(
fc.property(fc.string({ unit: fc.constantFrom('a', 'b', '\n', '\r') }), (s) => {
expect(longestLineOf(s)).toBe(Math.max(...s.split('\n').map((l) => l.length)))
fc.property(fc.string({ unit: fc.constantFrom('a', 'b', '\n', '\r', '\r\n') }), (s) => {
expect(longestLineOf(s)).toBe(Math.max(...s.split(/\r\n?|\n/).map((l) => l.length)))
}),
)
})
Expand All @@ -26,6 +37,13 @@ describe('assessSize thresholds', () => {
expect(assessSize('', 'x'.repeat(MAX_DIFF_LINE_CHARS + 1)).tier).toBe('refuse')
})

it('does not refuse a large file with lone \\r line endings as one long line', () => {
const classicMac = ('x'.repeat(79) + '\r').repeat(40_000)
const v = assessSize(classicMac, classicMac.replaceAll('\r', '\r\n'))
expect(v.tier).toBe('line-only')
expect(v.longestLine).toBe(79)
})

it('refuses a 5 MB single line, the case that hangs CodeMirror', () => {
const v = assessSize('a'.repeat(5_000_000), 'short')
expect(v.tier).toBe('refuse')
Expand Down
32 changes: 28 additions & 4 deletions packages/core/src/diff/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,13 +151,37 @@ export function assessSize(a: string, b: string): SizeVerdict {
return { tier: 'ok', reason: null, longestLine }
}

/** Length of the longest `\n`-separated line, not counting the terminator. */
/**
* Length of the longest line, not counting its terminator.
*
* A line ends at `\r\n`, `\n` or a lone `\r`, which is what CodeMirror splits
* on (`/\r\n?|\n/`), so the verdict matches what the editor will lay out: a
* classic-Mac file of short `\r`-separated lines is many lines, not one.
*
* Two `indexOf` cursors, each re-searched only once the scan passes it, so it
* stays linear. A file with no `\r` pays one failed search for it, and a CRLF
* file roughly two scans instead of one.
*/
export function longestLineOf(text: string): number {
let longest = 0
let start = 0
for (let nl = text.indexOf('\n'); nl !== -1; nl = text.indexOf('\n', start)) {
if (nl - start > longest) longest = nl - start
start = nl + 1
let lf = text.indexOf('\n')
let cr = text.indexOf('\r')
while (lf !== -1 || cr !== -1) {
let end: number
let next: number
if (cr === -1 || (lf !== -1 && lf < cr)) {
end = lf
next = lf + 1
} else {
end = cr
// \r\n is one break, not a break followed by an empty line.
next = lf === cr + 1 ? cr + 2 : cr + 1
}
if (end - start > longest) longest = end - start
start = next
if (lf !== -1 && lf < start) lf = text.indexOf('\n', start)
if (cr !== -1 && cr < start) cr = text.indexOf('\r', start)
}
return Math.max(longest, text.length - start)
}
23 changes: 23 additions & 0 deletions pnpm-lock.yaml

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

5 changes: 3 additions & 2 deletions src/components/ui/IssueCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { AlertTriangle } from 'lucide-react'
export interface Issue {
message: string
line: number | null
column: number
column: number | null
hint: string | null
snippet: {
lines: ReadonlyArray<{ number: number; text: string }>
Expand All @@ -29,7 +29,8 @@ export function IssueCard({ issue }: { issue: Issue }) {
<div className="text-[13px] font-medium wrap-break-word text-fg">{issue.message}</div>
{issue.line !== null && (
<div className="font-mono text-[11px] text-faint">
line {issue.line}, column {issue.column}
line {issue.line}
{issue.column !== null && `, column ${issue.column}`}
</div>
)}
</div>
Expand Down
41 changes: 41 additions & 0 deletions src/lib/codemirror/theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { EditorView } from '@codemirror/view'

/**
* Matches the app's design tokens so an editor does not look bolted on:
* 44px right-aligned line-number gutter on its own darker surface, and
* 12px/18px tabular mono. Shared by the Diff and JSON tools; each adds its
* own rules on top.
*/
export const editorTheme = EditorView.theme(
{
'&': {
backgroundColor: 'var(--color-bg)',
color: 'var(--color-fg)',
height: '100%',
fontSize: '12px',
},
'.cm-scroller': {
fontFamily: 'var(--font-mono)',
fontVariantNumeric: 'tabular-nums',
lineHeight: '18px',
},
'.cm-gutters': {
backgroundColor: 'var(--color-gutter)',
color: 'var(--color-faint)',
border: 'none',
borderRight: '1px solid var(--color-border)',
userSelect: 'none',
},
'.cm-lineNumbers .cm-gutterElement': {
minWidth: '44px',
padding: '0 6px 0 0',
textAlign: 'right',
},
'.cm-activeLine': { backgroundColor: 'rgb(255 255 255 / 0.03)' },
'.cm-activeLineGutter': { backgroundColor: 'transparent', color: 'var(--color-muted)' },
'.cm-content': { caretColor: 'var(--color-accent)' },
'&.cm-focused': { outline: 'none' },
'.cm-selectionBackground, ::selection': { backgroundColor: 'var(--color-accent-dim)' },
},
{ dark: true },
)
2 changes: 1 addition & 1 deletion src/lib/registry/about.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export const ABOUT: Record<ToolSlug, string> = {
'lorem': 'Counts are exact in every unit and format, capped at 50,000 words, 5,000 sentences or 1,000 paragraphs, and the tool says when a cap applies. A seed reproduces the same text, and one seed gives the same words whether the output is plain text, HTML with one <p> per paragraph, or Markdown, which emphasises the odd word but never the first of a sentence. Opening with the classic "Lorem ipsum dolor sit amet" is optional, and a request for only a few words cuts it short without leaving a dangling comma.',
'cron': 'Errors name their field and position, a pasted crontab line with a command on the end is recognised, and Quartz syntax (?, L, W, #) is refused by name instead of half-accepted. When day-of-month and day-of-week are both restricted they are ORed, as Vixie cron does, 7 means Sunday, and six-field schedules run to the second. Next runs handle daylight saving: a fixed-time job in the spring gap moves to the first instant after it and runs once in the autumn overlap, half-hour zones such as Adelaide work, and 30 February is reported as never firing.',
'csv': 'Parsing follows RFC 4180, including quoted fields that hold delimiters, line breaks and doubled quotes, reads LF, CRLF and lone CR alike, and never invents a row from a trailing newline. The delimiter is sniffed among comma, tab, semicolon and pipe, ignoring any inside quotes, and European files with decimal commas resolve to semicolon. Ragged rows are reported with expected and actual cell counts and padded rather than dropped, an unterminated quote is reported at its position, and repeated column names are flagged. JSON output keeps every value as its exact string, and Markdown output escapes pipes and newlines.',
'json-types': 'Every sample is merged into one shape, so an array of objects becomes one element type in which a key missing from some elements is optional and a key that is present but null is nullable; those are kept apart in all three outputs. Integers and floats are told apart by their literal, so 1.0 is a float, and an id past 2^53 is flagged rather than silently typed as a number Zod 4 would reject; in Go it widens to int64, then uint64. Objects with the same structure share one type, and names collide predictably: parent prefix, then a number. Go fields follow golint initialisms (UserID, APIKey), align as gofmt does, and a key a struct tag cannot hold is reported. NDJSON lines are read as samples, comments and trailing commas are accepted, and invalid JSON shows a caret.',
'json-types': 'Every sample is merged into one shape: a key missing from some objects is optional, a key present but null is nullable, and all three outputs keep the two apart. Integers and floats are told apart by their literal, so 1.0 is a float, and an id past 2^53 is flagged rather than typed as a number Zod 4 would reject; in Go it widens to int64, then uint64. Objects with the same structure share one type, and name collisions resolve predictably. Go fields follow golint initialisms (UserID, APIKey) and gofmt alignment, and a key no struct tag can hold is reported. NDJSON lines are read as samples, comments and trailing commas are accepted, and invalid JSON shows a caret.',
'yaml-toml': 'YAML is read as 1.2 unless a document declares %YAML 1.1, so country: no stays a string (the Norway problem), with a note, and YAML output quotes such values so 1.1 readers agree. Anchors, aliases and merge keys are resolved, but an alias bomb is refused rather than expanded, duplicate keys are reported, and a multi-document stream becomes a JSON array. Integers beyond 2^53 stay exact between formats, TOML datetimes become ISO strings with a note, and a null, which TOML cannot hold, is refused with its key path. Parse errors carry a line and a caret.',
'qr': 'Wi-Fi codes escape reserved characters in both the SSID and the password, add H:true only for a hidden network, and warn about a WPA password outside 8 to 63 characters or an SSID over 32 UTF-8 bytes. A bare host gets https:// added, while host:port is not mistaken for a scheme. The quiet zone defaults to 4 modules, with warnings for a thinner one, for version 20 and above, for low contrast, and for an inverted code some scanners cannot read. The SVG is a single path, and the PNG is snapped to whole pixels per module.',
'sql': 'Formatting uses sql-formatter across 12 dialects, from standard SQL and PostgreSQL to BigQuery, T-SQL, Spark and Trino, and a parse or lexer error is reported at its line and column. Minifying is separate and dialect-aware: string literals, quoted identifiers and dollar-quoted bodies are left untouched, backslash escapes are read only where the dialect has them, # starts a comment only in MySQL-like dialects, and BigQuery triple-quoted strings are understood. Comments are kept by default and can be stripped, except optimizer hints and MySQL versioned comments.',
Expand Down
Loading
Loading