Ranger architecture

4 — How a change to the program lands

Written: 2026-08-15 · Established by: reading src/store/file-slice.ts, src/transforms/apply.ts, src/transforms/guard.ts, src/transforms/TransformDialogShell.tsx and src/viewer/useBrushStroke.ts at commit 93a9a67.

Every write to the program is a whole-text replacement followed by a re-parse. There is no incremental update of the model, and no path that edits moves or operations directly. Five very different-looking controls — a cleanup dialog, a check's guided fix, the backplot brush, a click-to-delete, and typing in the editor — all converge on two store actions.

refused

allowed

throws

parses

Cleanup dialogs

Edit array

Guided fix

Backplot brush

applyEdits

refusalReason

Click to delete

loadError set,
nothing changes

applyTransform
remount = true

Typing, debounced

editFullFile
remount = false

replaceFile

tryParse
the whole file

file replaced,
history pushed

Block-count check

Export and diff

Box Where it lives
Cleanup dialogs src/transforms — one *Dialog.tsx per tool
Guided fix src/sidebar/FixDialog.tsx
Backplot brush src/viewer/useBrushStroke.ts
Click to delete deleteBlock in src/store/file-slice.ts
applyEdits src/transforms/apply.ts
refusalReason src/transforms/guard.ts
applyTransform, editFullFile, replaceFile src/store/file-slice.ts
Export and diff src/export

Why the two store actions differ by exactly one flag

replaceFile takes a remount boolean, and it separates a write the user is making from one being made for them. Typing must not bump historyRevision, because remounting the editor mid-keystroke yanks the caret. A transform or a backplot delete must bump it, or the change never appears in the uncontrolled editor at all.

Three properties of applyEdits that are easy to get wrong

Edits are applied back to front, so an earlier edit's replacement cannot shift the line numbers a later edit is expressed in.

Block indices are converted to line indices through GCodeBlock.lineIndex, never used directly. They happen to be equal today, because tokenize maps lines to blocks one to one — and nothing may assume it. A transform splicing the wrong lines is the worst failure this code has available.

The replacement inherits the file's line endings. Every large fixture here is CRLF, and a merged line written with a bare LF would leave a file that is half one and half the other, which some controls read as a stray character rather than as whitespace.

The guard is on the store action, not on the button

refusalReason runs inside applyTransform, so a refusal survives a refactor of the UI — ADR-0005. It takes a TransformKind of 'writes', 'consumes' or 'deletes', and the three are gated differently: subprograms block the first two and not the third, because a deletion has no computed geometry to be silently wrong about in the invocations it did not preview.

What re-parsing costs, and why it is still the design

test-fixtures/README.md puts the three large fixtures at roughly 230–250 ms to parse, and that is paid on every applied transform. Not re-measured for this diagram — treat it as the order of magnitude rather than a current number. It buys one model with one construction path, so a transform cannot leave moves and raw disagreeing. Undo is the same mechanism in reverse: the history stack holds text, not diffs.

Drag to pan · scroll to zoom · Esc to close