DiffView
The lines added and removed between two texts, unified or side by side, with line numbers.
Demo
config/app.toml
+4 added−4 removed
| Line before | Line after | Change | Text |
|---|---|---|---|
| 1 | 1 | [server] | |
| 2 | |||
| 3 | |||
| 2 | Added: port = 8443 | ||
| 3 | Added: workers = 8 | ||
| 4 | 4 | log = "info" | |
| 5 | Added: tls = true | ||
| 5 | 6 | ||
| 6 | 7 | [database] | |
| 7 | |||
| 8 | |||
| 8 | Added: url = "postgres://db.internal/app" | ||
| 9 | 9 | ||
| 10 | 10 | [mail] | |
| 11 | 11 | from = "hello@example.com" |
app.toml (main)app.toml (deploy)
+4 added−4 removed
| Line before | Before | Line after | After |
|---|---|---|---|
| 1 | [server] | 1 | [server] |
| 2 | 2 | Added: port = 8443 | |
| 3 | 3 | Added: workers = 8 | |
| 4 | log = "info" | 4 | log = "info" |
| 5 | Added: tls = true | ||
| 5 | 6 | ||
| 6 | [database] | 7 | [database] |
| 7 | 8 | Added: url = "postgres://db.internal/app" | |
| 8 | |||
| 9 | 9 | ||
| 10 | [mail] | 10 | [mail] |
| 11 | from = "hello@example.com" | 11 | from = "hello@example.com" |
checkout.qwe
+1 added−1 removed
| Line before | Line after | Change | Text |
|---|---|---|---|
| ⋯ 9 unchanged lines | |||
| 10 | 10 | line 10 | |
| 11 | 11 | line 11 | |
| 12 | |||
| 12 | Added: total = price * qty * (1 + tax) | ||
| 13 | 13 | line 13 | |
| 14 | 14 | line 14 | |
| ⋯ 16 unchanged lines | |||
README.md
+0 added−0 removed
No changes
Get it
It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/diff_view.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/diff_view.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/diff_view.qwe
let diff_view_demo_before = `[server]port = 8080workers = 4log = "info"[database]url = "sqlite://app.db"pool = 5[mail]from = "hello@example.com"`let diff_view_demo_after = `[server]port = 8443workers = 8log = "info"tls = true[database]url = "postgres://db.internal/app"[mail]from = "hello@example.com"`let diff_view_demo_old = for i in range(1, 31) => i == 12 ? " total = price * qty" : ` line ${i}`let diff_view_demo_new = for i in range(1, 31) => i == 12 ? " total = price * qty * (1 + tax)" : ` line ${i}`view DiffViewDemo() = <div.demo-stack> <DiffView before={diff_view_demo_before} after={diff_view_demo_after} before_name="config/app.toml" after_name="config/app.toml"/> <DiffView before={diff_view_demo_before} after={diff_view_demo_after} mode="split" before_name="app.toml (main)" after_name="app.toml (deploy)"/> <DiffView before={join(diff_view_demo_old, "\n")} after={join(diff_view_demo_new, "\n")} context={2} before_name="checkout.qwe"/> <DiffView before="same text" after="same text" before_name="README.md"/> </div>Source
ui/diff_view.qwe
// DiffView: what changed between two texts, line by line: removed lines in red,// added ones in green, with both files' line numbers. Worked out on the server// (the longest common subsequence of the lines), so it needs no JavaScript.//// <DiffView before={old_config} after={new_config} before_name="v1/app.toml" after_name="v2/app.toml"/>// <DiffView before={a} after={b} mode="split" context={3}/>//// before, after: the two texts. mode: "unified" (default; one column, - and + lines)// or "split" (before on the left, after on the right, changed lines side by side).// before_name, after_name: the files' names, in a header with the count of lines// added and removed. context: show only this many unchanged lines around each// change, and say how many were left out in between (all of them by default).// label: what screen readers call the table ("Changes", or "Changes to" the name).// empty: what it says when the texts are the same ("No changes").// Lines are <del> and <ins>, so screen readers say which is which. Made for texts// of up to a few hundred changed lines; lines the same at both ends cost nothing.import ui/theme.qwe use ui_clslet diff_view_css = css`.ui-diff { min-width: 0; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 14px/1.4 var(--ui-font); overflow: hidden; }.ui-diff-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; padding: 8px 14px; border-bottom: 1px solid var(--ui-border); background: var(--ui-subtle); }.ui-diff-names { flex: 1; min-width: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; font: 500 13px/1.4 var(--ui-mono); overflow-wrap: anywhere; }.ui-diff-names span[aria-hidden] { color: var(--ui-muted); }.ui-diff-stat { display: flex; gap: 8px; font: 600 13px/1 var(--ui-mono); font-variant-numeric: tabular-nums; }.ui-diff-stat-add { color: var(--ui-success); }.ui-diff-stat-del { color: var(--ui-danger); }.ui-diff-scroll { overflow: auto; }.ui-diff-scroll:focus-visible { outline: none; box-shadow: inset var(--ui-focus); }.ui-diff table { width: 100%; border-collapse: collapse; font: 13px/1.6 var(--ui-mono); }.ui-diff td { padding: 0 10px; vertical-align: top; white-space: pre; }.ui-diff .ui-diff-n { width: 1%; min-width: 3ch; padding: 0 8px; text-align: right; color: var(--ui-muted); user-select: none; -webkit-user-select: none; font-variant-numeric: tabular-nums; }.ui-diff .ui-diff-sign { width: 1%; padding: 0 2px 0 6px; color: var(--ui-muted); user-select: none; -webkit-user-select: none; }.ui-diff ins, .ui-diff del { text-decoration: none; color: inherit; }.ui-diff-add { background: color-mix(in srgb, var(--ui-success) 12%, transparent); }.ui-diff-add .ui-diff-n, .ui-diff-add .ui-diff-sign { background: color-mix(in srgb, var(--ui-success) 18%, transparent); color: var(--ui-success); }.ui-diff-del { background: color-mix(in srgb, var(--ui-danger) 11%, transparent); }.ui-diff-del .ui-diff-n, .ui-diff-del .ui-diff-sign { background: color-mix(in srgb, var(--ui-danger) 17%, transparent); color: var(--ui-danger); }.ui-diff-none { background: var(--ui-subtle); }.ui-diff-split td.ui-diff-n:nth-child(3) { border-left: 1px solid var(--ui-border); }.ui-diff-split .ui-diff-text { width: 50%; }.ui-diff-fold td { padding: 3px 12px; background: var(--ui-subtle); color: var(--ui-muted); font: 12px/1.6 var(--ui-font); border-block: 1px solid var(--ui-border); }.ui-diff-empty { padding: 18px 14px; color: var(--ui-muted); text-align: center; }`// The text's lines, without the empty one after a final line break.fn diff_view_lines(text: str) -> list { if text == "" { return [] } let ls = split(replace(text, "\r\n", "\n"), "\n") len(ls) > 1 && last(ls) == "" ? take(ls, len(ls) - 1) : ls}// The steps from `a` to `b`: maps of { kind: "same" | "del" | "add", text, a, b }, where// a and b are the line's numbers in each text (null where it is not there).fn diff_view_ops(a: list, b: list) -> list { let n = len(a) let m = len(b) let p = 0 while p < n && p < m && a[p] == b[p] { p += 1 } let s = 0 while s < n - p && s < m - p && a[n - 1 - s] == b[m - 1 - s] { s += 1 } let mid_a = slice(a, p, n - s) let mid_b = slice(b, p, m - s) let ma = len(mid_a) let mb = len(mid_b) // lengths[i][j]: the longest common subsequence of mid_a from i and mid_b from j. let zero = for j in range(0, mb + 1) => 0 let lengths = [zero] let next = zero let i = ma - 1 while i >= 0 { let ai = mid_a[i] let row = reverse(reduce(range(0, mb), [0], (acc, k) => { let j = mb - 1 - k push(acc, ai == mid_b[j] ? next[j + 1] + 1 : max([next[j], last(acc)])) })) lengths = push(lengths, row) next = row i -= 1 } let table = reverse(lengths) let ops = for k in range(0, p) => { kind: "same", text: a[k], a: k + 1, b: k + 1 } let x = 0 let y = 0 while x < ma || y < mb { if x < ma && y < mb && mid_a[x] == mid_b[y] { ops = push(ops, { kind: "same", text: mid_a[x], a: p + x + 1, b: p + y + 1 }) x += 1 y += 1 } else if y >= mb || (x < ma && table[x + 1][y] >= table[x][y + 1]) { ops = push(ops, { kind: "del", text: mid_a[x], a: p + x + 1, b: null }) x += 1 } else { ops = push(ops, { kind: "add", text: mid_b[y], a: null, b: p + y + 1 }) y += 1 } } ops + (for k in range(0, s) => { kind: "same", text: a[n - s + k], a: n - s + k + 1, b: m - s + k + 1 })}// The ops with long runs of unchanged lines folded to `context` lines around each// change: a fold is { kind: "fold", count }.fn diff_view_fold(ops: list, context: int?) -> list { if context == null { return ops } let marks = range(0, len(ops)) | filter((i) => ops[i].kind != "same") let near = (i: int) => any(marks, (c) => abs(c - i) <= context) let acc = reduce(range(0, len(ops)), { out: [], hidden: 0 }, (acc, i) => ops[i].kind != "same" || near(i) ? { out: (acc.hidden > 0 ? push(acc.out, { kind: "fold", count: acc.hidden }) : acc.out) + [ops[i]], hidden: 0 } : { out: acc.out, hidden: acc.hidden + 1 }) acc.hidden > 0 ? push(acc.out, { kind: "fold", count: acc.hidden }) : acc.out}// Rows for the split mode: { left, right } (an op or null), runs of removed and// added lines paired side by side; a fold is { fold: count }.fn diff_view_pairs(ops: list) -> list { let flush = (acc: map) => acc.rows + (for k in range(0, max([len(acc.dels), len(acc.adds)])) => { left: acc.dels[k], right: acc.adds[k] }) let acc = reduce(ops, { rows: [], dels: [], adds: [] }, (acc, o) => o.kind == "del" ? (len(acc.adds) > 0 ? { rows: flush(acc), dels: [o], adds: [] } : { rows: acc.rows, dels: push(acc.dels, o), adds: acc.adds }) : o.kind == "add" ? { rows: acc.rows, dels: acc.dels, adds: push(acc.adds, o) } : { rows: push(flush(acc), o.kind == "fold" ? { fold: o.count } : { left: o, right: o }), dels: [], adds: [] }) flush(acc)}fn diff_view_fold_text(count: int) -> str = `⋯ ${count} unchanged line${count == 1 ? "" : "s"}`// One side's text: <del>, <ins> or plain, with what screen readers say first.fn diff_view_text(o: map?) = o == null ? null : o.kind == "del" ? <del><span.ui-sr>Removed: </span>{o.text}</del> : o.kind == "add" ? <ins><span.ui-sr>Added: </span>{o.text}</ins> : o.textview DiffView(before: str, after: str, mode: str = "unified", before_name: str? = null, after_name: str? = null, context: int? = null, label: str? = null, empty: str = "No changes", class: str? = null) { style global diff_view_css let ops = diff_view_ops(diff_view_lines(before), diff_view_lines(after)) let added = count(ops, (o) => o.kind == "add") let removed = count(ops, (o) => o.kind == "del") let shown = diff_view_fold(ops, context) let name = after_name ?? before_name let split_mode = mode == "split" <div class={ui_cls(["ui-diff", split_mode && "ui-diff-split", class])}> <div.ui-diff-head> <div.ui-diff-names> {before_name != null && <span>{before_name}</span>} {before_name != null && after_name != null && after_name != before_name && <span aria-hidden="true">→</span>} {after_name != null && after_name != before_name && <span>{after_name}</span>} </div> <div.ui-diff-stat> <span.ui-diff-stat-add>+{added}<span.ui-sr> added</span></span> <span.ui-diff-stat-del>−{removed}<span.ui-sr> removed</span></span> </div> </div> {added + removed == 0 ? <p.ui-diff-empty style="margin: 0">{empty}</p> : <div.ui-diff-scroll tabindex="0" role="region" aria-label={label ?? (name != null ? `Changes to ${name}` : "Changes")}> <table aria-label={label ?? (name != null ? `Changes to ${name}` : "Changes")}> <thead class="ui-sr"> {split_mode ? <tr><th scope="col">Line before</th><th scope="col">Before</th><th scope="col">Line after</th><th scope="col">After</th></tr> : <tr><th scope="col">Line before</th><th scope="col">Line after</th><th scope="col">Change</th><th scope="col">Text</th></tr>} </thead> <tbody> {split_mode ? (for r in diff_view_pairs(shown) => r.fold != null ? <tr.ui-diff-fold><td colspan="4">{diff_view_fold_text(r.fold)}</td></tr> : <tr> <td class={ui_cls(["ui-diff-n", r.left == null ? "ui-diff-none" : r.left.kind == "del" && "ui-diff-del"])}>{r.left.a}</td> <td class={ui_cls(["ui-diff-text", r.left == null ? "ui-diff-none" : r.left.kind == "del" && "ui-diff-del"])}>{diff_view_text(r.left)}</td> <td class={ui_cls(["ui-diff-n", r.right == null ? "ui-diff-none" : r.right.kind == "add" && "ui-diff-add"])}>{r.right.b}</td> <td class={ui_cls(["ui-diff-text", r.right == null ? "ui-diff-none" : r.right.kind == "add" && "ui-diff-add"])}>{diff_view_text(r.right)}</td> </tr>) : (for o in shown => o.kind == "fold" ? <tr.ui-diff-fold><td colspan="4">{diff_view_fold_text(o.count)}</td></tr> : <tr class={o.kind == "same" ? null : `ui-diff-${o.kind}`}> <td.ui-diff-n>{o.a}</td> <td.ui-diff-n>{o.b}</td> <td.ui-diff-sign aria-hidden="true">{o.kind == "add" ? "+" : o.kind == "del" ? "−" : " "}</td> <td.ui-diff-text>{diff_view_text(o)}</td> </tr>)} </tbody> </table> </div>} </div>}Related
JsonTableJSON as a table: open nested objects and arrays in place or as the table, search at every depth, export as JSON or CSV, thousands of lines kept fast.9 views · 7 downloadsLogViewerLog lines with each level in its colour, buttons to show or hide levels, a search that marks matches, wrapping and following the end.8 views · 7 downloadsTerminalOutputA terminal-like window with a title, commands after a prompt and their output, errors in red, for docs and tutorials.7 views · 6 downloadsActivityFeedA stream of what people did, with their pictures, an icon for each kind of action, when, quotes and day headings.5 views · 7 downloadsAuditLogWho did what to what and when, newest first, filtered by person and action, searched, and paged.5 views · 7 downloadsBarcodeA Code 128 barcode drawn as SVG from text, switching to the compact digit set for long numbers, with the text under it.4 views · 9 downloads
Thread
Thread 0 posts
On DiffView: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.