OXYGENHUB

Diffs: word marks, whitespace, split view, collapsing 9df855f29948

Within each block of changed lines, removals come first and pair with
the additions in the same place; similar pairs mark the tokens that
changed, so 42 -> 43 highlights just the number. ?w=1 hides
whitespace-only changes on change, compare and proposal pages. Every
file is a collapsible section (large ones start closed), and a toolbar
switches between unified and split views, remembered in a diff cookie,
and collapses or expands all files. Spec §6.8.1.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-28 08:22 UTC
Signature
✔ signed by Andy McCall
Hash
9df855f299484650f651eb99c256626a8cf21052c88d5c6542357e532d6d814c
Depends on
101740454617 18e381ea88c2 19bb31ff1f5c 93be9ce4653e c40c2d3edb51 d23464c0ac60 de505df5cf8d
Depended on by
b5a0a69b3622 a1b9869565aa 4f6925337848 cc19697f8871 3d1e74cdac76 64caed18236a 07b1a5bbf481
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
52 atoms, 0 tree operations, 11 files

Browse the tree at this change

modified assets/hub.js +73 −0
…
47 unchanged lines
48 48
    });
49 49
  }
50 50
51 +
  /* Diffs: unified or split (remembered in the diff cookie), collapse all. */
52 +
  var diffs = document.querySelectorAll("table.code.diff");
53 +
  if (diffs.length) {
54 +
    var pane = function (tr, col, side) {
55 +
      var n = document.createElement("td"), t = document.createElement("td");
56 +
      n.className = "n"; t.className = "t" + (side === "right" ? " mid" : "");
57 +
      if (!tr) { n.className += " none"; t.className += " none"; return [n, t]; }
58 +
      var kind = tr.classList.contains("add") ? "add" : tr.classList.contains("del") ? "del" : "ctx";
59 +
      if (kind !== "ctx") { n.className += " " + kind; t.className += " " + kind; }
60 +
      n.textContent = tr.cells[col].textContent;
61 +
      t.innerHTML = tr.cells[3].innerHTML;
62 +
      // Line comments belong to the projected (right) side only.
63 +
      if (side === "left") t.querySelectorAll(".linecomment").forEach(function (d) { d.remove(); });
64 +
      return [n, t];
65 +
    };
66 +
    var split = function (table) {
67 +
      var s = document.createElement("table");
68 +
      s.className = "code diff split";
69 +
      s.innerHTML = '<colgroup><col class="n"><col><col class="n"><col></colgroup>';
70 +
      var rows = [].slice.call(table.rows), i = 0;
71 +
      var add = function (l, r) {
72 +
        var tr = s.insertRow();
73 +
        pane(l, 0, "left").concat(pane(r, 1, "right")).forEach(function (c) { tr.appendChild(c); });
74 +
      };
75 +
      while (i < rows.length) {
76 +
        var r = rows[i];
77 +
        if (r.classList.contains("del") || r.classList.contains("add")) {
78 +
          var dels = [], adds = [];
79 +
          while (i < rows.length && rows[i].classList.contains("del")) dels.push(rows[i++]);
80 +
          while (i < rows.length && rows[i].classList.contains("add")) adds.push(rows[i++]);
81 +
          for (var k = 0; k < Math.max(dels.length, adds.length); k++) add(dels[k], adds[k]);
82 +
          continue;
83 +
        }
84 +
        if (r.classList.contains("gap") || r.querySelector("td.thread")) {
85 +
          var tr = s.insertRow(), td = tr.insertCell();
86 +
          tr.className = r.className; td.colSpan = 4; td.className = r.cells[r.cells.length - 1].className;
87 +
          td.innerHTML = r.cells[r.cells.length - 1].innerHTML;
88 +
        } else {
89 +
          add(r, r);
90 +
        }
91 +
        i++;
92 +
      }
93 +
      return s;
94 +
    };
95 +
    var cookieMode = (document.cookie.match(/(?:^|; )diff=(split|unified)/) || [])[1] || "unified";
96 +
    var apply = function (mode) {
97 +
      diffs.forEach(function (t) {
98 +
        if (mode === "split" && !t._split) { t._split = split(t); t.parentNode.insertBefore(t._split, t.nextSibling); }
99 +
        t.hidden = mode === "split";
100 +
        if (t._split) t._split.hidden = mode !== "split";
101 +
      });
102 +
      document.querySelectorAll("[data-diff-mode]").forEach(function (a) { a.classList.toggle("on", a.getAttribute("data-diff-mode") === mode); });
103 +
    };
104 +
    document.querySelectorAll("[data-diff-modes], [data-diff-collapse]").forEach(function (e) { e.hidden = false; });
105 +
    document.querySelectorAll("[data-diff-mode]").forEach(function (a) {
106 +
      a.addEventListener("click", function (ev) {
107 +
        ev.preventDefault();
108 +
        var mode = a.getAttribute("data-diff-mode");
109 +
        document.cookie = "diff=" + mode + "; path=/; max-age=31536000; samesite=lax";
110 +
        apply(mode);
111 +
      });
112 +
    });
113 +
    document.querySelectorAll("[data-diff-collapse]").forEach(function (a) {
114 +
      a.addEventListener("click", function (ev) {
115 +
        ev.preventDefault();
116 +
        var files = document.querySelectorAll("details.file-diff"), anyOpen = [].some.call(files, function (d) { return d.open; });
117 +
        files.forEach(function (d) { d.open = !anyOpen; });
118 +
        a.textContent = anyOpen ? "expand all" : "collapse all";
119 +
      });
120 +
    });
121 +
    apply(cookieMode);
122 +
  }
