Raxol. Harness. DiffExpansion
(Raxol v2.6.1)
View Source
Pure scrollable window over a full-screen diff render: the state behind the harness surface's "expand the focused diff block" feature.
This module owns none of the terminal bytes -- it renders a :diff
content map into a plain list of sanitized, width-truncated, styled
lines exactly once, then exposes a clamped scroll offset and a
header-plus-slice render over that list. Raxol.Harness.Surface is the
only caller that turns this into terminal bytes (via InlineAuthority);
this module never touches an IO.device(), a process, or ANSI beyond
the SGR it wraps its own lines in.
Rendering: one line per LineDiff op, not the Component view tree
This renders DIRECTLY from Raxol.UI.Components.Harness.LineDiff.diff/2
-- one styled line per {:equal | :delete | :insert, line} op --
rather than through DiffViewer.render/2 piped into
Raxol.Harness.Surface.ViewText.lines/3 (the seam every other harness
Component's rendered body uses, and this module's own first
implementation used too). That pipeline was tried and rejected, for a
concrete, measured reason: DiffViewer.render/2 composes each visual
row from SEVERAL side-by-side leaf nodes (a gutter bar, a line number,
a content span, alignment padding) meant to be laid out horizontally by
the normal Preparer -> LayoutEngine -> UIRenderer pipeline.
ViewText.lines/3 has no notion that those nodes share one physical
row -- it flattens every leaf into its OWN line, the same as it does
for the genuinely one-text-child-per-line bodies every other harness
Component renders (MessageBlock, ReasoningBlock, ToolCallBlock).
Measured against a real 20-line diff at 40 columns, that pipeline
produced ~170 flattened lines for what should have been ~21 rows --
breaking the row math scroll/2's clamp and the surface's
view_rows claim both depend on. Worse, ViewText.style_line/2 has no
:background handling at all (it only ever wraps :bold/:dim/:fg),
so even where the flatten produced a usable line, the diff's own
add/del row washes -- the merged visual language's whole point -- were
silently dropped. Rendering one line directly per op, styled here, is
what actually satisfies both "one row per diff line" and "the diff's
own styling is preserved."
What this module actually is
A plain map (t()), not a process or a Component:
content-- the:diffcontent map this expansion was built from (%{path, old, new, language}), kept soresize_view/3can re-render at a new geometry without the caller re-supplying it.:languageis accepted (part of the shared:diffcontent schema) and currently unused here -- see "Scope" below.lines-- the full render: one already-sanitized, width-truncated, styled binary perLineDiff.diff/2op, in document order. There is no folding of long unchanged runs -- a full-screen review surface exists precisely to show everything (DiffViewer's own folding is a property of ITS renderer, never reached here).total--length(lines), which is exactlylength(LineDiff.diff(old, new)).offset-- the firstlinesindex visible in the current window, clamped byscroll/2to0..max(0, total - view_rows).view_rows/width-- the current viewport geometry.
Per-row rendering (sanitize, then truncate, then pad, then style)
Each op's raw line content goes through, IN ORDER:
- Sanitize --
Raxol.Harness.Surface.ViewText.sanitize_line/1, the SAME trust-boundary byte-strip every harness Component's content passes through (ESC/C0 stripped except\t,\texcepted, multi-byte UTF-8 safe). This is content from an untrusted source (an agent-produced diff) -- sanitizing FIRST means styling (step 4) only ever wraps already-safe bytes. - Truncate --
ViewText.truncate/2towidth - 2display columns (2 reserved for the 1-column gutter glyph plus 1 column of gutter chrome), CJK-safe (Raxol.UI.TextMeasure, neverString.length), ellipsis-truncated when it would overflow. - Pad (changed rows only) -- an
:insert/:deleterow's content is right-padded with spaces to fill the fullwidth - 2budget, so the row's background wash paints the WHOLE row, not just the columns under the text.:equalrows are never padded (no wash to spread, and padding them would waste width truncating real content earlier for no visual benefit). - Style --
:insertrows get the▌gutter bar plus the merged palette'sadd_base/add_row_bg(DiffViewer.diff_palette/0-- the single source of these hexes, shared with the grid renderer, never a forked copy);:deleterows getdel_base/del_row_bg. Both wrap the WHOLE composed row (gutter + gap + content) in one 24-bit SGR run (\e[38;2;r;g;b;48;2;r;g;bm, fg then bg) and a single\e[0mreset at the end -- one run, not a mid-row reset, since\e[0mclears background too and a second reset partway through the row would cut the wash off early.:equalrows get a plain space gutter and carry no SGR at all -- unstyled content, exactly like every other unchanged line in this codebase's diff conventions.
Scope: this is the row-level tier of the diff visual language
DiffViewer's moduledoc describes several composed tiers: a row wash,
a brighter intra-line word-diff emphasis tier on top of it, a gutter
tint, and syntax-highlighted tokens. This line renderer carries only
the ROW tier -- gutter bar plus full-row wash, read from the exact same
DiffViewer.diff_palette/0 hexes. Word-level emphasis (which parts of
a changed line actually differ) and syntax highlighting are
DiffViewer's own GRID-rendered tiers (built from several styled
spans per row, the very composition this module deliberately does not
reach for -- see "Rendering" above) and are out of scope for a
single-binary-per-row line renderer. language in the content map is
accepted, unused.
The header
render_lines/1 is one header line (the file path under review, the
scroll position, and the dismiss/scroll hints) followed by exactly the
visible slice of lines. The path is there because full-screen review
hides the transcript block that named it -- "which file am I
reviewing" is supervision context the expanded view must carry itself.
The path is the ELASTIC element: sanitized (producer data) and
truncated to whatever display-width budget the fixed position/hint
suffix leaves, so a pathological path can never truncate away the
scroll position or the dismiss hint. The composed header is then run
through Raxol.Harness.Surface.ViewText.lines/3 itself -- the same
width-truncation budget every content line already obeys, so a narrow
terminal truncates the header exactly like it would any other row,
never overflowing it.
Why this maximizes the footer instead of visiting the alternate screen
The obvious "full-screen" mechanism would be a bracketed alt-screen
visit ([?1049h ... [?1049l) around the expanded diff, exactly
like a suspended external editor. That was considered and rejected, for
reasons worth naming honestly rather than assuming away:
- The inline driver profile's headline invariant is LC-P-NOALT: no
[?1049hbyte anywhere on this rendering path (seeRaxol.Terminal.InlineDriverand itsSequencesmoduledoc). Even the editor-suspend bracket -- the one place this codebase DOES hand the terminal to another process -- deliberately releases the DECSTBM region instead of visiting the alternate screen. An alt-screen diff expansion would be a different terminal contract than every other surface this harness renders through, not a variant of the same one. - No alt-screen capability detection exists anywhere in this
codebase, and
[?1049hsupport is not reliably probeable from inside a running session -- there is no honest fallback path for a terminal that silently ignores or mishandles the sequence. - The seal oracle's mechanical byte-walk (the strongest verification
tool this lane has for "history was never touched") classifies
?1049/?47mode switches as unverifiable control tokens -- an alt-screen bracket would blind exactly the tool that has to prove the sealed-history invariant holds, at the one moment (a full-viewport takeover) where that proof matters most. - An honest alt-screen visit needs editor-suspend-grade compensation machinery: a crash mid-visit stranding the terminal in the alt buffer is a real failure mode that has to be planned for. Growing the footer instead means a crash mid-expansion just leaves a bigger pinned footer -- the existing resize/teardown paths already recover that, with no new compensation code.
The mechanism this module renders FOR is instead footer-region
maximization: Raxol.Harness.Surface.expand_focused_diff/1 grows the
DECSTBM footer (via the same InlineAuthority.set_footer_rows/2 the
overlay picker already uses) to the largest claim that still leaves
history its 2-row minimum, and this module supplies the scrollable
content that fills it.
Honest cost of that choice, stated plainly: "full-screen" is approximate (history never shrinks below 2 rows, so a sliver of scrollback stays visible above the expansion), growing over occupied history rows scrolls them into the terminal's native scrollback earlier than ordinary streaming would have (the content is unchanged -- the seal oracle's scrollback-plus-on-screen-history concatenation is invariant under that earlier scroll -- but it happens sooner), and dismissing the expansion does not restore whatever scroll position the operator had before expanding.
Summary
Functions
Builds a new expansion from a :diff content map (%{path, old, new, language}).
Renders the current window: one header line (the file path under
review + scroll position + dismiss hint) followed by exactly
Enum.slice(t.lines, t.offset, t.view_rows).
Re-renders the SAME content at a new width/view_rows (a resize),
clamping the current offset to the new geometry's window. Refuses
exactly like new/2 when the requested geometry is degenerate,
leaving t untouched.
Scrolls by delta rows (negative scrolls up), clamped to
0..max(0, total - view_rows).
Types
@type t() :: %{ content: map(), lines: [String.t()], total: non_neg_integer(), offset: non_neg_integer(), view_rows: pos_integer(), width: pos_integer() }
Functions
Builds a new expansion from a :diff content map (%{path, old, new, language}).
Options
:width(required) -- display-width budget. Floored at@gutter_width + 1(3): every rendered body row prepends a fixed@gutter_width-column gutter ("▌" <> " ", or two spaces for an:equalrow), so a narrower budget can never fit even a zero-content row -- the gutter alone would overflow the column count and wrap onto the next row (whichInlineAuthority.repaint/2does NOT re-truncate). Below the floor there is also no content column left to show, so a sub-floor width is degenerate twice over.:view_rows(required) -- visible content rows (excluding the header),>= 1.
Refuses with {:error, :degenerate_view} when view_rows is missing
or < 1, or when width is missing or below the gutter floor
(< @gutter_width + 1) -- checked BEFORE content validation, since a
degenerate viewport can never render anything regardless of content.
Refuses with {:error, {:invalid_content, reason}} when content is
missing a required :diff key (Raxol.UI.Components.Harness.BodyProvider.validate/2,
the same schema check BlockBody uses for every other kind).
Renders the current window: one header line (the file path under
review + scroll position + dismiss hint) followed by exactly
Enum.slice(t.lines, t.offset, t.view_rows).
The path is in the header because full-screen review HIDES the transcript block that named it -- "which file am I reviewing" is supervision context the expanded view must carry itself. The path is the ELASTIC element: it is sanitized (producer data -- the same trust boundary every content line goes through) and truncated to whatever display-width budget the fixed position/hint suffix leaves, so a pathological path can never truncate away the scroll position or the dismiss hint.
@spec resize_view(t(), pos_integer(), pos_integer()) :: {:ok, t()} | {:error, :degenerate_view}
Re-renders the SAME content at a new width/view_rows (a resize),
clamping the current offset to the new geometry's window. Refuses
exactly like new/2 when the requested geometry is degenerate,
leaving t untouched.
Scrolls by delta rows (negative scrolls up), clamped to
0..max(0, total - view_rows).