Skip to content

Support raw HTML <br> line breaks in the Markdown renderer#13870

Open
fbartho wants to merge 9 commits into
warpdotdev:masterfrom
fbartho:fb/13732-markdown-br-implementation-v2
Open

Support raw HTML <br> line breaks in the Markdown renderer#13870
fbartho wants to merge 9 commits into
warpdotdev:masterfrom
fbartho:fb/13732-markdown-br-implementation-v2

Conversation

@fbartho

@fbartho fbartho commented Jul 16, 2026

Copy link
Copy Markdown

Closes #13732

The Markdown viewer rendered a raw <br> as literal text, both mid-paragraph and inside GFM pipe-table cells. Inside a pipe-table cell <br> is the only way to author a forced line break at all, so this was the more painful half of the gap.

This teaches the .md inline parser to recognize <br>, <br/>, and <br /> (case-insensitive) and turn them into a real line break, in ordinary paragraphs and inside GFM table cells.

Notes:

  • The break is stored as a newline fragment; the render path already turns an embedded newline into a visual break in both paragraphs and cells, so no rendering code changed. The table serialization paths translate the break back to <br> so an in-cell break does not corrupt row/column structure on round-trip.
  • Attributes (<br class="x">) and malformed forms (<br without >, < br>, </br>) fall through to literal text.
  • Markdown-native hard breaks (trailing double-space / backslash) are unchanged and out of scope.

Tested: new unit tests in markdown_parser pass, clippy and rustfmt clean, and exercised in a local dev build against the issue sample document — paragraphs, consecutive breaks, self-closing/case variants, table cells, and malformed forms all behave as described.

Human-focused Test Document + Screenshot
# `<br>` line breaks (paragraphs and GFM pipe-table cells)

Each `<br>` below should render as a real line break. Malformed forms stay literal.

Mid-paragraph:

First line of the paragraph.<br>
Second line, after a raw `<br>`.

Consecutive breaks (should produce blank lines):

Before the gap.<br><br><br>After a triple `<br>`.

Self-closing and case variants (each breaks):

alpha<br/>bravo<br />charlie<BR>delta

Inside a GFM pipe-table cell (two lines, table intact):

| Feature | Notes                                |
| ------- | ------------------------------------ |
| Export  | Supports CSV.<br>Also supports JSON. |

Trailing `<br>` at the end of a cell (should add a trailing empty line inside the cell, matching GitHub):

| Feature | Notes                  |
| ------- | ---------------------- |
| Import  | Ends with a break.<br> |

Malformed / attributed forms (each should stay literal text, no break):

one</br>two &lt;br three<br class="x">four

Control (unrelated tags unaffected): a <kbd>K</kbd> stays literal.
Screenshot of the above test document

fbartho added 9 commits July 16, 2026 11:11
…v#13732)

Warp's Markdown viewer rendered a raw <br> as literal text in both
paragraphs and GFM pipe-table cells. Recognize <br>, <br/>, and <br />
(case-insensitive) as an inline token that produces a forced line break.

The break is stored as a newline fragment: both the paragraph and
table-cell render paths already turn an embedded newline into a real
visual break, so no rendering changes are needed. The newline/tab/pipe
delimited serialization paths (inline_to_markdown, to_plain_text)
translate the break back to <br> so an in-cell break does not corrupt
table row/column structure.

Attributes (e.g. <br class="x">) and malformed forms (<br with no >,
< br>, </br>) deterministically fall through to literal text.
A raw HTML <br> at the end of a GFM pipe-table cell parses to a trailing
"\n" fragment, but layout_run strips a trailing newline out of the cell
text so the break was silently dropped — the cell collapsed to a single
line instead of rendering the trailing empty line GitHub shows.