123 +
51 124
  /* Copy buttons. */
52 125
  document.querySelectorAll("[data-copy]").forEach(function (b) {
53 126
    b.addEventListener("click", function () {
…
63 unchanged lines
modified crates/hub/src/render/diff.rs +195 −8
…
21 unchanged lines
22 22
    pub new_no: Option<u32>,
23 23
    /// Raw text, not escaped.
24 24
    pub text: String,
25 +
    /// For a changed line paired with its counterpart: the text in runs,
26 +
    /// marking the words that changed. Empty means show `text` as is.
27 +
    pub segments: Vec<Segment>,
28 +
}
29 +
30 +
/// A run of a changed line's text.
31 +
#[derive(Clone, Debug, PartialEq, Eq)]
32 +
pub struct Segment {
33 +
    pub text: String,
34 +
    pub changed: bool,
35 +
}
36 +
37 +
/// How to render a diff.
38 +
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
39 +
pub struct Options {
40 +
    /// Treat lines that differ only in whitespace as unchanged (`?w=1`).
41 +
    pub ignore_whitespace: bool,
25 42
}
26 43
44 +
impl Line {
45 +
    fn new(kind: Kind, old_no: Option<u32>, new_no: Option<u32>, text: String) -> Line {
46 +
        Line { kind, old_no, new_no, text, segments: vec![] }
47 +
    }
48 +
}
49 +
27 50
impl Kind {
28 51
    pub fn class(self) -> &'static str {
29 52
        match self {
…
22 unchanged lines
52 75
/// for an added file) and `hunks` come from `oxy diff`, whose `base_line`
53 76
/// is the 1-based line the additions follow (0 = before the first line).
54 77
pub fn unified(base: &[u8], hunks: &[DiffHunk], context: usize) -> Vec<Line> {
78 +
    unified_with(base, hunks, context, Options::default())
79 +
}
80 +
81 +
/// `unified`, with options, and changed lines refined word by word.
82 +
pub fn unified_with(base: &[u8], hunks: &[DiffHunk], context: usize, opts: Options) -> Vec<Line> {
55 83
    let base_lines: Vec<String> = oxy_core::diff::split_lines(base).into_iter().map(text).collect();
56 84
    // Turn hunks into a per-old-line plan: removed set, and additions keyed
57 85
    // by the base line they follow.
…
11 unchanged lines
69 97
    if let Some(lines) = added.get(&0) {
70 98
        for l in lines {
71 99
            new_no += 1;
72 -
            rows.push(Line { kind: Kind::Add, old_no: None, new_no: Some(new_no), text: l.clone() });
100 +
            rows.push(Line::new(Kind::Add, None, Some(new_no), l.clone()));
73 101
        }
74 102
    }
75 103
    for (i, l) in base_lines.iter().enumerate() {
76 104
        let old_no = i as u32 + 1;
77 105
        if removed.contains(&old_no) {
78 -
            rows.push(Line { kind: Kind::Del, old_no: Some(old_no), new_no: None, text: l.clone() });
106 +
            rows.push(Line::new(Kind::Del, Some(old_no), None, l.clone()));
79 107
        } else {
80 108
            new_no += 1;
81 -
            rows.push(Line { kind: Kind::Context, old_no: Some(old_no), new_no: Some(new_no), text: l.clone() });
109 +
            rows.push(Line::new(Kind::Context, Some(old_no), Some(new_no), l.clone()));
82 110
        }
83 111
        if let Some(lines) = added.get(&old_no) {
84 112
            for a in lines {
85 113
                new_no += 1;
86 -
                rows.push(Line { kind: Kind::Add, old_no: None, new_no: Some(new_no), text: a.clone() });
114 +
                rows.push(Line::new(Kind::Add, None, Some(new_no), a.clone()));
87 115
            }
88 116
        }
89 117
    }
…
2 unchanged lines
92 120
        if *anchor as usize > base_lines.len() {
93 121
            for a in lines {
94 122
                new_no += 1;
95 -
                rows.push(Line { kind: Kind::Add, old_no: None, new_no: Some(new_no), text: a.clone() });
123 +
                rows.push(Line::new(Kind::Add, None, Some(new_no), a.clone()));
124 +
            }
125 +
        }
126 +
    }
127 +
    with_context(refine(rows, opts), context)
128 +
}
129 +
130 +
/// Pair the removed and added lines of each block of changes, line by
131 +
/// line. A pair that differs only in whitespace becomes one
132 +
/// context line when whitespace is ignored; otherwise a similar pair gets
133 +
/// word-level segments so the page can mark what changed within it.
134 +
fn refine(rows: Vec<Line>, opts: Options) -> Vec<Line> {
135 +
    let mut out: Vec<Line> = Vec::with_capacity(rows.len());
136 +
    let mut i = 0;
137 +
    while i < rows.len() {
138 +
        let changed = |k: usize| matches!(rows[k].kind, Kind::Del | Kind::Add);
139 +
        if !changed(i) {
140 +
            out.push(rows[i].clone());
141 +
            i += 1;
142 +
            continue;
143 +
        }
144 +
        // One block of changed lines, whichever order oxy's hunks put them
145 +
        // in; it is shown removals first, then additions.
146 +
        let end = (i..rows.len()).find(|&k| !changed(k)).unwrap_or(rows.len());
147 +
        let mut dels: Vec<Line> = rows[i..end].iter().filter(|l| l.kind == Kind::Del).cloned().collect();
148 +
        let mut adds: Vec<Line> = rows[i..end].iter().filter(|l| l.kind == Kind::Add).cloned().collect();
149 +
        let adds_end = end;
150 +
        let pairs = dels.len().min(adds.len());
151 +
        let mut same_but_space = vec![false; pairs];
152 +
        for k in 0..pairs {
153 +
            if opts.ignore_whitespace && squash(&dels[k].text) == squash(&adds[k].text) {
154 +
                same_but_space[k] = true;
155 +
            } else if let Some((d, a)) = words(&dels[k].text, &adds[k].text) {
156 +
                dels[k].segments = d;
157 +
                adds[k].segments = a;
96 158
            }
97 159
        }
160 +
        if same_but_space.iter().any(|x| *x) {
161 +
            // Keep the file's order: changed lines before an unchanged pair
162 +
            // come out as removals then additions, then the pair as context.
163 +
            let (mut pd, mut pa): (Vec<Line>, Vec<Line>) = (Vec::new(), Vec::new());
164 +
            for k in 0..pairs {
165 +
                if same_but_space[k] {
166 +
                    out.append(&mut pd);
167 +
                    out.append(&mut pa);
168 +
                    out.push(Line::new(Kind::Context, dels[k].old_no, adds[k].new_no, adds[k].text.clone()));
169 +
                } else {
170 +
                    pd.push(dels[k].clone());
171 +
                    pa.push(adds[k].clone());
172 +
                }
173 +
            }
174 +
            out.append(&mut pd);
175 +
            out.extend(dels.drain(pairs..));
176 +
            out.append(&mut pa);
177 +
            out.extend(adds.drain(pairs..));
178 +
        } else {
179 +
            out.extend(dels);
180 +
            out.extend(adds);
181 +
        }
182 +
        i = adds_end;
98 183
    }
99 -
    with_context(rows, context)
184 +
    out
100 185
}
101 186
187 +
/// A line split into identifiers, whitespace runs and single other
188 +
/// characters, so `42;` changing to `43;` marks only the number.
189 +
fn tokens(s: &str) -> Vec<&str> {
190 +
    let mut out = Vec::new();
191 +
    let mut start = 0;
192 +
    let class = |c: char| {
193 +
        if c.is_alphanumeric() || c == '_' {
194 +
            0
195 +
        } else if c.is_whitespace() {
196 +
            1
197 +
        } else {
198 +
            2
199 +
        }
200 +
    };
201 +
    let mut prev: Option<u8> = None;
202 +
    for (i, c) in s.char_indices() {
203 +
        let k = class(c);
204 +
        if prev.is_some_and(|p| p != k || k == 2) {
205 +
            out.push(&s[start..i]);
206 +
            start = i;
207 +
        }
208 +
        prev = Some(k);
209 +
    }
210 +
    if start < s.len() {
211 +
        out.push(&s[start..]);
212 +
    }
213 +
    out
214 +
}
215 +
216 +
/// Text with every run of whitespace removed, for comparing ignoring it.
217 +
fn squash(s: &str) -> String {
218 +
    s.split_whitespace().collect()
219 +
}
220 +
221 +
/// Word-level runs of an old and a new line, or None when the lines share
222 +
/// too little for marking words to help (then the whole line reads as changed).
223 +
fn words(old: &str, new: &str) -> Option<(Vec<Segment>, Vec<Segment>)> {
224 +
    if old.len() + new.len() > 2_000 {
225 +
        return None;
226 +
    }
227 +
    let (ot, nt) = (tokens(old), tokens(new));
228 +
    let diff = similar::TextDiff::configure().algorithm(Algorithm::Patience).diff_slices(&ot, &nt);
229 +
    if diff.ratio() < 0.4 {
230 +
        return None;
231 +
    }
232 +
    let (mut d, mut a) = (Vec::new(), Vec::new());
233 +
    let push = |v: &mut Vec<Segment>, text: &str, changed: bool| match v.last_mut() {
234 +
        Some(last) if last.changed == changed => last.text.push_str(text),
235 +
        _ => v.push(Segment { text: text.to_string(), changed }),
236 +
    };
237 +
    for change in diff.iter_all_changes() {
238 +
        match change.tag() {
239 +
            similar::ChangeTag::Equal => {
240 +
                push(&mut d, change.value(), false);
241 +
                push(&mut a, change.value(), false);
242 +
            }
243 +
            similar::ChangeTag::Delete => push(&mut d, change.value(), true),
244 +
            similar::ChangeTag::Insert => push(&mut a, change.value(), true),
245 +
        }
246 +
    }
247 +
    Some((d, a))
248 +
}
249 +
102 250
/// Keep `context` rows around every change, replacing longer runs with a gap.
103 251
fn with_context(rows: Vec<Line>, context: usize) -> Vec<Line> {
104 252
    let changed: Vec<bool> = rows.iter().map(|r| r.kind != Kind::Context).collect();
…
12 unchanged lines
117 265
    for (row, k) in rows.into_iter().zip(keep) {
118 266
        if k {
119 267
            if gap > 0 {
120 -
                out.push(Line { kind: Kind::Gap, old_no: None, new_no: None, text: format!("{gap} unchanged lines") });
268 +
                out.push(Line::new(Kind::Gap, None, None, format!("{gap} unchanged lines")));
121 269
                gap = 0;
122 270
            }
123 271
            out.push(row);
…
2 unchanged lines
126 274
        }
127 275
    }
128 276
    if gap > 0 {
129 -
        out.push(Line { kind: Kind::Gap, old_no: None, new_no: None, text: format!("{gap} unchanged lines") });
277 +
        out.push(Line::new(Kind::Gap, None, None, format!("{gap} unchanged lines")));
130 278
    }
131 279
    out
132 280
}
…
106 unchanged lines
239 387
        assert_eq!(rows.iter().map(|r| r.kind).collect::<Vec<_>>(), [Kind::Add, Kind::Add]);
240 388
    }
241 389
}
390 +
391 +
#[cfg(test)]
392 +
mod refine_tests {
393 +
    use super::*;
394 +
395 +
    fn rows(base: &str, new: &str, opts: Options) -> Vec<Line> {
396 +
        unified_with(base.as_bytes(), &hunks_between(base.as_bytes(), new.as_bytes()), 3, opts)
397 +
    }
398 +
399 +
    #[test]
400 +
    fn changed_words_are_marked_within_a_line() {
401 +
        let r = rows("let answer = 42;\n", "let answer = 43;\n", Options::default());
402 +
        let del = r.iter().find(|l| l.kind == Kind::Del).unwrap();
403 +
        let add = r.iter().find(|l| l.kind == Kind::Add).unwrap();
404 +
        assert_eq!(del.segments.iter().filter(|s| s.changed).map(|s| s.text.as_str()).collect::<Vec<_>>(), ["42"]);
405 +
        assert_eq!(add.segments.iter().filter(|s| s.changed).map(|s| s.text.as_str()).collect::<Vec<_>>(), ["43"]);
406 +
        assert_eq!(add.segments.iter().map(|s| s.text.as_str()).collect::<String>(), add.text, "segments cover the line");
407 +
        // Lines with nothing in common are not broken into words.
408 +
        let r = rows("alpha beta\n", "gamma delta\n", Options::default());
409 +
        assert!(r.iter().all(|l| l.segments.is_empty()));
410 +
    }
411 +
412 +
    #[test]
413 +
    fn whitespace_changes_can_be_hidden() {
414 +
        let base = "fn main() {\n  a();\n  b();\n}\n";
415 +
        let new = "fn main() {\n    a();\n    c();\n}\n";
416 +
        let shown = rows(base, new, Options::default());
417 +
        assert_eq!(shown.iter().filter(|l| l.kind == Kind::Add).count(), 2);
418 +
        let hidden = rows(base, new, Options { ignore_whitespace: true });
419 +
        let kinds: Vec<(Kind, &str)> = hidden.iter().map(|l| (l.kind, l.text.trim())).collect();
420 +
        assert_eq!(
421 +
            kinds,
422 +
            [(Kind::Context, "fn main() {"), (Kind::Context, "a();"), (Kind::Del, "b();"), (Kind::Add, "c();"), (Kind::Context, "}")],
423 +
            "only the real change remains, in order"
424 +
        );
425 +
        let ctx = hidden.iter().find(|l| l.text.trim() == "a();").unwrap();
426 +
        assert_eq!((ctx.old_no, ctx.new_no), (Some(2), Some(2)));
427 +
    }
428 +
}
modified crates/hub/src/web/history.rs +29 −5
…
67 unchanged lines
68 68
}
69 69
70 70
pub fn render_files(files: &[FileDiff], bases: &BTreeMap<String, Vec<u8>>) -> Vec<FileRows> {
71 +
    render_files_with(files, bases, diff::Options::default())
72 +
}
73 +
74 +
/// `render_files` with diff options (hub spec §6.4: `?w=1` hides whitespace changes).
75 +
pub fn render_files_with(files: &[FileDiff], bases: &BTreeMap<String, Vec<u8>>, opts: diff::Options) -> Vec<FileRows> {
71 76
    files
72 77
        .iter()
73 78
        .enumerate()
74 79
        .map(|(i, f)| {
75 80
            let base = bases.get(&f.path).map(Vec::as_slice).unwrap_or(b"");
76 -
            let lines = diff::unified(base, &f.hunks, 3);
81 +
            let lines = diff::unified_with(base, &f.hunks, 3, opts);
77 82
            FileRows {
78 83
                path: f.path.clone(),
79 84
                from: f.from.clone(),
…
15 unchanged lines
95 100
    c: view::ChangeDetail,
96 101
    files: Vec<FileRows>,
97 102
    body_html: String,
103 +
    ignore_ws: bool,
104 +
}
105 +
106 +
/// `?w=1` on a diff page hides whitespace-only changes.
107 +
#[derive(Deserialize, Default)]
108 +
pub struct DiffQuery {
109 +
    #[serde(default)]
110 +
    w: Option<String>,
111 +
}
112 +
113 +
impl DiffQuery {
114 +
    pub fn options(&self) -> diff::Options {
115 +
        diff::Options { ignore_whitespace: self.w.as_deref() == Some("1") }
116 +
    }
98 117
}
99 118
100 119
pub async fn change(
101 120
    State(app): State<Shared>,
102 121
    viewer: Viewer,
103 122
    Path((owner, name, hash)): Path<(String, String, String)>,
123 +
    Query(q): Query<DiffQuery>,
104 124
) -> Result<Page<ChangePage>, Error> {
105 125
    let shell = Shell::new(&app, &format!("/{owner}/{name}/change/{hash}"), &viewer);
106 126
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
…
13 unchanged lines
120 140
        all.extend(c.dependents.iter_mut());
121 141
        super::attribute(&app, &mut all).await;
122 142
    }
123 -
    let files = render_files(&c.files, &c.bases);
143 +
    let files = render_files_with(&c.files, &c.bases, q.options());
144 +
    let ignore_ws = q.options().ignore_whitespace;
124 145
    let body_html =
125 146
        if c.view.body.is_empty() { String::new() } else { format!("<pre class=\"msg\">{}</pre>", render::escape(&c.view.body)) };
126 -
    Ok(Page(ChangePage { shell, repo: RepoShell::new(&app, &row, "changes").with_role(role), c, files, body_html }))
147 +
    Ok(Page(ChangePage { shell, repo: RepoShell::new(&app, &row, "changes").with_role(role), c, files, body_html, ignore_ws }))
127 148
}
128 149
129 150
#[derive(Template)]
…
3 unchanged lines
133 154
    repo: RepoShell,
134 155
    cmp: view::Compare,
135 156
    files: Vec<FileRows>,
157 +
    ignore_ws: bool,
136 158
}
137 159
138 160
/// `/compare/{base}...{head}`.
…
1 unchanged lines
140 162
    State(app): State<Shared>,
141 163
    viewer: Viewer,
142 164
    Path((owner, name, spec)): Path<(String, String, String)>,
165 +
    Query(q): Query<DiffQuery>,
143 166
) -> Result<Page<ComparePage>, Error> {
144 167
    let shell = Shell::new(&app, &format!("/{owner}/{name}/compare/{spec}"), &viewer);
145 168
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
…
12 unchanged lines
158 181
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
159 182
    let mut cmp = cmp;
160 183
    super::attribute(&app, &mut cmp.added.iter_mut().collect::<Vec<_>>()).await;
161 -
    let files = render_files(&cmp.files, &cmp.bases);
162 -
    Ok(Page(ComparePage { shell, repo: RepoShell::new(&app, &row, "changes").with_role(role), cmp, files }))
184 +
    let files = render_files_with(&cmp.files, &cmp.bases, q.options());
185 +
    let ignore_ws = q.options().ignore_whitespace;
186 +
    Ok(Page(ComparePage { shell, repo: RepoShell::new(&app, &row, "changes").with_role(role), cmp, files, ignore_ws }))
163 187
}
modified crates/hub/src/web/proposals.rs +22 −1
…
310 unchanged lines
311 311
    pub conflicted: bool,
312 312
    pub conflicts: Vec<ConflictView>,
313 313
    pub files: Vec<FileRows>,
314 +
    /// The per-file hunks and bases behind `files`, to render again with other options.
315 +
    pub diffs: Vec<oxy_core::repo::FileDiff>,
316 +
    pub bases: std::collections::BTreeMap<String, Vec<u8>>,
314 317
    /// Atom per (path, new line number) of the projected state.
315 318
    pub atoms: HashMap<(String, usize), (String, u32)>,
316 319
    pub sealing_keys: Vec<String>,
…
60 unchanged lines
377 380
                conflicted: p.conflicted,
378 381
                conflicts: p.conflicts,
379 382
                files,
383 +
                diffs: p.files.clone(),
384 +
                bases: p.bases.clone(),
380 385
                atoms,
381 386
                sealing_keys: sealing,
382 387
                touched,
…
106 unchanged lines
489 494
    tab: String,
490 495
    changes: Vec<ChangeView>,
491 496
    files: Vec<FileRows>,
497 +
    diffs: Vec<oxy_core::repo::FileDiff>,
498 +
    bases: std::collections::BTreeMap<String, Vec<u8>>,
499 +
    /// Whitespace-only changes are hidden (`?w=1`).
500 +
    ignore_ws: bool,
492 501
    conflicts: Vec<ConflictView>,
493 502
    conflicted: bool,
494 503
    comments: Vec<CommentView>,
…
26 unchanged lines
521 530
#[derive(Deserialize)]
522 531
pub struct DetailQuery {
523 532
    tab: Option<String>,
533 +
    #[serde(default)]
534 +
    w: Option<String>,
524 535
}
525 536
526 537
/// The page assembler every action ends in; its arguments are the route
…
78 unchanged lines
605 616
        tab,
606 617
        changes,
607 618
        files: loaded.files,
619 +
        diffs: loaded.diffs,
620 +
        bases: loaded.bases,
621 +
        ignore_ws: false,
608 622
        conflicts: loaded.conflicts,
609 623
        conflicted: loaded.conflicted,
610 624
        comments,
…
30 unchanged lines
641 655
    Query(q): Query<DetailQuery>,
642 656
) -> Result<Page<ProposalPage>, Error> {
643 657
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
644 -
    detail(&app, shell, &viewer, &owner, &name, number, q.tab.unwrap_or_else(|| "conversation".into()), None, None).await
658 +
    let mut page = detail(&app, shell, &viewer, &owner, &name, number, q.tab.unwrap_or_else(|| "conversation".into()), None, None).await?;
659 +
    if q.w.as_deref() == Some("1") {
660 +
        // Hide whitespace-only changes: the same hunks, rendered again.
661 +
        page.0.files =
662 +
            super::history::render_files_with(&page.0.diffs, &page.0.bases, crate::render::diff::Options { ignore_whitespace: true });
663 +
        page.0.ignore_ws = true;
664 +
    }
665 +
    Ok(page)
645 666
}
646 667
647 668
// ---------------------------------------------------------------- actions
…
444 unchanged lines
modified crates/hub/tests/issues.rs +35 −0
…
429 unchanged lines
430 430
    assert_eq!(svg.body, get(app, "/avatar/ANDY", None).await.body);
