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 |