forks/sorcery

Mirror of https://git.t4t.associates/char/sorcery

git clone https://git.gae.moe/forks/sorcery

Charlotte Somshow renames and stat overview in the commit diff view768100d

main
7.6 KiB198 linesraw
1import { treeDiff, type FileChange } from "./git/walk.ts";
2import { diffLines } from "./git/diff.ts";
3import type { Site, View } from "./route.ts";
4import { href, topbar } from "./tree.tsx";
5import { humanSize, identityDate, looksBinary, looksGenerated, plural, utf8 } from "./util.ts";
6
7const highlightedLines = (path: string, source: string) =>
8  import("./highlight.ts").then(module => module.highlightedLines(path, source));
9
10export async function commitView(site: Site, view: View): Promise<Node[]> {
11  const { repo } = site;
12  const { oid } = view;
13  await repo.prefetch([oid], { smart: "commit-diff" });
14  const commit = await repo.commit(oid);
15  const parentTree = commit.parents.length > 0
16    ? (await repo.commit(commit.parents[0])).tree
17    : null;
18  const changes = await treeDiff(repo, parentTree, commit.tree);
19  const blobs = changes.flatMap(change =>
20    [change.oldOid, change.newOid].filter(oid => oid !== null)
21  );
22  await repo.prefetch(blobs, { depth: 0 });
23  const files = await Promise.all(changes.map(change => fileSection(site, oid, change)));
24  const adds = files.reduce((n, f) => n + f.adds, 0);
25  const dels = files.reduce((n, f) => n + f.dels, 0);
26  const index = (
27    <ol class="diff-index">
28      {changes.map((change, at) => {
29        const file = files[at];
30        return (
31          <li>
32            {file.bytes
33              ? <span class="byte-count">{byteCounts(file.bytes)}</span>
34              : [
35                <span class="add-count">+{String(file.adds)}</span>,
36                <span class="del-count">-{String(file.dels)}</span>,
37              ]}
38            <button
39              type="button"
40              _onclick={() => {
41                file.element.open = true;
42                file.element.scrollIntoView({ block: "start" });
43              }}
44            >
45              <span class={`status ${change.status}`}>{change.status[0].toUpperCase()}</span>
46              <span>{change.from ? `${change.from} \u2192 ${change.path}` : change.path}</span>
47            </button>
48          </li>
49        );
50      })}
51    </ol>
52  ) as HTMLElement;
53
54  const [subject, ...rest] = commit.message.trimEnd().split("\n");
55  const body = rest.join("\n").trim();
56
57  const meta = (<table class="commit-meta" />) as HTMLTableElement;
58  const row = (key: string, value: Node | string) =>
59    meta.append(
60      <tr>
61        <th>{key}</th>
62        <td _also={el => el.append(typeof value === "string" ? document.createTextNode(value) : value)} />
63      </tr>,
64    );
65  row("author", `${commit.author.name} <${commit.author.email}>`);
66  row("date", (
67    <time dateTime={new Date(commit.author.time * 1000).toISOString()}>
68      {identityDate(commit.author)}
69    </time>
70  ));
71  row("commit", <span class="sha">{oid}</span>);
72  for (const parent of commit.parents) {
73    row("parent", <a class="sha" href={`#commit/${parent}`}>{parent}</a>);
74  }
75  if (commit.changeId) row("change-id", <span class="cid">{commit.changeId}</span>);
76
77  const browse = <a href={await href(site, { kind: "tree", oid, path: [] })}>browse files</a>;
78  return [
79    await topbar(site, view, [], [browse]),
80    <h2 class="commit-subject">{subject}</h2>,
81    ...(body ? [<pre class="commit-body">{body}</pre>] : []),
82    meta,
83    <p class="meta diffstat">
84      <span class="add-count">+{String(adds)}</span>
85      <span class="del-count">-{String(dels)}</span>
86      <span>{plural(changes.length, "changed file")}</span>
87    </p>,
88    ...(changes.length > 0 ? [index] : []),
89    ...files.map(f => f.element),
90  ];
91}
92
93interface FileSection {
94  element: HTMLDetailsElement;
95  adds: number;
96  dels: number;
97  /** old and new sizes for a binary file, which has no line counts to show */
98  bytes: { old: number | null; new: number | null } | null;
99}
100
101function byteCounts(bytes: NonNullable<FileSection["bytes"]>): string {
102  if (bytes.old !== null && bytes.new !== null) {
103    return `${humanSize(bytes.old)} \u2192 ${humanSize(bytes.new)}`;
104  }
105  return bytes.new !== null ? `+${humanSize(bytes.new)}` : `-${humanSize(bytes.old!)}`;
106}
107
108async function fileSection(site: Site, revision: string, change: FileChange): Promise<FileSection> {
109  const { repo } = site;
110  const generated = looksGenerated(change.path);
111  const actions = (<span class="file-actions" />) as HTMLElement;
112  if (change.status !== "modified") {
113    actions.append(<span class={`status ${change.status}`}>{change.status}</span>);
114  }
115  if (change.newOid) {
116    const view = await href(site, { kind: "blob", oid: revision, path: change.path.split("/") });
117    actions.append(<a class="status" href={view}>view</a>);
118  }
119  if (generated) actions.append(<span class="generated-note">generated</span>);
120  const header = (
121    <summary>
122      <span class="file-summary-content">
123        <span class="path">{change.from ? `${change.from} \u2192 ${change.path}` : change.path}</span>
124        {actions}
125      </span>
126    </summary>
127  ) as HTMLElement;
128  const section = (<details class="file-diff">{header}</details>) as HTMLDetailsElement;
129  section.open = !generated;
130
131  const [oldData, newData] = await Promise.all([
132    change.oldOid ? repo.blob(change.oldOid) : Promise.resolve(new Uint8Array()),
133    change.newOid ? repo.blob(change.newOid) : Promise.resolve(new Uint8Array()),
134  ]);
135  if (looksBinary(oldData) || looksBinary(newData)) {
136    const bytes = {
137      old: change.oldOid ? oldData.length : null,
138      new: change.newOid ? newData.length : null,
139    };
140    actions.prepend(<span class="byte-count">{byteCounts(bytes)}</span>);
141    section.append(<p class="meta binary-note">binary file</p>);
142    return { element: section, adds: 0, dels: 0, bytes };
143  }
144
145  const oldText = utf8.decode(oldData);
146  const newText = utf8.decode(newData);
147  const hunks = diffLines(oldText, newText);
148  const cells: Array<{ cell: HTMLTableCellElement; old: boolean; line: number }> = [];
149  let adds = 0;
150  let dels = 0;
151  const table = (<table class="diff" />) as HTMLTableElement;
152  for (const hunk of hunks) {
153    table.append(
154      <tr class="hunk">
155        <td class="ln" />
156        <td class="ln" />
157        <td>{`@@ -${hunk.aStart},${hunk.aLines} +${hunk.bStart},${hunk.bLines} @@`}</td>
158      </tr>,
159    );
160    let aLine = hunk.aStart;
161    let bLine = hunk.bStart;
162    for (const line of hunk.lines) {
163      const kind = line.sign === "+" ? "add" : line.sign === "-" ? "del" : "ctx";
164      const old = line.sign === "-";
165      const sourceLine = (old ? aLine : bLine) - 1;
166      if (line.sign === "+") adds++;
167      if (line.sign === "-") dels++;
168      const cell = (<td class="text">{line.text}</td>) as HTMLTableCellElement;
169      cells.push({ cell, old, line: sourceLine });
170      table.append(
171        <tr class={kind}>
172          <td class="ln">{line.sign === "+" ? "" : String(aLine++)}</td>
173          <td class="ln">{line.sign === "-" ? "" : String(bLine++)}</td>
174          {/* the +/- marker is CSS ::before so copied text stays clean */}
175          {cell}
176        </tr>,
177      );
178    }
179  }
180  actions.prepend(
181    <span class="add-count">+{String(adds)}</span>,
182    <span class="del-count">-{String(dels)}</span>,
183  );
184  if (hunks.length > 0) section.append(table);
185  else if (change.status === "renamed") {
186    section.append(<p class="meta binary-note">renamed without changes</p>);
187  }
188  void Promise.all([
189    change.oldOid ? highlightedLines(change.path, oldText) : Promise.resolve(null),
190    change.newOid ? highlightedLines(change.path, newText) : Promise.resolve(null),
191  ]).then(([oldLines, newLines]) => {
192    for (const target of cells) {
193      const line = (target.old ? oldLines : newLines)?.[target.line];
194      if (line) target.cell.replaceChildren(...line.map(node => node.cloneNode(true)));
195    }
196  }).catch(err => console.warn("arborium:", err));
197  return { element: section, adds, dels, bytes: null };
198}