OXYGENHUB
# de505df5cf8d oxygenhub / crates / hub / src / render / diff.rs
8.9 KiB · Rust · last changed State cache keyed by heads; history and graph through oxy-core's lanes and log window 1 month ago source blame history raw
d23464c0 Andy McCall ↶ 1
//! Diff rendering: `oxy`'s per-file hunks become unified rows with
2
//! context, and two snapshots become per-file hunks.
3
4
use oxy_core::repo::{DiffHunk, FileDiff};
5
use oxy_core::state::{FileContent, Snapshot};
6
use similar::{Algorithm, DiffOp};
7
use std::collections::BTreeMap;
8
9
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10
pub enum Kind {
11
    Context,
12
    Add,
13
    Del,
14
    /// Elided lines between hunks.
15
    Gap,
16
}
17
18
#[derive(Clone, Debug, PartialEq, Eq)]
19
pub struct Line {
20
    pub kind: Kind,
21
    pub old_no: Option<u32>,
22
    pub new_no: Option<u32>,
23
    /// Raw text, not escaped.
24
    pub text: String,
25
}
26
27
impl Kind {
28
    pub fn class(self) -> &'static str {
29
        match self {
30
            Kind::Context => "ctx",
31
            Kind::Add => "add",
32
            Kind::Del => "del",
33
            Kind::Gap => "gap",
34
        }
35
    }
36
37
    pub fn sign(self) -> &'static str {
38
        match self {
39
            Kind::Context => " ",
40
            Kind::Add => "+",
41
            Kind::Del => "-",
42
            Kind::Gap => "…",
43
        }
44
    }
