diff --git a/src/App.vue b/src/App.vue index 731e8b3..42ffb0c 100644 --- a/src/App.vue +++ b/src/App.vue @@ -156,15 +156,15 @@ async function copyHash() { } function toggleRun(hash: string) { - const id = built.value.runOf.get(hash); - if (!id) return; + const ids = built.value.runsAt.get(hash); + if (!ids) return; const next = new Set(toggled.value); - if (!next.delete(id)) next.add(id); + for (const id of ids) if (!next.delete(id)) next.add(id); toggled.value = next; } function toggleCollapseAll() { - if (!built.value.collapsible) return showToast("nothing to fold: no run of 3 or more plain commits"); + if (!built.value.collapsible) return showToast("nothing to fold: no 2 or more plain commits in a row"); collapseAll.value = !collapseAll.value; toggled.value = new Set(); } @@ -249,7 +249,7 @@ onUnmounted(() => { class="collapse" :class="{ on: collapseAll && built.collapsible }" :disabled="!built.collapsible" - :title="built.collapsible ? 'c' : 'Nothing to fold: no run of 3 or more commits without a branch, tag or merge'" + :title="built.collapsible ? 'c' : 'Nothing to fold: no 2 or more commits in a row without a branch, tag, fork or merge'" @click="toggleCollapseAll" > diff --git a/src/lib/collapse.test.ts b/src/lib/collapse.test.ts index d5b041e..d42abca 100644 --- a/src/lib/collapse.test.ts +++ b/src/lib/collapse.test.ts @@ -15,43 +15,54 @@ describe("buildRows", () => { expect(r.rows[1].parents).toEqual([{ hash: "p2", dotted: false }]); }); - it("keeps the first and last commit of a run and joins them with a dotted edge", () => { + it("hides the whole run and joins its two ends with a dotted edge", () => { const r = buildRows(commits, labeled, true, new Set()); - expect(hashes(r)).toEqual(["top", "p1", "p4", "root"]); - expect(r.rows[1].parents).toEqual([{ hash: "p4", dotted: true }]); - expect(r.rows[1].hiddenCount).toBe(2); + expect(hashes(r)).toEqual(["top", "root"]); + expect(r.rows[0].parents).toEqual([{ hash: "root", dotted: true }]); + expect(r.rows[0].hiddenCount).toBe(4); }); - it("gives every commit of the run the same run id", () => { + it("finds the run from its members and from the commit above it", () => { const r = buildRows(commits, labeled, true, new Set()); - const id = r.runOf.get("p1"); + const [id] = r.runsAt.get("top")!; expect(id).toBeDefined(); - expect(["p2", "p3", "p4"].map((h) => r.runOf.get(h))).toEqual([id, id, id]); - expect(r.runOf.get("top")).toBeUndefined(); + expect(["p1", "p2", "p3", "p4"].map((h) => r.runsAt.get(h))).toEqual([[id], [id], [id], [id]]); + expect(r.runsAt.get("root")).toBeUndefined(); }); it("a toggled run does the opposite of the global setting", () => { - const id = buildRows(commits, labeled, true, new Set()).runOf.get("p1")!; + const [id] = buildRows(commits, labeled, true, new Set()).runsAt.get("top")!; expect(hashes(buildRows(commits, labeled, true, new Set([id])))).toHaveLength(6); - expect(hashes(buildRows(commits, labeled, false, new Set([id])))).toEqual(["top", "p1", "p4", "root"]); + expect(hashes(buildRows(commits, labeled, false, new Set([id])))).toEqual(["top", "root"]); }); - it("does not collapse runs shorter than three", () => { - const short = chain("top:p1", "p1:p2", "p2:root", "root"); + it("does not fold a single plain commit", () => { + const short = chain("top:p1", "p1:root", "root"); const r = buildRows(short, new Set(["top"]), true, new Set()); - expect(hashes(r)).toHaveLength(4); + expect(hashes(r)).toHaveLength(3); expect(r.collapsible).toBe(false); }); + it("folds two plain commits", () => { + const two = chain("top:p1", "p1:p2", "p2:root", "root"); + expect(hashes(buildRows(two, new Set(["top"]), true, new Set()))).toEqual(["top", "root"]); + }); + it("reports when a run can collapse, whether collapsed or not", () => { expect(buildRows(commits, labeled, false, new Set()).collapsible).toBe(true); expect(buildRows(commits, labeled, true, new Set()).collapsible).toBe(true); }); - it("stops a run at merges, forks and labels", () => { - // b is a fork point (two children), m is a merge. - const c = chain("m:x1,y1", "y1:b", "x1:x2", "x2:x3", "x3:b", "b:z", "z"); + it("stops runs at merges, forks and labels, and folds each side of a merge", () => { + // m merges x1 and y1; b is a fork point (two children). + const c = chain("m:x1,y1", "y1:y2", "y2:b", "x1:x2", "x2:x3", "x3:b", "b:z", "z"); const r = buildRows(c, new Set(["m"]), true, new Set()); - expect(hashes(r)).toEqual(["m", "y1", "x1", "x3", "b", "z"]); + expect(hashes(r)).toEqual(["m", "b", "z"]); + expect(r.rows[0].parents).toEqual([ + { hash: "b", dotted: true }, + { hash: "b", dotted: true }, + ]); + expect(r.rows[0].hiddenCount).toBe(5); + expect(r.runsAt.get("m")).toHaveLength(2); }); }); diff --git a/src/lib/collapse.ts b/src/lib/collapse.ts index ccd20f4..d929db9 100644 --- a/src/lib/collapse.ts +++ b/src/lib/collapse.ts @@ -2,16 +2,20 @@ import type { Commit, DisplayRow } from "./types"; export interface RowsResult { rows: DisplayRow[]; - /** Commit hash → id of the run of plain commits it belongs to. */ - runOf: Map; + /** + * Commit hash → ids of the runs it can fold: the run a plain commit belongs to, + * or the runs right below a commit that is not plain. + */ + runsAt: Map; /** Whether any run is long enough to collapse. */ collapsible: boolean; } /** * Builds the rows to show. A run is a chain of plain commits: one parent, one child, - * no label. A run of three or more is collapsed when `collapseAll` differs from - * `toggled.has(runId)`; only its first and last commits stay, joined by a dotted edge. + * no label. A run of two or more is collapsed when `collapseAll` differs from + * `toggled.has(runId)`: all of it is hidden, and the commits at both ends are joined + * by a dotted edge. */ export function buildRows( commits: Commit[], @@ -33,39 +37,44 @@ export function buildRows( return !!c && c.parents.length === 1 && childCount.get(c.hash) === 1 && !labeled.has(c.hash); }; - const runOf = new Map(); + const runsAt = new Map(); const hidden = new Set(); - /** First commit of a collapsed run → [last commit, number hidden]. */ + /** First commit of a collapsed run → [the commit below the run, number hidden]. */ const jump = new Map(); let collapsible = false; for (const c of commits) { - if (!plain(c.hash) || plain(childOf.get(c.hash))) continue; + const above = childOf.get(c.hash); + if (!plain(c.hash) || plain(above)) continue; // c starts a run: follow first parents while they stay plain. const run = [c.hash]; let next = c.parents[0]; - while (plain(next) && !runOf.has(next)) { + while (plain(next)) { run.push(next); next = byHash.get(next)!.parents[0]; } + if (run.length < 2) continue; + collapsible = true; const id = c.hash; - for (const h of run) runOf.set(h, id); - if (run.length >= 3) collapsible = true; - if (run.length >= 3 && collapseAll !== toggled.has(id)) { - for (const h of run.slice(1, -1)) hidden.add(h); - jump.set(c.hash, [run[run.length - 1], run.length - 2]); + for (const h of run) runsAt.set(h, [id]); + runsAt.set(above!, [...(runsAt.get(above!) ?? []), id]); + if (collapseAll !== toggled.has(id)) { + for (const h of run) hidden.add(h); + jump.set(c.hash, [next, run.length]); } } const rows: DisplayRow[] = []; for (const c of commits) { if (hidden.has(c.hash)) continue; - const j = jump.get(c.hash); - rows.push( - j - ? { commit: c, parents: [{ hash: j[0], dotted: true }], hiddenCount: j[1] } - : { commit: c, parents: c.parents.map((hash) => ({ hash, dotted: false })) }, - ); + let hiddenCount = 0; + const parents = c.parents.map((hash) => { + const j = jump.get(hash); + if (!j) return { hash, dotted: false }; + hiddenCount += j[1]; + return { hash: j[0], dotted: true }; + }); + rows.push(hiddenCount ? { commit: c, parents, hiddenCount } : { commit: c, parents }); } - return { rows, runOf, collapsible }; + return { rows, runsAt, collapsible }; }