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) {
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"
>
<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 }]);
});
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);
});
});
+29 -20
View File
@@ -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<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. */
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<string, string>();
const runsAt = new Map<string, 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]>();
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 };
}