steid

@jamesgill /

feat: blame reads as code, the same way the blob does

`ui.md` calls the two views "two readings of one thing" and they already shared
type, leading and row height — colour was the one place they disagreed, because
`highlight.rs` and `blame.rs` shipped on branches that could not see each other.
The code cell now goes through `source_lines`, so the classes, the caps and the
plain fallback are the blob's rather than a second set.

Called **once for the whole file, not once per run**: the highlighter carries parse
state across lines — a block comment opens on one line and closes on another — and
a blamed run is an arbitrary slice, so a run highlighted alone would start
mid-language. The text is reassembled by line number rather than by walking the
groups, and `line_index` is shared by the reassembly and the rows so the colouring
cannot drift from the numbering.

Rows are flattened into a `Row` list before the view, which is what lets the cell
own its markup; nested `for`s could only borrow a local the view outlives. The
commit column, the age tint and the run hairlines are untouched, and rows measure
19.5px in the browser, the same as the blob's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J18ViwAfdswUCMb2DXJZFG
JamesPatrickGill authored 14 hours agoparentfb21638Browse files8daff1cf0e89d40fe74a9bdd05026e79ab552946

2 files changed+120 −36

plans/current.md+11 −6View file
@@ -138,10 +138,14 @@ instance, and Steid's own source is pushed to it and browsable there.
138138 ### Opened by the section 1 wave
139139
140140 - **Highlighting caches nothing.** The same file is re-highlighted on every view at
141 ~87 ms per thousand lines in release. Cache per blob object id when it shows.
141+ ~87 ms per thousand lines in release. Cache per blob object id when it shows. Blame
142+ now pays this too, on top of being the most expensive read in `GitQuery` — the two
143+ costs land on the same page, which is where it will show first.
142144 - **Diffs, READMEs and markdown code fences are unhighlighted.** The README goes through
143145 `web/markdown.rs`, which writes its own `<pre><code>`; wiring `highlight.rs` into it is
144 a small follow-up.
146+ a small follow-up. Blame was the fourth of these and is now done, so the adapter is
147+ reached from two pages and the pattern for a third is set: build the file's text in
148+ line order, call `source_lines` **once**, render `Classed` through `Unescaped`.
145149 - ~~`.jsx` is plain~~ — **aliased onto JavaScript.** `highlight.rs` gained an `ALIASES`
146150 table, consulted only after the syntax set's own answer, so an alias can never
147151 overrule a real grammar. It has one entry and should stay small.
@@ -225,10 +229,11 @@ Two things the pass found, neither a break:
225229 it is the entry point that is missing, and putting it on the tree page means deciding
226230 whether that page gets a sidebar, which [ui.md](ui.md#the-repository-page) settled the
227231 other way.
228- **Blame renders its lines unhighlighted while the blob highlights them.**
229 `highlight.rs` and `blame.rs` shipped on separate branches. `ui.md` says the two views
230 are "two readings of one thing" with the same type and leading, and colour is now the
231 one place they disagree.
232+- ~~Blame renders its lines unhighlighted while the blob highlights them~~ — **closed.**
233+ Blame's code cell now goes through `source_lines`, the blob's own adapter, so the two
234+ views share classes, caps and plain fallback. Both render the same file to an
235+ identical set of `hl-` spans, and rows stay 19.5px — the only variance is at a run's
236+ hairline, which is `border-collapse` splitting that 1px, not the markup.
232237
233238 ### Carried over — small, unblocked
234239
src/infrastructure/web/blame.rs+109 −30View file
@@ -16,12 +16,13 @@ use topcoat::{
1616 context::Cx,
1717 icon::{icon, iconify::iconify_icon},
1818 router::{error::not_found, page, path_param},
19 view::{attributes, component, view},
19+ view::{Unescaped, attributes, component, view},
2020 };
2121
2222 use crate::{
2323 application::{Blame, BlameContent, BlameFile, BlameGroup, blame::AGE_STEPS, blame_file},
2424 domain::{ObjectId, RefName, RepoPath},
25+ infrastructure::highlight::{Source, SourceLine, source_lines},
2526 };
2627
2728 use super::{
@@ -205,12 +206,40 @@ async fn took_too_long(blob: &str) -> Result {
205206 }
206207 }
207208
209+/// One rendered line of the table.
210+struct Row<'a> {
211+ group: &'a BlameGroup,
212+ /// The first line of a run, which is the only one that names its commit.
213+ starts_run: bool,
214+ /// Whether this row opens a run with a hairline — every run but the file's first.
215+ ruled: bool,
216+ /// 1-based, and what the `#L{n}` link points at.
217+ number: usize,
218+ code: SourceLine,
219+}
220+
221+/// Where a line of a run sits in the file, 0-based.
222+///
223+/// One function because the reassembled text and the rendered rows must agree about it;
224+/// two copies of `start_line + offset - 1` is exactly how they would stop agreeing.
225+fn line_index(group: &BlameGroup, offset: usize) -> usize {
226+ (group.start_line + offset).saturating_sub(1)
227+}
228+
208229 /// The blame table: commit, line number, code.
209230 ///
210231 /// A table, and one row per line with the same type and leading as the blob's, so
211232 /// scrolling from one view to the other lands on the same lines in the same places. The
212233 /// commit cell is deliberately **one line tall** — anything taller would make the two
213234 /// views of a file disagree about where line 400 is.
235+///
236+/// The code cell goes through [`source_lines`], the same adapter and therefore the same
237+/// classes, caps and plain fallback as the blob's. It is called **once for the whole
238+/// file, not once per run**: `highlight.rs` carries parse state across lines — a block
239+/// comment opens on one line and closes on another — and a blamed run is an arbitrary
240+/// slice of the file, so highlighting a run on its own would start mid-language.
241+/// Highlighting only ever changes what is inside the cell, so the row height is the
242+/// blob's either way.
214243 #[component]
215244 async fn blame_rows(
216245 handle: &str,
@@ -219,39 +248,89 @@ async fn blame_rows(
219248 blob: &str,
220249 blame: &Blame,
221250 ) -> Result {
251+ // Reassembled by line number rather than by walking the groups in order, so the
252+ // colouring cannot drift from the numbering even if a run ever arrives out of
253+ // sequence. Joined with `\n` and never a trailing one, so the line count is exactly
254+ // `blame.line_count()` — the invariant `source_lines` checks before it trusts its
255+ // own output.
256+ let mut ordered = vec![""; blame.line_count()];
257+ for group in &blame.groups {
258+ for (offset, line) in group.lines.iter().enumerate() {
259+ if let Some(slot) = ordered.get_mut(line_index(group, offset)) {
260+ *slot = line.as_str();
261+ }
262+ }
263+ }
264+
265+ let Source { lines, too_large } =
266+ source_lines(path.file_name().unwrap_or_default(), &ordered.join("\n"));
267+
268+ // Flattened to one row per line before the view rather than nested `for`s inside
269+ // it: the code cell now owns highlighted markup, and a local the view only borrows
270+ // does not outlive it.
271+ let mut rows: Vec<Row> = Vec::new();
272+ let mut code = lines.into_iter();
273+ for (index, group) in blame.groups.iter().enumerate() {
274+ for offset in 0..group.lines.len() {
275+ rows.push(Row {
276+ group,
277+ // The commit is shown once per run, and every run but the first opens
278+ // with a hairline. Repeating the sha down forty adjacent rows is noise;
279+ // saying it once is the whole reason blame is grouped.
280+ starts_run: offset == 0,
281+ ruled: offset == 0 && index > 0,
282+ number: line_index(group, offset) + 1,
283+ // An empty cell has no height, and a blame row must keep the blob's.
284+ code: code
285+ .next()
286+ .unwrap_or_else(|| SourceLine::Plain(" ".to_owned())),
287+ });
288+ }
289+ }
290+
222291 view! {
292+ if too_large {
293+ <p class="border-b border-border px-4 py-1.5 font-mono text-xs text-muted-foreground">
294+ "Too large to highlight — shown as plain text."
295+ </p>
296+ }
297+
223298 <div class="overflow-x-auto">
224299 <table class="w-full border-collapse font-mono text-xs leading-relaxed">
225300 <tbody>
226 for (index, group) in blame.groups.iter().enumerate() {
227 for (offset, line) in group.lines.iter().enumerate() {
228 <tr class=(if offset == 0 && index > 0 {
229 "border-t border-border"
230 } else {
231 ""
232 })>
233 // The age tint rides on the leftmost cell of every row
234 // in the run, so consecutive rows draw one unbroken
235 // edge. See `styles.css`.
236 <td class=(format!(
237 "w-px border-r border-border pl-2 pr-3 align-top blame-age blame-age-{}",
238 group.age.min(AGE_STEPS - 1),
239 ))>
240 if offset == 0 {
241 commit_cell(handle: handle, name: name, path: path, group: group)
242 }
243 </td>
244 <td class="w-px select-none border-r border-border px-3 text-right align-top text-muted-foreground">
245 <a
246 href=(format!("{blob}#L{}", group.start_line + offset))
247 class="hover:text-foreground"
248 >((group.start_line + offset).to_string())</a>
249 </td>
250 <td class="whitespace-pre px-4 align-top">
251 (if line.is_empty() { " " } else { line.as_str() })
252 </td>
253 </tr>
254 }
301+ for row in rows {
302+ <tr class=(if row.ruled { "border-t border-border" } else { "" })>
303+ // The age tint rides on the leftmost cell of every row in
304+ // the run, so consecutive rows draw one unbroken edge. See
305+ // `styles.css`.
306+ <td class=(format!(
307+ "w-px border-r border-border pl-2 pr-3 align-top blame-age blame-age-{}",
308+ row.group.age.min(AGE_STEPS - 1),
309+ ))>
310+ if row.starts_run {
311+ commit_cell(
312+ handle: handle,
313+ name: name,
314+ path: path,
315+ group: row.group,
316+ )
317+ }
318+ </td>
319+ <td class="w-px select-none border-r border-border px-3 text-right align-top text-muted-foreground">
320+ <a
321+ href=(format!("{blob}#L{}", row.number))
322+ class="hover:text-foreground"
323+ >((row.number).to_string())</a>
324+ </td>
325+ <td class="whitespace-pre px-4 align-top">
326+ match row.code {
327+ // Markup this codebase wrote: `highlight` escapes
328+ // the source itself and emits nothing but spans.
329+ SourceLine::Classed(html) => (Unescaped::new_unchecked(html)),
330+ SourceLine::Plain(text) => (text),
331+ }
332+ </td>
333+ </tr>
255334 }
256335 </tbody>
257336 </table>