1use std::collections::{HashMap, HashSet};
37use std::ops::Range;
38use std::sync::Arc;
39
40use ashlar_diff::{FileDiff, FileStatus, Gap, LineKind};
41use ashlar_syntax::{Builtin, Highlighter, Kind, State};
42use egui::accesskit::Role as NodeRole;
43use egui::text::{ByteIndex, LayoutJob, LayoutSection, TextWrapping};
44use egui::{Color32, FontId, Galley, Key, Rect, Sense, TextFormat, Ui, pos2, vec2};
45
46use crate::automation::{self, Details, Role};
47use crate::cache::{self, hash_of};
48use crate::code_view::SyntaxTheme;
49use crate::hscroll::HScroll;
50use crate::selection::Selection;
51use crate::tokens::{self, Tokens};
52use crate::virtual_list::{self, Header, MAX_ROWS, RowHeights, VirtualList};
53
54pub const EXPAND_STEP: u32 = 20;
56
57const MAX_LINE_BYTES: usize = 2000;
59
60pub const MAX_SELECTED_LINES: usize = 100_000;
62
63const MAX_STATE_WALK: usize = 1_000_000;
65
66const NONE: u32 = u32::MAX;
67
68#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
70pub enum DiffMode {
71 #[default]
73 Unified,
74 Split,
76}
77
78#[derive(Clone, Debug, PartialEq, Eq)]
80pub struct DiffLabels {
81 pub expand: String,
83 pub hidden: String,
85 pub binary: String,
87 pub added: String,
89 pub removed: String,
90}
91
92impl Default for DiffLabels {
93 fn default() -> Self {
94 DiffLabels {
95 expand: "Expand {n} lines".into(),
96 hidden: "{n} unchanged lines".into(),
97 binary: "Binary file not shown".into(),
98 added: "added".into(),
99 removed: "removed".into(),
100 }
101 }
102}
103
104#[derive(Clone, Debug, Default)]
106pub struct DiffViewState {
107 pub selection: Selection,
109 expanded: HashMap<(usize, usize), u32>,
110 collapsed: HashSet<usize>,
111 version: u64,
113 revision: Option<u64>,
114}
115
116impl DiffViewState {
117 pub fn new() -> Self {
118 Self::default()
119 }
120
121 pub fn expand(&mut self, file: usize, gap: usize, lines: u32) {
123 let e = self.expanded.entry((file, gap)).or_insert(0);
124 *e = e.saturating_add(lines);
125 self.version = self.version.wrapping_add(1);
126 }
127
128 pub fn expanded(&self, file: usize, gap: usize) -> u32 {
130 self.expanded.get(&(file, gap)).copied().unwrap_or(0)
131 }
132
133 pub fn set_file_collapsed(&mut self, file: usize, collapsed: bool) {
135 let changed = if collapsed { self.collapsed.insert(file) } else { self.collapsed.remove(&file) };
136 if changed {
137 self.version = self.version.wrapping_add(1);
138 }
139 }
140
141 pub fn is_file_collapsed(&self, file: usize) -> bool {
142 self.collapsed.contains(&file)
143 }
144}
145
146#[derive(Clone, Copy, Debug, PartialEq, Eq)]
148pub struct SelectedLine {
149 pub file: usize,
150 pub kind: LineKind,
151 pub old_no: Option<u32>,
152 pub new_no: Option<u32>,
153}
154
155pub struct DiffResponse {
157 pub response: egui::Response,
158 pub selected_lines: Vec<SelectedLine>,
161 pub expanded: Option<(usize, usize)>,
163 pub toggled_file: Option<usize>,
165 pub hunk: Option<(usize, usize)>,
167 pub top_file: Option<usize>,
169 pub visible: Range<usize>,
171}
172
173pub struct DiffView<'a> {
175 id: &'a str,
176 state: &'a mut DiffViewState,
177 files: &'a [FileDiff],
178 revision: u64,
179 mode: DiffMode,
180 theme: Option<&'a SyntaxTheme>,
181 labels: Option<&'a DiffLabels>,
182 highlighter: &'a dyn Highlighter,
183 max_height: Option<f32>,
184}
185
186impl<'a> DiffView<'a> {
187 pub fn new(id: &'a str, state: &'a mut DiffViewState, files: &'a [FileDiff], revision: u64) -> Self {
190 DiffView { id, state, files, revision, mode: DiffMode::Unified, theme: None, labels: None, highlighter: &Builtin, max_height: None }
191 }
192
193 pub fn mode(mut self, mode: DiffMode) -> Self {
194 self.mode = mode;
195 self
196 }
197
198 pub fn theme(mut self, theme: &'a SyntaxTheme) -> Self {
200 self.theme = Some(theme);
201 self
202 }
203
204 pub fn labels(mut self, labels: &'a DiffLabels) -> Self {
205 self.labels = Some(labels);
206 self
207 }
208
209 pub fn highlighter(mut self, h: &'a dyn Highlighter) -> Self {
211 self.highlighter = h;
212 self
213 }
214
215 pub fn max_height(mut self, h: f32) -> Self {
217 self.max_height = Some(h);
218 self
219 }
220
221 pub fn show(self, ui: &mut Ui) -> DiffResponse {
222 show(self, ui)
223 }
224}
225
226#[derive(Clone, Copy, Debug, PartialEq, Eq)]
227enum RowKind {
228 File,
229 Note,
231 Hunk,
232 Gap,
233 Ctx,
235 Line,
236}
237
238#[derive(Clone, Copy, Debug)]
242struct RowRef {
243 kind: RowKind,
244 file: u32,
245 item: u32,
246 a: u32,
247 b: u32,
248}
249
250#[derive(Clone, Default)]
251struct Model {
252 key: u64,
253 rows: Vec<RowRef>,
254 hunk_rows: Vec<usize>,
256 states: Vec<Option<(State, State)>>,
258 gaps: Vec<Vec<Gap>>,
259 langs: Vec<&'static str>,
260 stats: Vec<(usize, usize)>,
262 digits: usize,
264 widest: usize,
266}
267
268fn to_u32(x: usize) -> u32 {
269 u32::try_from(x).unwrap_or(NONE)
270}
271
272impl Model {
273 fn build(key: u64, files: &[FileDiff], state: &DiffViewState, mode: DiffMode) -> Model {
274 let mut m = Model { key, ..Model::default() };
275 let mut max_no = 0u32;
276 for (fi, f) in files.iter().enumerate() {
277 if m.rows.len() >= MAX_ROWS {
278 break;
279 }
280 let file = to_u32(fi);
281 m.rows.push(RowRef { kind: RowKind::File, file, item: 0, a: 0, b: 0 });
282 m.langs.push(ashlar_syntax::lang_for_path(f.path()).unwrap_or(""));
283 m.stats.push(f.stats());
284 let gaps = f.gaps();
285 if let Some(h) = f.hunks.last() {
286 max_no = max_no.max(h.old_range().end).max(h.new_range().end);
287 }
288 max_no = max_no.max(f.source_len().unwrap_or(0));
289 if state.collapsed.contains(&fi) {
290 m.gaps.push(gaps);
291 continue;
292 }
293 if f.binary {
294 m.rows.push(RowRef { kind: RowKind::Note, file, item: 0, a: 0, b: 0 });
295 }
296 let mut gi = 0usize;
297 for (hi, h) in f.hunks.iter().enumerate() {
298 while let Some(g) = gaps.get(gi).filter(|g| g.before_hunk <= hi) {
299 m.push_gap(file, gi, g, state.expanded(fi, gi));
300 gi += 1;
301 }
302 if m.rows.len() >= MAX_ROWS {
303 break;
304 }
305 m.hunk_rows.push(m.rows.len());
306 let item = to_u32(hi);
307 m.rows.push(RowRef { kind: RowKind::Hunk, file, item, a: 0, b: 0 });
308 let line = |a: usize, b: Option<usize>| RowRef { kind: RowKind::Line, file, item, a: to_u32(a), b: b.map_or(NONE, to_u32) };
309 let lines = &h.lines;
310 m.widest = lines.iter().map(|l| l.text.len()).fold(m.widest, usize::max);
311 let kind = |i: usize| lines.get(i).map(|l| l.kind);
312 let mut i = 0usize;
313 while i < lines.len() && m.rows.len() < MAX_ROWS {
314 match (mode, kind(i)) {
315 (DiffMode::Unified, _) => {
316 m.rows.push(line(i, None));
317 i += 1;
318 }
319 (DiffMode::Split, Some(LineKind::Context)) => {
320 m.rows.push(line(i, Some(i)));
321 i += 1;
322 }
323 (DiffMode::Split, _) => {
324 let removed = (i..lines.len()).take_while(|&k| kind(k) == Some(LineKind::Removed)).count();
325 let added = (i + removed..lines.len()).take_while(|&k| kind(k) == Some(LineKind::Added)).count();
326 for k in 0..removed.max(added) {
327 let a = if k < removed { to_u32(i + k) } else { NONE };
328 let b = if k < added { to_u32(i + removed + k) } else { NONE };
329 m.rows.push(RowRef { kind: RowKind::Line, file, item, a, b });
330 }
331 i += (removed + added).max(1);
332 }
333 }
334 }
335 }
336 while let Some(g) = gaps.get(gi) {
337 m.push_gap(file, gi, g, state.expanded(fi, gi));
338 gi += 1;
339 }
340 m.gaps.push(gaps);
341 }
342 m.rows.truncate(MAX_ROWS);
343 m.states = vec![None; m.rows.len()];
344 m.digits = max_no.to_string().len().max(3);
345 m
346 }
347
348 fn push_gap(&mut self, file: u32, gi: usize, g: &Gap, revealed: u32) {
349 let shown = if g.expandable { revealed.min(g.len) } else { 0 };
350 let item = to_u32(gi);
351 for k in 0..shown {
352 if self.rows.len() >= MAX_ROWS {
353 return;
354 }
355 let (a, b) = (g.new_start.saturating_add(k), g.old_start.saturating_add(k));
356 self.rows.push(RowRef { kind: RowKind::Ctx, file, item, a, b });
357 }
358 if shown < g.len {
359 self.rows.push(RowRef { kind: RowKind::Gap, file, item, a: g.len - shown, b: 0 });
360 }
361 }
362
363 fn segment_start(&self, j: usize) -> bool {
365 let (Some(cur), Some(prev)) = (self.rows.get(j), j.checked_sub(1).and_then(|p| self.rows.get(p))) else { return true };
366 prev.kind != cur.kind || prev.file != cur.file || prev.item != cur.item
367 }
368
369 fn state(
371 &mut self,
372 files: &[FileDiff],
373 mode: DiffMode,
374 hl: &dyn Highlighter,
375 i: usize,
376 scratch: &mut Vec<ashlar_syntax::Span>,
377 ) -> (State, State) {
378 if let Some(s) = self.states.get(i).copied().flatten() {
379 return s;
380 }
381 let mut j = i;
382 let mut walked = 0usize;
383 while walked < MAX_STATE_WALK && !self.segment_start(j) && self.states.get(j).copied().flatten().is_none() {
384 j -= 1;
385 walked += 1;
386 }
387 let mut st = self.states.get(j).copied().flatten().unwrap_or_default();
388 let lang = self.rows.get(i).and_then(|r| self.langs.get(r.file as usize)).copied().unwrap_or("");
389 for r in j..i {
390 if let Some(s) = self.states.get_mut(r) {
391 *s = Some(st);
392 }
393 let (old, new) = self.rows.get(r).map_or((None, None), |row| texts(files, mode, row));
394 if let Some(t) = old {
395 scratch.clear();
396 st.0 = hl.highlight_line(lang, display(t), st.0, scratch);
397 }
398 if let Some(t) = new {
399 scratch.clear();
400 st.1 = hl.highlight_line(lang, display(t), st.1, scratch);
401 }
402 }
403 if let Some(s) = self.states.get_mut(i) {
404 *s = Some(st);
405 }
406 st
407 }
408}
409
410fn hunk_line<'f>(files: &'f [FileDiff], row: &RowRef, idx: u32) -> Option<&'f ashlar_diff::Line> {
411 if idx == NONE {
412 return None;
413 }
414 files.get(row.file as usize)?.hunks.get(row.item as usize)?.lines.get(idx as usize)
415}
416
417fn texts<'f>(files: &'f [FileDiff], mode: DiffMode, row: &RowRef) -> (Option<&'f str>, Option<&'f str>) {
419 match row.kind {
420 RowKind::Ctx => {
421 let t = files.get(row.file as usize).and_then(|f| f.source_line(row.a));
422 (t, t)
423 }
424 RowKind::Line => match mode {
425 DiffMode::Unified => match hunk_line(files, row, row.a) {
426 Some(l) => {
427 let t = Some(l.text.as_str());
428 match l.kind {
429 LineKind::Context => (t, t),
430 LineKind::Removed => (t, None),
431 LineKind::Added => (None, t),
432 }
433 }
434 None => (None, None),
435 },
436 DiffMode::Split => (hunk_line(files, row, row.a).map(|l| l.text.as_str()), hunk_line(files, row, row.b).map(|l| l.text.as_str())),
437 },
438 _ => (None, None),
439 }
440}
441
442fn display(text: &str) -> &str {
444 let t = text.strip_suffix('\r').unwrap_or(text);
445 if t.len() <= MAX_LINE_BYTES {
446 return t;
447 }
448 let mut end = MAX_LINE_BYTES;
449 while end > 0 && !t.is_char_boundary(end) {
450 end -= 1;
451 }
452 t.get(..end).unwrap_or("")
453}
454
455fn clip_label(s: &str) -> &str {
456 let s = s.trim_end_matches('\r');
457 match s.char_indices().nth(160) {
458 Some((i, _)) => s.get(..i).unwrap_or(s),
459 None => s,
460 }
461}
462
463fn line_job(text: &str, spans: &[ashlar_syntax::Span], emphasis: &[Range<usize>], font: &FontId, theme: &SyntaxTheme, emph_bg: Color32) -> LayoutJob {
465 let mut sections = Vec::with_capacity(spans.len() + 2 * emphasis.len());
466 let mut e = emphasis.iter().peekable();
467 for (r, kind) in spans {
468 let color = theme.color(*kind);
469 let end = r.end.min(text.len());
470 let mut pos = r.start.min(end);
471 let mut guard = 0usize;
472 while pos < end && guard < 4 * (emphasis.len() + 1) {
473 guard += 1;
474 while e.peek().is_some_and(|x| x.end <= pos) {
475 e.next();
476 }
477 let (next, bg) = match e.peek() {
478 Some(x) if x.start <= pos => (x.end.min(end), emph_bg),
479 Some(x) => (x.start.min(end), Color32::TRANSPARENT),
480 None => (end, Color32::TRANSPARENT),
481 };
482 let next = if next <= pos || !text.is_char_boundary(next) { end } else { next };
483 sections.push(LayoutSection {
484 leading_space: 0.0,
485 byte_range: ByteIndex(pos)..ByteIndex(next),
486 format: TextFormat { font_id: font.clone(), color, background: bg, ..Default::default() },
487 });
488 pos = next;
489 }
490 }
491 LayoutJob {
492 text: text.to_owned(),
493 sections,
494 wrap: TextWrapping { max_width: f32::INFINITY, ..Default::default() },
495 break_on_newline: false,
496 ..Default::default()
497 }
498}
499
500fn file_label(f: &FileDiff) -> String {
501 match (f.status, f.old_path.as_deref(), f.new_path.as_deref()) {
502 (FileStatus::Renamed | FileStatus::Copied, Some(old), Some(new)) if old != new => format!("{old} → {new}"),
503 _ => f.path().to_owned(),
504 }
505}
506
507struct Palette {
509 added: Color32,
510 removed: Color32,
511 added_gutter: Color32,
512 removed_gutter: Color32,
513 added_emph: Color32,
514 removed_emph: Color32,
515 hunk: Color32,
516}
517
518impl Palette {
519 fn new(t: &Tokens) -> Palette {
520 let (ok, bad) = (t.status.success, t.status.danger);
521 Palette {
522 added: ok.gamma_multiply(0.12),
523 removed: bad.gamma_multiply(0.12),
524 added_gutter: ok.gamma_multiply(0.2),
525 removed_gutter: bad.gamma_multiply(0.2),
526 added_emph: ok.gamma_multiply(0.35),
527 removed_emph: bad.gamma_multiply(0.35),
528 hunk: t.status.info.gamma_multiply(0.1),
529 }
530 }
531
532 fn line(&self, kind: LineKind) -> (Color32, Color32, Color32) {
533 match kind {
534 LineKind::Added => (self.added, self.added_gutter, self.added_emph),
535 LineKind::Removed => (self.removed, self.removed_gutter, self.removed_emph),
536 LineKind::Context => (Color32::TRANSPARENT, Color32::TRANSPARENT, Color32::TRANSPARENT),
537 }
538 }
539}
540
541fn show(view: DiffView<'_>, ui: &mut Ui) -> DiffResponse {
542 let DiffView { id, state, files, revision, mode, theme, labels, highlighter, max_height } = view;
543 let t = tokens::current(ui.ctx());
544 let base = ui.make_persistent_id(id);
545 let installed = crate::labels::current(ui.ctx());
546 let labels = labels.unwrap_or(&installed.diff);
547 let own_theme;
548 let theme = match theme {
549 Some(th) => th,
550 None => {
551 own_theme = SyntaxTheme::from_tokens(&t);
552 &own_theme
553 }
554 };
555 if state.revision != Some(revision) {
556 state.revision = Some(revision);
557 state.expanded.clear();
558 state.collapsed.clear();
559 state.selection.clear();
560 state.version = state.version.wrapping_add(1);
561 }
562
563 let model_id = base.with("model");
565 let key = hash_of(&(revision, mode, state.version, files.as_ptr() as usize, files.len()));
566 let mut model =
567 ui.data_mut(|d| d.remove_temp::<Model>(model_id)).filter(|m| m.key == key).unwrap_or_else(|| Model::build(key, files, state, mode));
568 let rows = model.rows.len();
569 state.selection.clamp(rows);
570
571 let font = t.mono();
572 let (line_h, char_w) = ui.fonts_mut(|f| (f.row_height(&font), f.glyph_width(&font, '0')));
573 let (line_h, char_w) = (line_h.max(1.0) + 2.0, char_w.max(1.0));
574 let pad = t.space.sm;
575 let num_w = model.digits as f32 * char_w + 2.0 * pad;
576 let sign_w = 2.0 * char_w;
577 let style_rev = hash_of(&(theme.revision(), font.size.to_bits(), t.dark));
578 let pal = Palette::new(&t);
579
580 let top_id = base.with("top");
582 let goto_id = base.with("goto");
583 let (last_top, page): (usize, usize) = ui.data(|d| d.get_temp(top_id)).unwrap_or((0, 0));
584 let mut scroll_to: Option<usize> = ui.data_mut(|d| d.remove_temp::<usize>(goto_id)).filter(|r| *r < rows);
585 let mut moved_hunk = None;
586 if ui.memory(|m| m.has_focus(base)) {
587 let (next, prev) = ui.input(|i| {
588 let plain = !i.modifiers.command && !i.modifiers.ctrl && !i.modifiers.alt;
589 (
590 (plain && !i.modifiers.shift && i.key_pressed(Key::N)) || (!i.modifiers.shift && i.key_pressed(Key::F7)),
591 (plain && !i.modifiers.shift && i.key_pressed(Key::P)) || (i.modifiers.shift && i.key_pressed(Key::F7)),
592 )
593 });
594 let cursor = state.selection.cursor().unwrap_or(last_top);
595 let target = if next {
596 model.hunk_rows.get(model.hunk_rows.partition_point(|&r| r <= cursor)).copied()
597 } else if prev {
598 model.hunk_rows.partition_point(|&r| r < cursor).checked_sub(1).and_then(|k| model.hunk_rows.get(k)).copied()
599 } else {
600 None
601 };
602 if let Some(r) = target {
603 state.selection.select_only(r);
604 scroll_to = Some(if r > last_top { r.saturating_add(page.saturating_sub(3)).min(rows.saturating_sub(1)) } else { r });
606 moved_hunk = model.rows.get(r).map(|row| (row.file as usize, row.item as usize));
607 }
608 }
609
610 let area = ui.available_rect_before_wrap();
612 let text_view = match mode {
613 DiffMode::Unified => area.width() - 2.0 * num_w - sign_w,
614 DiffMode::Split => area.width() / 2.0 - num_w - sign_w,
615 } - ui.spacing().scroll.bar_width;
616 let mut hs = HScroll::begin(ui, base.with("hx"), area, model.widest as f32 * char_w + pad, text_view);
617 let hx = hs.offset;
618 let mut list = VirtualList::new(id, rows, RowHeights::Uniform(line_h)).scroll_to(scroll_to).selection(&mut state.selection);
619 if let Some(h) = max_height {
620 list = list.max_height(h);
621 }
622 let mut header_rect = None;
623 let mut record_header = |_: &mut Ui, r: Rect| header_rect = Some(r);
624 let header = Header { height: line_h + 2.0, paint: &mut record_header };
625 let kind = virtual_list::Kind { role: Role::Document, rows: false, node: NodeRole::Document, item: NodeRole::Paragraph };
626 let mut top_row: Option<usize> = None;
627 let mut shown_rows = 0usize;
628 let mut scratch = Vec::new();
629 let ctx = ui.ctx().clone();
630
631 let out = virtual_list::show_rows(ui, base, &mut list, kind, Some(header), |ui, row| {
632 let i = row.index;
633 let Some(r) = model.rows.get(i).copied() else { return };
634 if ui.clip_rect().height() > line_h * 0.5 {
635 top_row.get_or_insert(i);
636 shown_rows += 1;
637 }
638 let rect = row.rect;
639 let painter = ui.painter().clone();
640 let fi = r.file as usize;
641 let Some(f) = files.get(fi) else { return };
642 let row_node = virtual_list::row_id(base, i);
643 let node = |role: NodeRole, label: &str| {
644 ctx.accesskit_node_builder(row_node, |n| {
645 n.set_role(role);
646 n.set_label(label);
647 });
648 };
649 let galley_at =
650 |key: (&str, usize, u8), rev: u64, job: &mut dyn FnMut() -> LayoutJob| -> Arc<Galley> { cache::galley(ui, (base, key), rev, job) };
651 let text_y = rect.top() + 1.0;
652 match r.kind {
653 RowKind::File => {
654 if !row.selected {
655 painter.rect_filled(rect, 0, t.surface.raised);
656 }
657 painter.hline(rect.x_range(), rect.bottom() - 0.5, egui::Stroke::new(1.0, t.border));
658 let g = file_galley(ui, base, f, fi, model.stats.get(fi).copied().unwrap_or_default(), state.collapsed.contains(&fi), &t, style_rev);
659 painter.galley(pos2(rect.left() + pad, text_y), g, t.text.primary);
660 let label = file_label(f);
661 node(NodeRole::Heading, &label);
662 automation::register_fmt(ui, rect, format_args!("{id}.file.{fi}"), &label, Role::Button, true, Details::default());
663 }
664 RowKind::Note => {
665 painter.rect_filled(rect, 0, t.surface.sunken);
666 let g =
667 galley_at(("note", i, 0), style_rev, &mut || LayoutJob::simple_singleline(labels.binary.clone(), font.clone(), t.text.secondary));
668 painter.galley(pos2(rect.left() + pad + num_w, text_y), g, t.text.secondary);
669 node(NodeRole::Paragraph, &labels.binary);
670 }
671 RowKind::Hunk => {
672 let hi = r.item as usize;
673 let header = f.hunks.get(hi).map(|h| h.header()).unwrap_or_default();
674 if !row.selected {
675 painter.rect_filled(rect, 0, pal.hunk);
676 }
677 let g = galley_at(("hunk", i, 0), hash_of(&(model.key, style_rev)), &mut || {
678 LayoutJob::simple_singleline(display(&header).to_owned(), font.clone(), t.text.secondary)
679 });
680 let x = if mode == DiffMode::Unified { 2.0 * num_w } else { num_w };
681 painter.galley(pos2(rect.left() + x + pad, text_y), g, t.text.secondary);
682 node(NodeRole::Heading, &header);
683 automation::register_fmt(ui, rect, format_args!("{id}.file.{fi}.hunk.{hi}"), &header, Role::Header, true, Details::default());
684 }
685 RowKind::Gap => {
686 let gi = r.item as usize;
687 let expandable = model.gaps.get(fi).and_then(|g| g.get(gi)).is_some_and(|g| g.expandable);
688 if !row.selected {
689 painter.rect_filled(rect, 0, t.surface.sunken);
690 }
691 let hidden = labels.hidden.replace("{n}", &r.a.to_string());
692 let label =
693 if expandable { format!("{hidden} · {}", labels.expand.replace("{n}", &r.a.min(EXPAND_STEP).to_string())) } else { hidden };
694 let color = if expandable { t.accent } else { t.text.disabled };
695 let g = galley_at(("gap", i, 0), hash_of(&(model.key, style_rev)), &mut || {
696 LayoutJob::simple_singleline(format!("⋯ {label}"), font.clone(), color)
697 });
698 painter.galley(pos2(rect.left() + pad, text_y), g, color);
699 ctx.accesskit_node_builder(row_node, |n| {
700 n.set_role(if expandable { NodeRole::Button } else { NodeRole::Paragraph });
701 n.set_label(label.as_str());
702 });
703 automation::register_fmt(ui, rect, format_args!("{id}.file.{fi}.gap.{gi}"), &label, Role::Button, expandable, Details::default());
704 }
705 RowKind::Ctx | RowKind::Line => {
706 let st = model.state(files, mode, highlighter, i, &mut scratch);
707 let lang = model.langs.get(fi).copied().unwrap_or("");
708 let cells: Vec<(Option<&ashlar_diff::Line>, Option<u32>, Option<u32>, LineKind, State, u8)> = match (r.kind, mode) {
710 (RowKind::Ctx, DiffMode::Unified) => vec![(None, Some(r.b), Some(r.a), LineKind::Context, st.1, 0)],
711 (RowKind::Ctx, DiffMode::Split) => {
712 vec![(None, Some(r.b), None, LineKind::Context, st.0, 0), (None, None, Some(r.a), LineKind::Context, st.1, 1)]
713 }
714 (_, DiffMode::Unified) => hunk_line(files, &r, r.a)
715 .map(|l| {
716 let s = if l.kind == LineKind::Removed { st.0 } else { st.1 };
717 vec![(Some(l), l.old_no, l.new_no, l.kind, s, 0)]
718 })
719 .unwrap_or_default(),
720 (_, DiffMode::Split) => {
721 let left = hunk_line(files, &r, r.a);
722 let right = hunk_line(files, &r, r.b);
723 vec![
724 (left, left.and_then(|l| l.old_no), None, left.map_or(LineKind::Context, |l| l.kind), st.0, 0),
725 (right, None, right.and_then(|l| l.new_no), right.map_or(LineKind::Context, |l| l.kind), st.1, 1),
726 ]
727 }
728 };
729 let half = rect.width() / 2.0;
730 let mut spoken = String::new();
731 for (line, old_no, new_no, lk, lex, side) in &cells {
732 let x0 = rect.left() + if *side == 1 { half } else { 0.0 };
733 let cell = Rect::from_min_max(
734 pos2(x0, rect.top()),
735 pos2(if mode == DiffMode::Split && *side == 0 { x0 + half } else { rect.right() }, rect.bottom()),
736 );
737 let empty = mode == DiffMode::Split && r.kind == RowKind::Line && line.is_none();
738 let (bg, gutter_bg, emph_bg) = pal.line(*lk);
739 let gutter_w = if mode == DiffMode::Unified { 2.0 * num_w } else { num_w };
740 if empty {
741 painter.rect_filled(cell, 0, t.surface.sunken);
742 } else if !row.selected {
743 painter.rect_filled(cell, 0, bg);
744 painter.rect_filled(Rect::from_min_size(cell.min, vec2(gutter_w, cell.height())), 0, gutter_bg);
745 }
746 if mode == DiffMode::Split && *side == 1 {
747 painter.vline(cell.left(), cell.y_range(), egui::Stroke::new(1.0, t.border));
748 }
749 if empty {
750 continue;
751 }
752 let mut nx = cell.left();
754 let numbers: &[Option<u32>] = if mode == DiffMode::Unified {
755 &[*old_no, *new_no]
756 } else if *side == 0 {
757 &[*old_no]
758 } else {
759 &[*new_no]
760 };
761 for n in numbers {
762 if let Some(n) = n {
763 let g = cache::galley(ui, (base, "num", *n), hash_of(&(font.size.to_bits(), t.dark)), || {
764 LayoutJob::simple_singleline(n.to_string(), font.clone(), t.text.disabled)
765 });
766 painter.galley(pos2(nx + num_w - pad - g.size().x, text_y), g, t.text.disabled);
767 }
768 nx += num_w;
769 }
770 let sign = match lk {
771 LineKind::Added => "+",
772 LineKind::Removed => "-",
773 LineKind::Context => "",
774 };
775 if !sign.is_empty() {
776 let color = if *lk == LineKind::Added { t.status.success } else { t.status.danger };
777 let g = cache::galley(ui, (base, "sign", sign), hash_of(&(font.size.to_bits(), t.dark)), || {
778 LayoutJob::simple_singleline(sign.to_owned(), font.clone(), color)
779 });
780 painter.galley(pos2(nx + pad * 0.5, text_y), g, color);
781 }
782 let raw = match line {
784 Some(l) => l.text.as_str(),
785 None => f.source_line(r.a).unwrap_or(""),
786 };
787 let shown = display(raw);
788 let emphasis: &[Range<usize>] = line.map_or(&[], |l| l.emphasis.as_slice());
789 let rev = hash_of(&(model.key, style_rev, lex.to_bits()));
790 let g = cache::galley(ui, (base, "text", i, *side), rev, || {
791 scratch.clear();
792 let _ = highlighter.highlight_line(lang, shown, *lex, &mut scratch);
793 line_job(shown, &scratch, emphasis, &font, theme, emph_bg)
794 });
795 hs.see(g.size().x + pad);
796 let text_cell = Rect::from_min_max(pos2(nx + sign_w, cell.top()), cell.max);
797 painter.with_clip_rect(text_cell.intersect(ui.clip_rect())).galley(pos2(nx + sign_w - hx, text_y), g, theme.color(Kind::Plain));
798 if !spoken.is_empty() {
799 spoken.push_str(" | ");
800 }
801 let num = new_no.or(*old_no).map(|n| n.to_string()).unwrap_or_default();
802 match lk {
803 LineKind::Added => spoken.push_str(&format!("{} {num}: ", labels.added)),
804 LineKind::Removed => spoken.push_str(&format!("{} {num}: ", labels.removed)),
805 LineKind::Context => spoken.push_str(&format!("{num}: ")),
806 }
807 spoken.push_str(clip_label(shown));
808 }
809 node(NodeRole::Paragraph, &spoken);
810 let details = Details { value: None, selected: Some(row.selected) };
811 if r.kind == RowKind::Ctx {
812 let n = r.a;
813 automation::register_fmt(ui, rect, format_args!("{id}.file.{fi}.gap.{}.line.{n}", r.item), &spoken, Role::Row, true, details);
814 } else {
815 let k = if r.a != NONE { r.a } else { r.b };
816 automation::register_fmt(ui, rect, format_args!("{id}.file.{fi}.hunk.{}.line.{k}", r.item), &spoken, Role::Row, true, details);
817 }
818 }
819 }
820 });
821 let r = out.response.rect;
822 hs.end(ui, Rect::from_min_max(r.min, pos2(r.right() - ui.spacing().scroll.bar_width, r.bottom())), &t);
823 let top = top_row.unwrap_or(last_top);
824 let top_file = model.rows.get(top).map(|r| r.file as usize);
825
826 if let (Some(rect), Some(fi)) = (header_rect, top_file)
828 && let Some(f) = files.get(fi)
829 {
830 let resp = ui.interact(rect, base.with("sticky"), Sense::click());
831 let painter = ui.painter_at(rect);
832 painter.rect_filled(rect, 0, t.surface.raised);
833 painter.hline(rect.x_range(), rect.bottom() - 0.5, egui::Stroke::new(1.0, t.border_strong));
834 let g = file_galley(ui, base, f, fi, model.stats.get(fi).copied().unwrap_or_default(), state.is_file_collapsed(fi), &t, style_rev);
835 painter.galley(pos2(rect.left() + pad, rect.top() + 2.0), g, t.text.primary);
836 let label = file_label(f);
837 ui.ctx().accesskit_node_builder(resp.id, |n| {
838 n.set_role(NodeRole::Heading);
839 n.set_label(label.as_str());
840 });
841 automation::register_with(ui, &resp, &format!("{id}.header"), &label, Role::Header, Details::default());
842 if resp.clicked()
843 && let Some(file_row) = model.rows.iter().position(|r| r.file as usize == fi && r.kind == RowKind::File)
844 {
845 ui.data_mut(|d| d.insert_temp(goto_id, file_row));
846 crate::wake::wake(ui.ctx(), crate::wake::Reason::INPUT);
847 }
848 }
849
850 let alt = ui.input(|i| i.modifiers.alt);
852 let activated = out.activated.filter(|_| out.double_clicked.is_none());
853 let mut expanded = None;
854 let mut toggled_file = None;
855 if let Some(i) = out.clicked.or(activated)
856 && let Some(r) = model.rows.get(i).copied()
857 {
858 let fi = r.file as usize;
859 match r.kind {
860 RowKind::File => {
861 let now = !state.is_file_collapsed(fi);
862 state.set_file_collapsed(fi, now);
863 toggled_file = Some(fi);
864 }
865 RowKind::Gap if model.gaps.get(fi).and_then(|g| g.get(r.item as usize)).is_some_and(|g| g.expandable) => {
866 state.expand(fi, r.item as usize, if alt { r.a } else { r.a.min(EXPAND_STEP) });
867 expanded = Some((fi, r.item as usize));
868 }
869 _ => {}
870 }
871 if toggled_file.is_some() || expanded.is_some() {
872 state.selection.select_only(i);
873 crate::wake::wake(ui.ctx(), crate::wake::Reason::INPUT);
874 }
875 }
876
877 let mut selected_lines = Vec::new();
879 for i in state.selection.iter() {
880 if selected_lines.len() >= MAX_SELECTED_LINES {
881 break;
882 }
883 let Some(r) = model.rows.get(i) else { break };
884 let file = r.file as usize;
885 match r.kind {
886 RowKind::Ctx => selected_lines.push(SelectedLine { file, kind: LineKind::Context, old_no: Some(r.b), new_no: Some(r.a) }),
887 RowKind::Line => {
888 let both = if mode == DiffMode::Split && r.a != r.b { [r.a, r.b] } else { [r.a, NONE] };
889 for idx in both {
890 if let Some(l) = hunk_line(files, r, idx) {
891 selected_lines.push(SelectedLine { file, kind: l.kind, old_no: l.old_no, new_no: l.new_no });
892 }
893 }
894 }
895 _ => {}
896 }
897 }
898
899 ui.ctx().accesskit_node_builder(out.response.id, |n| {
900 n.set_role(NodeRole::Document);
901 n.set_read_only();
902 });
903 ui.data_mut(|d| {
904 d.insert_temp(top_id, (top, shown_rows));
905 d.insert_temp(model_id, model);
906 });
907 DiffResponse { response: out.response, selected_lines, expanded, toggled_file, hunk: moved_hunk, top_file, visible: out.visible }
908}
909
910fn file_galley(
912 ui: &Ui,
913 base: egui::Id,
914 f: &FileDiff,
915 fi: usize,
916 (added, removed): (usize, usize),
917 collapsed: bool,
918 t: &Tokens,
919 style_rev: u64,
920) -> Arc<Galley> {
921 let rev = hash_of(&(style_rev, collapsed, added, removed, f.path(), f.binary));
922 cache::galley(ui, (base, "file", fi), rev, || {
923 let font = egui::TextStyle::Body.resolve(ui.style());
924 let mut job = LayoutJob::default();
925 let fmt = |color: Color32| TextFormat { font_id: font.clone(), color, ..Default::default() };
926 job.append(if collapsed { "▸ " } else { "▾ " }, 0.0, fmt(t.text.secondary));
927 job.append(&file_label(f), 0.0, fmt(t.text.primary));
928 if added > 0 {
929 job.append(&format!("+{added}"), 12.0, fmt(t.status.success));
930 }
931 if removed > 0 {
932 job.append(&format!("−{removed}"), 8.0, fmt(t.status.danger));
933 }
934 job.wrap = TextWrapping { max_width: f32::INFINITY, ..Default::default() };
935 job
936 })
937}
938
939#[cfg(test)]
940mod tests {
941 use super::*;
942
943 #[test]
944 fn split_pairs_removed_and_added_runs() {
945 let d = FileDiff::from_texts("a\nb\nc\nd\n", "a\nB\nC\nX\nd\n");
946 let files = [d];
947 let m = Model::build(1, &files, &DiffViewState::new(), DiffMode::Split);
948 let lines: Vec<(u32, u32)> = m.rows.iter().filter(|r| r.kind == RowKind::Line).map(|r| (r.a, r.b)).collect();
949 assert_eq!(lines, vec![(0, 0), (1, 3), (2, 4), (NONE, 5), (6, 6)]);
951 let m = Model::build(1, &files, &DiffViewState::new(), DiffMode::Unified);
952 assert_eq!(m.rows.iter().filter(|r| r.kind == RowKind::Line).count(), 7);
953 assert_eq!(m.hunk_rows, vec![1]);
954 }
955
956 #[test]
957 fn gaps_expand_and_files_fold() {
958 let old: String = (1..=50).map(|i| format!("{i}\n")).collect();
959 let new = old.replacen("25\n", "x\n", 1);
960 let files = [FileDiff::from_texts(&old, &new)];
961 let mut st = DiffViewState::new();
962 let m = Model::build(1, &files, &st, DiffMode::Unified);
963 let gaps: Vec<u32> = m.rows.iter().filter(|r| r.kind == RowKind::Gap).map(|r| r.a).collect();
964 assert_eq!(gaps, vec![21, 22]);
965 st.expand(0, 0, 5);
966 let m2 = Model::build(2, &files, &st, DiffMode::Unified);
967 assert_eq!(m2.rows.len(), m.rows.len() + 5);
968 st.expand(0, 0, 100);
969 let m3 = Model::build(3, &files, &st, DiffMode::Unified);
970 assert_eq!(m3.rows.len(), m.rows.len() + 21 - 1);
971 st.set_file_collapsed(0, true);
972 assert_eq!(Model::build(4, &files, &st, DiffMode::Unified).rows.len(), 1);
973 }
974
975 #[test]
976 fn emphasis_sections_split_spans() {
977 let theme = SyntaxTheme::from_tokens(&Tokens::default());
978 let font = FontId::monospace(12.0);
979 let text = "let x = 1;";
980 let spans = ashlar_syntax::highlight(Some("rust"), text);
981 let job = line_job(text, &spans, &[4..5, 8..9], &font, &theme, Color32::RED);
982 let red: Vec<Range<usize>> =
983 job.sections.iter().filter(|s| s.format.background == Color32::RED).map(|s| s.byte_range.start.0..s.byte_range.end.0).collect();
984 assert_eq!(red, vec![4..5, 8..9]);
985 assert_eq!(job.sections.last().map(|s| s.byte_range.end.0), Some(text.len()));
986 let job = line_job(text, &spans, &[100..200, 3..1000], &font, &theme, Color32::RED);
988 assert!(job.sections.iter().all(|s| s.byte_range.end.0 <= text.len()));
989 }
990}