45
}
46
47
fn text(b: &[u8]) -> String {
48
    String::from_utf8_lossy(b).trim_end_matches(['\n', '\r']).to_string()
49
}
50
51
/// Unified rows for one file: `base` is the file before the change (empty
52
/// for an added file) and `hunks` come from `oxy diff`, whose `base_line`
53
/// is the 1-based line the additions follow (0 = before the first line).
54
pub fn unified(base: &[u8], hunks: &[DiffHunk], context: usize) -> Vec<Line> {
55
    let base_lines: Vec<String> = oxy_core::diff::split_lines(base).into_iter().map(text).collect();
56
    // Turn hunks into a per-old-line plan: removed set, and additions keyed
57
    // by the base line they follow.
58
    let mut removed = std::collections::HashSet::new();
59
    let mut added: BTreeMap<u32, Vec<String>> = BTreeMap::new();
60
    for h in hunks {
61
        for (line, _) in &h.removed {
62
            removed.insert(*line);
63
        }
64
        added.entry(h.base_line).or_default().extend(h.added.iter().map(|b| text(b)));
65
    }
66
    // Walk the base, emitting rows; then trim to context around changes.
67
    let mut rows: Vec<Line> = Vec::new();
68
    let mut new_no: u32 = 0;
69
    if let Some(lines) = added.get(&0) {
70
        for l in lines {
71
            new_no += 1;
72
            rows.push(Line { kind: Kind::Add, old_no: None, new_no: Some(new_no), text: l.clone() });
73
        }
74
    }
75
    for (i, l) in base_lines.iter().enumerate() {
76
        let old_no = i as u32 + 1;
77
        if removed.contains(&old_no) {
78
            rows.push(Line { kind: Kind::Del, old_no: Some(old_no), new_no: None, text: l.clone() });
79
        } else {
80
            new_no += 1;
81
            rows.push(Line { kind: Kind::Context, old_no: Some(old_no), new_no: Some(new_no), text: l.clone() });
82
        }
83
        if let Some(lines) = added.get(&old_no) {
84
            for a in lines {
85
                new_no += 1;
86
                rows.push(Line { kind: Kind::Add, old_no: None, new_no: Some(new_no), text: a.clone() });
87
            }
88
        }
89
    }
90
    // Any additions anchored past the end (defensive).
91
    for (anchor, lines) in &added {
92
        if *anchor as usize > base_lines.len() {
93
            for a in lines {
94
                new_no += 1;
95
                rows.push(Line { kind: Kind::Add, old_no: None, new_no: Some(new_no), text: a.clone() });
96
            }
97
        }
98
    }
99
    with_context(rows, context)
100
}
101
102
/// Keep `context` rows around every change, replacing longer runs with a gap.
103
fn with_context(rows: Vec<Line>, context: usize) -> Vec<Line> {
104
    let changed: Vec<bool> = rows.iter().map(|r| r.kind != Kind::Context).collect();
105
    let mut keep = vec![false; rows.len()];
106
    for (i, c) in changed.iter().enumerate() {
107
        if *c {
108
            let lo = i.saturating_sub(context);
109
            let hi = (i + context + 1).min(rows.len());
110
            for k in keep.iter_mut().take(hi).skip(lo) {
111
                *k = true;
112
            }
113
        }
114
    }
115
    let mut out = Vec::new();
116
    let mut gap = 0;
117
    for (row, k) in rows.into_iter().zip(keep) {
118
        if k {
119
            if gap > 0 {
120
                out.push(Line { kind: Kind::Gap, old_no: None, new_no: None, text: format!("{gap} unchanged lines") });
121
                gap = 0;
122
            }
123
            out.push(row);
124
        } else {
125
            gap += 1;
126
        }
127
    }
128
    if gap > 0 {
129
        out.push(Line { kind: Kind::Gap, old_no: None, new_no: None, text: format!("{gap} unchanged lines") });
130
    }
131
    out
132
}
133
134
/// Hunks between two texts, in `oxy diff`'s shape.
135
pub fn hunks_between(old: &[u8], new: &[u8]) -> Vec<DiffHunk> {
136
    let old_lines = oxy_core::diff::split_lines(old);
137
    let new_lines = oxy_core::diff::split_lines(new);
138
    let ops = similar::capture_diff_slices(Algorithm::Myers, &old_lines, &new_lines);
139
    let mut hunks: Vec<DiffHunk> = Vec::new();
140
    for op in ops {
141
        match op {
142
            DiffOp::Equal { .. } => {}
143
            DiffOp::Delete { old_index, old_len, .. } => {
144
                hunks.push(DiffHunk {
145
                    base_line: (old_index + old_len) as u32,
146
                    added: vec![],
147
                    removed: (old_index..old_index + old_len).map(|i| (i as u32 + 1, old_lines[i].to_vec())).collect(),
148
                });
149
            }
150
            DiffOp::Insert { old_index, new_index, new_len } => {
151
                hunks.push(DiffHunk {
152
                    base_line: old_index as u32,
153
                    added: (new_index..new_index + new_len).map(|i| new_lines[i].to_vec()).collect(),
154
                    removed: vec![],
155
                });
156
            }
157
            DiffOp::Replace { old_index, old_len, new_index, new_len } => {
158
                hunks.push(DiffHunk {
159
                    base_line: (old_index + old_len) as u32,
160
                    added: (new_index..new_index + new_len).map(|i| new_lines[i].to_vec()).collect(),
161
                    removed: (old_index..old_index + old_len).map(|i| (i as u32 + 1, old_lines[i].to_vec())).collect(),
162
                });
163
            }
164
        }
165
    }
166
    hunks
167
}
168
9ff27da5 Andy McCall ↶ 169
/// The per-file diff of one change against its base state (what
170
/// `oxy diff CHANGE` shows), using an already loaded base.
171
pub fn change_files(change: &oxy_core::objects::Change, base: &oxy_core::state::State, base_snapshot: &Snapshot) -> Vec<FileDiff> {
172
    oxy_core::repo::diff_files_of(change, base, base_snapshot)
173
}
174
d23464c0 Andy McCall ↶ 175
/// Every file that differs between two snapshots, with the base content
176
/// of files present in both (for context).
177
pub fn snapshot_diff(base: &Snapshot, head: &Snapshot) -> (Vec<FileDiff>, BTreeMap<String, Vec<u8>>) {
178
    let mut files = Vec::new();
179
    let mut bases = BTreeMap::new();
180
    let text_of = |f: &oxy_core::state::FileState| match &f.content {
181
        FileContent::Text { bytes, .. } => Some(bytes.clone()),
182
        _ => None,
183
    };
184
    for (path, hf) in &head.files {
185
        if hf.kind == oxy_core::objects::NodeKind::Dir {
186
            continue;
187
        }
188
        match base.files.get(path) {
189
            None => {
190
                let new = text_of(hf).unwrap_or_default();
191
                files.push(FileDiff { path: path.clone(), kind: "added".into(), hunks: hunks_between(b"", &new), from: None });
192
            }
193
            Some(bf) => {
194
                let (old, new) = (text_of(bf), text_of(hf));
195
                if old != new {
196
                    let old_b = old.clone().unwrap_or_default();
197
                    let hunks = hunks_between(&old_b, &new.unwrap_or_default());
198
                    if let Some(o) = old {
199
                        bases.insert(path.clone(), o);
200
                    }
201
                    files.push(FileDiff { path: path.clone(), kind: "modified".into(), hunks, from: None });
202
                } else if bf.mode != hf.mode {
203
                    files.push(FileDiff { path: path.clone(), kind: "mode".into(), hunks: vec![], from: None });
204
                }
205
            }
206
        }
207
    }
208
    for (path, bf) in &base.files {
209
        if bf.kind != oxy_core::objects::NodeKind::Dir && !head.files.contains_key(path) {
210
            let old = text_of(bf).unwrap_or_default();
211
            bases.insert(path.clone(), old.clone());
212
            files.push(FileDiff { path: path.clone(), kind: "removed".into(), hunks: hunks_between(&old, b""), from: None });
213
        }
214
    }
215
    files.sort_by(|a, b| a.path.cmp(&b.path));
216
    (files, bases)
217
}
218
219
#[cfg(test)]
220
mod tests {
221
    use super::*;
222
223
    #[test]
224
    fn unified_rows_with_context() {
225
        let base = b"a\nb\nc\nd\ne\nf\ng\nh\n";
226
        let hunks = hunks_between(base, b"a\nb\nc\nX\ne\nf\ng\nh\nZ\n");
227
        let rows = unified(base, &hunks, 1);
228
        let s: Vec<String> = rows.iter().map(|r| format!("{}{}", r.kind.sign(), r.text)).collect();
229
        assert_eq!(s, ["…2 unchanged lines", " c", "-d", "+X", " e", "…2 unchanged lines", " h", "+Z"]);
230
        assert_eq!(rows[2].old_no, Some(4));
231
        assert_eq!(rows[3].new_no, Some(4));
232
        assert_eq!(rows.last().unwrap().new_no, Some(9));
233
    }
234
235
    #[test]
236
    fn added_file() {
237
        let hunks = hunks_between(b"", b"one\ntwo\n");
238
        let rows = unified(b"", &hunks, 3);
239
        assert_eq!(rows.iter().map(|r| r.kind).collect::<Vec<_>>(), [Kind::Add, Kind::Add]);
240
    }
241
}