char/sorcery

static-files based git repo viewer

git clone https://git.t4t.associates/char/sorcery

Charlotte Somexperiment: soft line wrapping on source code rendersf92b8f4

main
5.8 KiB149 linesraw
1import { treeDiff, type FileChange } from "./git/walk.ts";
2import { diffLines } from "./git/diff.ts";
3import { topbar } from "./historical.tsx";
4import { href, type Site, type View } from "./route.ts";
5import { 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
27  const [subject, ...rest] = commit.message.trimEnd().split("\n");
28  const body = rest.join("\n").trim();
29
30  const meta = (<table class="commit-meta" />) as HTMLTableElement;
31  const row = (key: string, value: Node | string) =>
32    meta.append(
33      <tr>
34        <th>{key}</th>
35        <td _also={el => el.append(typeof value === "string" ? document.createTextNode(value) : value)} />
36      </tr>,
37    );
38  row("author", `${commit.author.name} <${commit.author.email}>`);
39  row("date", identityDate(commit.author));
40  row("commit", <span class="sha">{oid}</span>);
41  for (const parent of commit.parents) {
42    row("parent", <a class="sha" href={`#commit/${parent}`}>{parent}</a>);
43  }
44  if (commit.changeId) row("change-id", <span class="cid">{commit.changeId}</span>);
45
46  const browse = <a href={await href(site, { kind: "tree", oid, path: [] })}>browse files</a>;
47  return [
48    await topbar(site, view, [], [browse]),
49    <h2 class="commit-subject">{subject}</h2>,
50    ...(body ? [<pre class="commit-body">{body}</pre>] : []),
51    meta,
52    <p class="meta diffstat">
53      <span class="add-count">+{String(adds)}</span>
54      <span class="del-count">-{String(dels)}</span>
55      <span>{plural(changes.length, "changed file")}</span>
56    </p>,
57    ...files.map(f => f.element),
58  ];
59}
60
61interface FileSection {
62  element: HTMLElement;
63  adds: number;
64  dels: number;
65}
66
67async function fileSection(site: Site, revision: string, change: FileChange): Promise<FileSection> {
68  const { repo } = site;
69  const generated = looksGenerated(change.path);
70  const actions = (<span class="file-actions" />) as HTMLElement;
71  if (change.status !== "modified") {
72    actions.append(<span class={`status ${change.status}`}>{change.status}</span>);
73  }
74  if (change.newOid) {
75    const view = await href(site, { kind: "blob", oid: revision, path: change.path.split("/") });
76    actions.append(<a class="status" href={view}>view</a>);
77  }
78  if (generated) actions.append(<span class="generated-note">generated</span>);
79  const header = (
80    <summary>
81      <span class="file-summary-content">
82        <span class="path">{change.path}</span>
83        {actions}
84      </span>
85    </summary>
86  ) as HTMLElement;
87  const section = (<details class="file-diff">{header}</details>) as HTMLDetailsElement;
88  section.open = !generated;
89
90  const [oldData, newData] = await Promise.all([
91    change.oldOid ? repo.blob(change.oldOid) : Promise.resolve(new Uint8Array()),
92    change.newOid ? repo.blob(change.newOid) : Promise.resolve(new Uint8Array()),
93  ]);
94  if (looksBinary(oldData) || looksBinary(newData)) {
95    section.append(<p class="meta binary-note">binary file</p>);
96    return { element: section, adds: 0, dels: 0 };
97  }
98
99  const oldText = utf8.decode(oldData);
100  const newText = utf8.decode(newData);
101  const hunks = diffLines(oldText, newText);
102  const cells: Array<{ cell: HTMLTableCellElement; old: boolean; line: number }> = [];
103  let adds = 0;
104  let dels = 0;
105  const table = (<table class="diff" />) as HTMLTableElement;
106  for (const hunk of hunks) {
107    table.append(
108      <tr class="hunk">
109        <td class="ln" />
110        <td class="ln" />
111        <td>{`@@ -${hunk.aStart},${hunk.aLines} +${hunk.bStart},${hunk.bLines} @@`}</td>
112      </tr>,
113    );
114    let aLine = hunk.aStart;
115    let bLine = hunk.bStart;
116    for (const line of hunk.lines) {
117      const kind = line.sign === "+" ? "add" : line.sign === "-" ? "del" : "ctx";
118      const old = line.sign === "-";
119      const sourceLine = (old ? aLine : bLine) - 1;
120      if (line.sign === "+") adds++;
121      if (line.sign === "-") dels++;
122      const cell = (<td class="text">{line.text}</td>) as HTMLTableCellElement;
123      cells.push({ cell, old, line: sourceLine });
124      table.append(
125        <tr class={kind}>
126          <td class="ln">{line.sign === "+" ? "" : String(aLine++)}</td>
127          <td class="ln">{line.sign === "-" ? "" : String(bLine++)}</td>
128          {/* the +/- marker is CSS ::before so copied text stays clean */}
129          {cell}
130        </tr>,
131      );
132    }
133  }
134  actions.prepend(
135    <span class="add-count">+{String(adds)}</span>,
136    <span class="del-count">-{String(dels)}</span>,
137  );
138  section.append(table);
139  void Promise.all([
140    change.oldOid ? highlightedLines(change.path, oldText) : Promise.resolve(null),
141    change.newOid ? highlightedLines(change.path, newText) : Promise.resolve(null),
142  ]).then(([oldLines, newLines]) => {
143    for (const target of cells) {
144      const line = (target.old ? oldLines : newLines)?.[target.line];
145      if (line) target.cell.replaceChildren(...line.map(node => node.cloneNode(true)));
146    }
147  }).catch(err => console.warn("arborium:", err));
148  return { element: section, adds, dels };
149}