Skip to main content

fc_dioxus/components/
diff.rs

1//! A line diff for review, from diff.css, on upstream's `fc-diff` model (Myers' O(ND)
2//! diff, the same code upstream can run as WASM).
3use 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/// What to draw: a run of lines, or a run of unchanged lines folded away.
18#[derive(Debug, Clone, PartialEq, Eq)]
19enum Row {
20    Line(model::DiffLine),
21    Gap {
22        from: usize,
23        lines: Vec<model::DiffLine>,
24    },
25}
26
27/// Fold unchanged runs longer than `context` lines on each side of a change.
28fn 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/// The difference between two texts, line by line, with counts above.
70///
71/// Colour is never the only cue: added and removed lines also differ in their +/−
72/// glyph, their accessible label, and `data-fc-diff`. Folded unchanged lines stay in
73/// the DOM, hidden, so a machine reading the diff sees all of it.
74#[component]
75pub fn DiffView(
76    before: String,
77    after: String,
78    /// Keep lines whole and scroll sideways, in a focusable region named by this.
79    #[props(default)]
80    scroll: Option<AccessibleName>,
81    /// Fold unchanged runs, keeping this many lines around each change.
82    #[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        // Source reads left to right in every document.
118        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            // Upstream's markup names this `fc-diff__body`, which diff.css does not define.
125            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}