1use crate::{
4 core::{
5 assets,
6 attrs::{component_attributes, diff_attribute},
7 },
8 models::diff as model,
9 prelude::*,
10 theme::use_stylesheets,
11};
12const BEM: Bem = Bem::new("fc-diff");
13const STAT: Bem = Bem::new("fc-diff__stat");
14fc_modifier! { enum DiffLayout for BEM { Wrap => None, Scroll => "scroll" } }
15fc_modifier! { enum StatKind for STAT { Plain => None, Added => "added", Removed => "removed" } }
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19enum Row {
20 Line(model::DiffLine),
21 Gap {
22 from: usize,
23 lines: Vec<model::DiffLine>,
24 },
25}
26
27fn fold(lines: Vec<model::DiffLine>, context: Option<usize>) -> Vec<Row> {
29 let Some(context) = context else {
30 return lines.into_iter().map(Row::Line).collect();
31 };
32 let changed: Vec<bool> = lines
33 .iter()
34 .map(|l| l.kind != model::DiffKind::Equal)
35 .collect();
36 let near = |i: usize| {
37 let low = i.saturating_sub(context);
38 let high = (i + context).min(changed.len().saturating_sub(1));
39 changed.get(low..=high).is_some_and(|w| w.contains(&true))
40 };
41 let mut rows = Vec::new();
42 let mut pending: Vec<model::DiffLine> = Vec::new();
43 let mut pending_from = 0;
44 for (index, line) in lines.into_iter().enumerate() {
45 if near(index) {
46 if !pending.is_empty() {
47 rows.push(Row::Gap {
48 from: pending_from,
49 lines: std::mem::take(&mut pending),
50 });
51 }
52 rows.push(Row::Line(line));
53 } else {
54 if pending.is_empty() {
55 pending_from = index;
56 }
57 pending.push(line);
58 }
59 }
60 if !pending.is_empty() {
61 rows.push(Row::Gap {
62 from: pending_from,
63 lines: pending,
64 });
65 }
66 rows
67}
68
69#[component]
75pub fn DiffView(
76 before: String,
77 after: String,
78 #[props(default)]
80 scroll: Option<AccessibleName>,
81 #[props(default)]
83 context: Option<usize>,
84 #[props(default)] class: Option<String>,
85 #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
86) -> Element {
87 let lines = model::diff_lines(&before, &after);
88 let stats = model::diff_stats(&lines);
89 let rows = fold(lines, context);
90 let mut opened = use_signal(Vec::<usize>::new);
91 let mut attributes = component_attributes(
92 attributes,
93 ClassList::new(BEM.block())
94 .with_modifier(
95 BEM,
96 if scroll.is_some() {
97 DiffLayout::Scroll
98 } else {
99 DiffLayout::Wrap
100 },
101 )
102 .extend(class.as_deref()),
103 &["dir", "role", "aria-label", "tabindex"],
104 );
105 if let Some(label) = &scroll {
106 attributes.push(Attribute::new("role", "region", None, false));
107 attributes.push(Attribute::new("aria-label", label.to_string(), None, false));
108 attributes.push(Attribute::new("tabindex", "0", None, false));
109 }
110 let stat = |kind: StatKind| {
111 ClassList::new(STAT.block())
112 .with_modifier(STAT, kind)
113 .to_string()
114 };
115 rsx! {
116 if use_stylesheets() { document::Stylesheet { href: assets::COMPONENTS_DIFF_CSS } }
117 div { dir: "ltr", ..attributes,
119 div { class: BEM.element("bar"),
120 span { class: stat(StatKind::Added), "+{stats.added}" }
121 span { class: stat(StatKind::Removed), "−{stats.removed}" }
122 if stats.identical { span { class: stat(StatKind::Plain), "No changes" } }
123 }
124 div {
126 for row in rows {
127 match row {
128 Row::Line(line) => line_view(&line, false),
129 Row::Gap { from, lines } => {
130 let open = opened().contains(&from);
131 let count = lines.len();
132 let plural = if count == 1 { "" } else { "s" };
133 rsx! {
134 if !open {
135 button { key: "gap-{from}", class: BEM.element("gap"), r#type: "button",
136 onclick: move |_| opened.write().push(from),
137 "{count} unchanged line{plural}"
138 }
139 }
140 for line in lines { {line_view(&line, !open)} }
141 }
142 }
143 }
144 }
145 }
146 }
147 }
148}
149
150fn line_view(line: &model::DiffLine, hidden: bool) -> Element {
151 let (marker, label, data) = match line.kind {
152 model::DiffKind::Add => (
153 "+",
154 Some(format!("Added: {}", line.text)),
155 crate::spread![diff_attribute("add")],
156 ),
157 model::DiffKind::Remove => (
158 "−",
159 Some(format!("Removed: {}", line.text)),
160 crate::spread![diff_attribute("remove")],
161 ),
162 model::DiffKind::Equal => (" ", None, Spread::default()),
163 };
164 let old = line.old_line.map(|n| n.to_string()).unwrap_or_default();
165 let new = line.new_line.map(|n| n.to_string()).unwrap_or_default();
166 rsx! {
167 div { class: BEM.element("line"), hidden, aria_label: label, ..data,
168 span { class: BEM.element("marker"), aria_hidden: "true", "{marker}" }
169 span { class: BEM.element("num"), aria_hidden: "true", "{old}" }
170 span { class: BEM.element("num"), aria_hidden: "true", "{new}" }
171 span { class: BEM.element("content"), "{line.text}" }
172 }
173 }
174}