measure_table_cells now honors layout_run's has_new_line return: when the
final run ends in a stripped newline, re-append it to the cell text so both
the measure pass and the render pass emit the trailing empty line. Embedded
breaks (interior newline) were never stripped and are unchanged.
The editor's table export path (BufferMarkdownParser::serialize_table_to_gfm_markdown)
uses a local inline_to_markdown that emitted fragment text unchanged, so an in-cell
hard break (raw HTML <br>, stored as an embedded newline) was written as a raw newline
that split the pipe-table row. Apply the same newline-to-<br> conversion the
markdown_parser serializer already performs, guarded on non-inline-code fragments.
TableCellOffsetMap::from_inline_and_source walked source char-by-char against rendered text, assuming a 1:1 correspondence. A raw HTML <br> is 4+ source chars but a single rendered newline, so the walk desynced: offsets after an in-cell break mapped past the break instead of to their true source positions, corrupting cursor rects, selection highlights, and clipboard slices for post-break text. Walk each fragment in newline-delimited segments, emitting one linear range per segment and consuming the <br>/<br/>/<br /> token as a source gap between segments.
serialize_table_cell_inline wrote a cell fragment's text verbatim, so an authored hard break (stored as an embedded newline) landed as a raw newline inside <td>. HTML collapses that to a space, silently dropping the in-cell break when copying a table as HTML. Split the fragment on newlines and emit a <br> element between the parts, mirroring the sibling guard in inline_to_markdown (ca6a4dd). Inline code is left verbatim since it can never contain such a newline.
clipboard_table_text_in_range flattened each cell with inline_to_text, which emits an authored hard break (stored as an embedded newline) as a raw newline. Inside the tab/newline-delimited clipboard format that raw newline splits the row, so pasting a copied table back into Warp re-parsed a spurious extra row. Translate the break back to a literal <br> when building each cell slice, scoped to the table-cell context rather than changing inline_to_text globally (other callers may legitimately want the newline).
The parser had no coverage combining <br> with inline style delimiters. Guard that a break inside bold, underline, or a link keeps the surrounding style on both sides of the embedded newline.
The plain-text clipboard flavor is rendered text and already strips styling, so an authored hard break must emit a real newline rather than the literal HTML string "<br>" the user never selected. The HTML flavor still carries the break as a real <br> element for Warp-to-Warp round-trip fidelity; the accepted cost is that a plain-text-only re-paste of such a row may split it, a standard TSV-class limitation.
@github-actions github-actions Bot added the external-contributor Indicates that a PR has been opened by someone outside the Warp team. label Jul 16, 2026
@fbartho

fbartho commented Jul 16, 2026

Copy link
Copy Markdown
Author

Feedback from original PR:

This PR continues #13854 (same branch history; that PR exhausted its automated-review budget). All feedback received there, and everything a deeper self-review pass surfaced, is accounted for below.

Source Issue Resolution
Oz round 1 (inline) Editor's GFM table export uses a local inline_to_markdown that wrote raw newlines into pipe cells on copy/export, splitting rows Same newline→<br> guard applied in the editor export path, with a round-trip test (ca6a4dd)
Oz round 2 (inline), re-flagged in round 3 before the fix landed Table cells dropped a <br> at a style boundary — only the final run's stripped trailing newline was preserved Per-run newline reinsertion in measure_table_cells, with style-boundary tests (993de7b)
Oz round 1 (overview) No spec context provided #13732 is labeled ready-to-implement (direct code PR per CONTRIBUTING's readiness labels); round 2 recorded "no spec-drift finding"
self-discovered Table-cell offset map assumed one source char per rendered char, so cursor, selection highlight, and clipboard slices mapped wrong after an in-cell break Segment-wise walk that treats the <br> token as a source gap, with offset-mapping tests (0d4b4eb)
self-discovered Copy-as-HTML wrote the break as a raw newline inside <td>, which HTML collapses to a space Emit a real <br> element, with an HTML serialization test (538c961)
self-discovered Plain-text table copy emitted a raw newline inside a tab-delimited row, so re-pasting split the row Flavor split by design: the HTML clipboard flavor carries a <br> element (Warp-to-Warp paste round-trips); the plain-text flavor keeps a real newline rather than injecting HTML; trade-off documented in-test (716e569 + 6b3ea8d)
self-discovered No test coverage for <br> combined with style delimiters (bold/underline/links) Regression guards added; styles carry across the break (5b346de)
self-discovered Hypothesis: a styled newline fragment might paint a stray underline/bold sliver on macOS CoreText Manually verified in a local dev build — no artifacts at break points; no code change needed
self-discovered The new (not-yet-wired) TUI table renderer counts an embedded newline into column width and would misalign a multi-line cell ❌ out-of-scope: that renderer is currently test-only and unwired; flagging it for whoever wires it up
self-discovered A cell that acquired a raw newline from a non-<br> source (IME, multi-line paste) would canonicalize to <br> on first serialization ❌ not addressed: theoretical corner — any path that stored a raw newline in a cell already corrupted the row format before this PR; the guards narrow that hole rather than widen it

@fbartho

fbartho commented Jul 17, 2026

Copy link
Copy Markdown
Author

@cla-bot check

@cla-bot cla-bot Bot added the cla-signed label Jul 17, 2026
@cla-bot

cla-bot Bot commented Jul 17, 2026

Copy link
Copy Markdown

The cla-bot has been summoned, and re-checked this pull request!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla-signed external-contributor Indicates that a PR has been opened by someone outside the Warp team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Markdown viewer: support <br> for explicit line breaks

1 participant