431 431
    assert_eq!(get(app, "/nobody-here", None).await.status, StatusCode::NOT_FOUND);
432 432
}
433 +
434 +
#[tokio::test(flavor = "multi_thread")]
435 +
async fn diffs_mark_words_hide_whitespace_and_collapse() {
436 +
    let h = hub().await;
437 +
    let app = &h.app;
438 +
    let andy = register(app, "andy").await;
439 +
    app.repos.create(&RepoName::parse("andy/demo").unwrap(), None).unwrap();
440 +
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
441 +
    let work = tempfile::tempdir().unwrap();
442 +
    let mut client = Repo::init(work.path()).unwrap();
443 +
    client.config.set("remote.hub.url", &format!("{}/andy/demo", h.base)).unwrap();
444 +
    client.config.set("remote.hub.token", "site").unwrap();
445 +
    client.save_config().unwrap();
446 +
    write(work.path(), "main.rs", b"fn main() {\n  setup();\n  let answer = 42;\n}\n");
447 +
    client.commit("first", &[]).unwrap();
448 +
    write(work.path(), "main.rs", b"fn main() {\n    setup();\n  let answer = 43;\n}\n");
449 +
    let second = client.commit("reindent and change the answer", &[]).unwrap();
450 +
    assert!(client.push("hub", false).unwrap().iter().all(|r| r.ok));
451 +
452 +
    let url = format!("/andy/demo/change/{}", second.to_hex());
453 +
    let page = get(app, &url, Some(&andy)).await;
454 +
    assert_eq!(page.status, StatusCode::OK);
455 +
    assert!(page.body.contains(r#"<details class="file-diff""#), "files collapse: {}", page.body);
456 +
    assert!(page.body.contains(r#"<span class="wx">43</span>"#), "the changed word is marked: {}", page.body);
457 +
    assert!(page.body.contains("hide whitespace changes"));
458 +
    assert!(page.body.contains("data-diff-mode=\"split\""));
459 +
    let adds = |b: &str| b.matches(r#"<tr class="add">"#).count();
460 +
    assert_eq!(adds(&page.body), 2, "re-indent and answer both changed");
461 +
462 +
    let quiet = get(app, &format!("{url}?w=1"), Some(&andy)).await;
463 +
    assert_eq!(adds(&quiet.body), 1, "only the answer changed ignoring whitespace: {}", quiet.body);
464 +
    assert!(quiet.body.contains("show whitespace changes"));
465 +
466 +
    assert!(quiet.body.contains("?w=0"), "the toggle leads back");
467 +
}
modified docs/spec.md +4 −0
…
275 unchanged lines
276 276
277 277
Actions for `write`: open a proposal containing this change, tag it, copy hash. Describing, abandoning, squashing and rebasing are client operations and are not offered here.
278 278
279 +
### 6.8.1 Diffs
280 +
281 +
Every diff (change, compare, proposal files) shows each file as a collapsible section, open unless it has more than 400 rows. Within a block of changed lines, removals come first and each is paired with the addition in the same position; a pair similar enough (at least 40% of its tokens shared, tokens being identifiers, whitespace runs and single other characters) marks the tokens that changed. `?w=1` hides whitespace-only changes: a pair equal once whitespace is removed is shown as one unchanged line. With scripts, a toolbar switches between unified and split (side by side, pairs opposite each other) views, remembered in a `diff` cookie, and collapses or expands every file.
282 +
279 283
### 6.9 Web edit
280 284
281 285
`CodeMirror 6` with the file's language mode. Saving creates a change with the given message signed by the hub delegate on behalf of the user (§5.5) and adds it to the chosen bookmark, or to a new bookmark plus a proposal, exactly as GitHub's "propose changes" flow. The hub refuses with a clear message when the user has not delegated to the hub key (§5.5) or the policy does not authorise them for the target. Binary files and files over `render_limit` cannot be edited in the browser.
…
263 unchanged lines
modified templates/change.html +1 −1
…
20 unchanged lines
21 21
  </dl>
22 22
  <p class="small"><a href="{{ repo.url }}/tree/{{ c.view.hash }}">Browse the tree at this change</a></p>
23 23
  <nav class="files small">{% for f in files %}<a href="#{{ f.anchor }}">{{ f.path }}</a>{% endfor %}</nav>
24 -
  {% call m::diff(files) %}
24 +
  {% call m::diff(files, ignore_ws, "") %}
25 25
</article>
26 26
{% endblock %}
modified templates/compare.html +1 −1
…
7 unchanged lines
8 8
<div class="head"><h1>Compare <small>{{ cmp.base.label }} … {{ cmp.head.label }}</small></h1></div>
9 9
<p class="lead">{{ cmp.added.len()|plural("change", "changes") }} in <strong>{{ cmp.head.label }}</strong> not in <strong>{{ cmp.base.label }}</strong>{% if cmp.missing > 0 %}; {{ cmp.missing }} the other way{% endif %}. {{ files.len()|plural("file", "files") }} differ between the two states.</p>
10 10
{% for c in cmp.added %}{% call m::change_row(repo, c) %}{% endfor %}
11 -
{% call m::diff(files) %}
11 +
{% call m::diff(files, ignore_ws, "") %}
12 12
{% endblock %}
modified templates/proposal.html +5 −4
…
34 unchanged lines
35 35
{% for c in conflicts %}<p><strong>{{ c.path }}</strong> <span class="tag bad">{{ c.kind }}</span></p>{% if let Some(t) = c.marked %}<pre class="marked">{{ t }}</pre>{% endif %}{% endfor %}
36 36
</section>
37 37
{% endif %}
38 +
{% if !files.is_empty() %}{% call m::diff_bar(ignore_ws, "tab=files&") %}{% endif %}
38 39
{% for f in files %}
39 -
<section class="file-diff" id="{{ f.anchor }}">
40 -
  <header><span class="kind kind-{{ f.kind }}">{{ f.kind }}</span> <strong>{{ f.path }}</strong> <span class="counts"><span class="add">+{{ f.added }}</span> <span class="del">−{{ f.removed }}</span></span></header>
40 +
<details class="file-diff" id="{{ f.anchor }}"{% if f.lines.len() <= 400 %} open{% endif %}>
41 +
  <summary><span class="kind kind-{{ f.kind }}">{{ f.kind }}</span> <strong>{{ f.path }}</strong> <span class="counts"><span class="add">+{{ f.added }}</span> <span class="del">−{{ f.removed }}</span></span></summary>
41 42
  {% if f.lines.is_empty() %}<p class="empty">No line changes.</p>{% else %}
42 43
  <table class="code diff">
43 44
  {% for l in f.lines %}
…
1 unchanged lines
45 46
      <td class="n">{% if let Some(n) = l.old_no %}{{ n }}{% endif %}</td>
46 47
      <td class="n">{% if let Some(n) = l.new_no %}{{ n }}{% endif %}</td>
47 48
      <td class="s">{{ l.kind.sign() }}</td>
48 -
      <td class="t"><pre>{{ l.text }}</pre>{% if let Some(n) = l.new_no %}{% if shell.user.is_some() && p.is_open() %}<details class="linecomment"><summary title="Comment on this line">+</summary>
49 +
      <td class="t">{% call m::line_text(l) %}{% if let Some(n) = l.new_no %}{% if shell.user.is_some() && p.is_open() %}<details class="linecomment"><summary title="Comment on this line">+</summary>
49 50
        <form method="post" action="{{ repo.url }}/proposal/{{ p.number }}/comment"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="path" value="{{ f.path }}"><input type="hidden" name="line" value="{{ n }}"><textarea name="body" rows="3" required placeholder="Anchored to this line's atom; it follows the line through rebases."></textarea><button type="submit">COMMENT</button></form></details>{% endif %}{% endif %}</td>
50 51
    </tr>
51 52
    {% if let Some(n) = l.new_no %}{% if let Some(idxs) = placed.get(&crate::web::proposals::key(&f.path, n)) %}
…
8 unchanged lines
60 61
  {% endfor %}
61 62
  </table>
62 63
  {% endif %}
63 -
</section>
64 +
</details>
64 65
{% endfor %}
65 66
{% if !outdated.is_empty() %}
66 67
<section class="panel"><h2>Comments on lines that are gone</h2>
…
85 unchanged lines
modified templates/repo_tabs.html +17 −6
…
36 unchanged lines
37 37
</div>
38 38
{% endmacro %}
39 39
40 -
{% macro diff(files) %}
40 +
{% macro line_text(l) %}<pre>{% if l.segments.is_empty() %}{{ l.text }}{% else %}{% for g in l.segments %}{% if g.changed %}<span class="wx">{{ g.text }}</span>{% else %}{{ g.text }}{% endif %}{% endfor %}{% endif %}</pre>{% endmacro %}
41 +
42 +
{% macro diff_bar(ignore_ws, keep) %}
43 +
<div class="diff-bar">
44 +
  <span class="modes" data-diff-modes hidden><a href="#" data-diff-mode="unified">unified</a><a href="#" data-diff-mode="split">split</a></span>
45 +
  <a class="small" href="?{{ keep }}w={% if ignore_ws %}0{% else %}1{% endif %}">{% if ignore_ws %}show whitespace changes{% else %}hide whitespace changes{% endif %}</a>
46 +
  <a class="small" href="#" data-diff-collapse hidden>collapse all</a>
47 +
</div>
48 +
{% endmacro %}
49 +
50 +
{% macro diff(files, ignore_ws, keep) %}
51 +
{% if !files.is_empty() %}{% call diff_bar(ignore_ws, keep) %}{% endif %}
41 52
{% for f in files %}
42 -
<section class="file-diff" id="{{ f.anchor }}">
43 -
  <header>
53 +
<details class="file-diff" id="{{ f.anchor }}"{% if f.lines.len() <= 400 %} open{% endif %}>
54 +
  <summary>
44 55
    <span class="kind kind-{{ f.kind }}">{{ f.kind }}</span>
45 56
    <strong>{% if let Some(from) = f.from %}{{ from }} → {% endif %}{{ f.path }}</strong>
46 57
    <span class="counts"><span class="add">+{{ f.added }}</span> <span class="del">−{{ f.removed }}</span></span>
47 -
  </header>
58 +
  </summary>
48 59
  {% if f.lines.is_empty() %}<p class="empty">No line changes.</p>{% else %}
49 60
  <table class="code diff">
50 61
  {% for l in f.lines %}
…
1 unchanged lines
52 63
      <td class="n">{% if let Some(n) = l.old_no %}{{ n }}{% endif %}</td>
53 64
      <td class="n">{% if let Some(n) = l.new_no %}{{ n }}{% endif %}</td>
54 65
      <td class="s">{{ l.kind.sign() }}</td>
55 -
      <td class="t"><pre>{{ l.text }}</pre></td>
66 +
      <td class="t">{% call line_text(l) %}</td>
56 67
    </tr>
57 68
  {% endfor %}
58 69
  </table>
59 70
  {% endif %}
60 -
</section>
71 +
</details>
61 72
{% endfor %}
62 73
{% endmacro %}
modified templates/style.css +20 −1
…
191 unchanged lines
192 192
table.diff tr.del td { background: var(--del); color: var(--del-ink); }
193 193
table.diff tr.gap td { background: var(--soft); color: var(--mute); font-family: "Barlow", sans-serif; font-size: 12px; padding: 2px 8px; }
194 194
.file-diff { background: var(--panel); border: 1px solid var(--line); margin: 0 0 14px; }
195 -
.file-diff > header { display: flex; align-items: center; gap: 10px; padding: 8px 12px; background: var(--soft); border-bottom: 1px solid var(--line); }
195 +
.file-diff > header, .file-diff > summary { display: flex; align-items: center; gap: 10px; padding: 8px 12px; background: var(--soft); border-bottom: 1px solid var(--line); }
196 +
.file-diff > summary { cursor: pointer; list-style: none; }
197 +
.file-diff > summary::-webkit-details-marker { display: none; }
198 +
.file-diff > summary::before { content: "▾"; color: var(--mute); width: 1em; }
199 +
.file-diff:not([open]) > summary::before { content: "▸"; }
200 +
.file-diff:not([open]) > summary { border-bottom: 0; }
201 +
.diff-bar { display: flex; gap: 16px; align-items: center; margin: 0 0 10px; }
202 +
.diff-bar .modes a { padding: 3px 10px; border: 1px solid var(--line); color: var(--mute); }
203 +
.diff-bar .modes a.on { color: var(--ink); border-color: var(--acc); }
204 +
table.diff .wx { border-radius: 2px; }
205 +
table.diff tr.add .wx, table.diff td.add .wx { background: color-mix(in srgb, var(--add-ink) 28%, transparent); }
206 +
table.diff tr.del .wx, table.diff td.del .wx { background: color-mix(in srgb, var(--del-ink) 28%, transparent); }
207 +
table.diff.split { table-layout: fixed; }
208 +
table.diff.split col.n { width: 3.5em; }
209 +
table.diff.split td.t { overflow-x: auto; }
210 +
table.diff.split td.t pre { white-space: pre-wrap; word-break: break-word; }
211 +
table.diff.split td.add { background: var(--add); color: var(--add-ink); }
212 +
table.diff.split td.del { background: var(--del); color: var(--del-ink); }
213 +
table.diff.split td.none { background: var(--soft); }
214 +
table.diff.split td.t.mid { border-left: 1px solid var(--line); }
196 215
.file-diff .counts { margin-left: auto; font-family: ui-monospace, monospace; font-size: 12px; }
197 216
.counts .add { color: var(--add-ink); }
198 217
.counts .del { color: var(--del-ink); }
…
140 unchanged lines