Fold a whole run of plain commits, so only its labelled ends stay

This commit is contained in:
2026-10-14 22:10:47 +02:00
parent e09dabd441
commit bf9b3baa1f
3 changed files with 62 additions and 42 deletions
+5 -5
View File
@@ -156,15 +156,15 @@ async function copyHash() {
} }
function toggleRun(hash: string) { function toggleRun(hash: string) {
const id = built.value.runOf.get(hash); const ids = built.value.runsAt.get(hash);
if (!id) return; if (!ids) return;
const next = new Set(toggled.value); 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; toggled.value = next;
} }
function toggleCollapseAll() { 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; collapseAll.value = !collapseAll.value;
toggled.value = new Set(); toggled.value = new Set();
} }
@@ -249,7 +249,7 @@ onUnmounted(() => {
class="collapse" class="collapse"
:class="{ on: collapseAll && built.collapsible }" :class="{ on: collapseAll && built.collapsible }"
:disabled="!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" @click="toggleCollapseAll"
> >
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"> <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
+28 -17
View File
@@ -15,43 +15,54 @@ describe("buildRows", () => {
expect(r.rows[1].parents).toEqual([{ hash: "p2", dotted: false }]); 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()); const r = buildRows(commits, labeled, true, new Set());
expect(hashes(r)).toEqual(["top", "p1", "p4", "root"]); expect(hashes(r)).toEqual(["top", "root"]);
expect(r.rows[1].parents).toEqual([{ hash: "p4", dotted: true }]); expect(r.rows[0].parents).toEqual([{ hash: "root", dotted: true }]);
expect(r.rows[1].hiddenCount).toBe(2); 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 r = buildRows(commits, labeled, true, new Set());
const id = r.runOf.get("p1"); const [id] = r.runsAt.get("top")!;
expect(id).toBeDefined(); expect(id).toBeDefined();
expect(["p2", "p3", "p4"].map((h) => r.runOf.get(h))).toEqual([id, id, id]); expect(["p1", "p2", "p3", "p4"].map((h) => r.runsAt.get(h))).toEqual([[id], [id], [id], [id]]);
expect(r.runOf.get("top")).toBeUndefined(); expect(r.runsAt.get("root")).toBeUndefined();
}); });
it("a toggled run does the opposite of the global setting", () => { 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, 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", () => { it("does not fold a single plain commit", () => {
const short = chain("top:p1", "p1:p2", "p2:root", "root"); const short = chain("top:p1", "p1:root", "root");
const r = buildRows(short, new Set(["top"]), true, new Set()); 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); 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", () => { 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, false, new Set()).collapsible).toBe(true);
expect(buildRows(commits, labeled, true, new Set()).collapsible).toBe(true); expect(buildRows(commits, labeled, true, new Set()).collapsible).toBe(true);
}); });
it("stops a run at merges, forks and labels", () => { it("stops runs at merges, forks and labels, and folds each side of a merge", () => {
// b is a fork point (two children), m is a merge. // m merges x1 and y1; b is a fork point (two children).
const c = chain("m:x1,y1", "y1:b", "x1:x2", "x2:x3", "x3:b", "b:z", "z"); 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()); 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);
}); });
}); });
+29 -20
View File
@@ -2,16 +2,20 @@ import type { Commit, DisplayRow } from "./types";
export interface RowsResult { export interface RowsResult {
rows: DisplayRow[]; rows: DisplayRow[];
/** Commit hash → id of the run of plain commits it belongs to. */ /**
runOf: Map<string, string>; * 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<string, string[]>;
/** Whether any run is long enough to collapse. */ /** Whether any run is long enough to collapse. */
collapsible: boolean; collapsible: boolean;
} }
/** /**
* Builds the rows to show. A run is a chain of plain commits: one parent, one child, * 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 * no label. A run of two or more is collapsed when `collapseAll` differs from
* `toggled.has(runId)`; only its first and last commits stay, joined by a dotted edge. * `toggled.has(runId)`: all of it is hidden, and the commits at both ends are joined
* by a dotted edge.
*/ */
export function buildRows( export function buildRows(
commits: Commit[], commits: Commit[],
@@ -33,39 +37,44 @@ export function buildRows(
return !!c && c.parents.length === 1 && childCount.get(c.hash) === 1 && !labeled.has(c.hash); return !!c && c.parents.length === 1 && childCount.get(c.hash) === 1 && !labeled.has(c.hash);
}; };
const runOf = new Map<string, string>(); const runsAt = new Map<string, string[]>();
const hidden = new Set<string>(); const hidden = new Set<string>();
/** 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<string, [string, number]>(); const jump = new Map<string, [string, number]>();
let collapsible = false; let collapsible = false;
for (const c of commits) { 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. // c starts a run: follow first parents while they stay plain.
const run = [c.hash]; const run = [c.hash];
let next = c.parents[0]; let next = c.parents[0];
while (plain(next) && !runOf.has(next)) { while (plain(next)) {
run.push(next); run.push(next);
next = byHash.get(next)!.parents[0]; next = byHash.get(next)!.parents[0];
} }
if (run.length < 2) continue;
collapsible = true;
const id = c.hash; const id = c.hash;
for (const h of run) runOf.set(h, id); for (const h of run) runsAt.set(h, [id]);
if (run.length >= 3) collapsible = true; runsAt.set(above!, [...(runsAt.get(above!) ?? []), id]);
if (run.length >= 3 && collapseAll !== toggled.has(id)) { if (collapseAll !== toggled.has(id)) {
for (const h of run.slice(1, -1)) hidden.add(h); for (const h of run) hidden.add(h);
jump.set(c.hash, [run[run.length - 1], run.length - 2]); jump.set(c.hash, [next, run.length]);
} }
} }
const rows: DisplayRow[] = []; const rows: DisplayRow[] = [];
for (const c of commits) { for (const c of commits) {
if (hidden.has(c.hash)) continue; if (hidden.has(c.hash)) continue;
const j = jump.get(c.hash); let hiddenCount = 0;
rows.push( const parents = c.parents.map((hash) => {
j const j = jump.get(hash);
? { commit: c, parents: [{ hash: j[0], dotted: true }], hiddenCount: j[1] } if (!j) return { hash, dotted: false };
: { commit: c, parents: c.parents.map((hash) => ({ 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 };
} }