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
10.6 KiB261 linesraw
1import type { Commit, LocatedObject, TreeEntry } from "./git/types.ts";
2import { log, objectAt, type PathChange, pathHistory } from "./git/walk.ts";
3import { detectLanguage, languageGroup } from "./languages.ts";
4import { paginate } from "./pagination.tsx";
5import { canonical, encodePath, href, kindAtTip, type Site, type View } from "./route.ts";
6import { humanSize, identityDate, looksBinary, looksGenerated, plural, utf8 } from "./util.ts";
7
8const TREE = 0o040000;
9const SYMLINK = 0o120000;
10const GITLINK = 0o160000;
11const HISTORY_PREFETCH = 25;
12const kindOf = (entry: TreeEntry) => entry.mode & 0o170000;
13
14async function entriesAt(site: Site, commit: Commit, path: string[]): Promise<TreeEntry[]> {
15  const object = await objectAt(site.repo, commit.tree, path);
16  if (!object || (object.mode & 0o170000) !== TREE) throw new Error(`${path.join("/")} is not a tree`);
17  return site.repo.tree(object.oid);
18}
19
20/** `a/b/c` when `a` holds only `b`, which holds only `c`: a chain of lone
21 * directories reads better as one link than as three clicks */
22async function collapseLoneDirs(site: Site, entry: TreeEntry): Promise<string[]> {
23  const names = [entry.name];
24  let oid = entry.oid;
25  for (;;) {
26    const [only, ...rest] = await site.repo.tree(oid);
27    if (rest.length > 0 || !only || kindOf(only) !== TREE) return names;
28    names.push(only.name);
29    oid = only.oid;
30  }
31}
32
33/** the same shape as the static topbar, minus the ref switcher: the commit
34 * panel above already says which commit this is */
35export async function topbar(site: Site, view: View, stats: string[], actions: Node[] = []): Promise<HTMLElement> {
36  const { name } = site;
37  const { kind, oid, path } = view;
38  const crumbs = (<nav class="crumbs" />) as HTMLElement;
39  const isRoot = kind !== "language" && kind !== "commit" && path.length === 0;
40  if (isRoot) crumbs.append(<span class="cur">{name}</span>);
41  else crumbs.append(<a href={await href(site, { kind: "tree", oid, path: [] })}>{name}</a>);
42  if (kind === "language") crumbs.append(<span class="label">language:</span>, " ", <span class="cur">{path[0]}</span>);
43  else if (kind === "commit") crumbs.append(<span class="label">commit:</span>, " ", <span class="cur">{oid.slice(0, 7)}</span>);
44  else {
45    for (let i = 0; i < path.length; i++) {
46      crumbs.append(" / ");
47      if (i < path.length - 1) {
48        crumbs.append(<a href={await href(site, { kind: "tree", oid, path: path.slice(0, i + 1) })}>{path[i]}</a>);
49      } else {
50        crumbs.append(<span class="cur">{path[i]}</span>);
51        if (kind === "tree") crumbs.append(" /");
52      }
53    }
54  }
55
56  if (kind === "tree" || kind === "blob") {
57    actions.unshift(<a href={await href(site, { kind: "history", oid, path })}>history</a>);
58  }
59  return (
60    <header class="topbar">
61      {crumbs}
62      <span class="view-stats">{stats.map(stat => <span>{stat}</span>)}</span>
63      <span class="actions">{actions}</span>
64    </header>
65  ) as HTMLElement;
66}
67
68function commitPanel(commit: Commit): HTMLElement {
69  return (
70    <p class="snapshot-commit">
71      <span class="commit-author">{commit.author.name}</span>
72      <a class="commit-message" href={`#commit/${commit.oid}`}>{commit.message.split("\n")[0]}</a>
73      <a class="snapshot-sha sha" href={`#commit/${commit.oid}`}>{commit.oid.slice(0, 7)}</a>
74      <time>{identityDate(commit.author).slice(0, 10)}</time>
75    </p>
76  ) as HTMLElement;
77}
78
79export async function treeView(site: Site, view: View): Promise<Node[]> {
80  const { oid, path } = view;
81  await site.repo.prefetch([oid], { smart: "tree" });
82  const commit = await site.repo.commit(oid);
83  const entries = await entriesAt(site, commit, path);
84  // directories first, then byte order, as git and the static pages
85  entries.sort((a, b) => Number(kindOf(a) !== TREE) - Number(kindOf(b) !== TREE) || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
86  const folders = entries.filter(entry => kindOf(entry) === TREE).length;
87  const files = entries.length - folders;
88  const stats = [folders ? plural(folders, "folder") : "", files ? plural(files, "file") : ""].filter(Boolean);
89  const rows = await Promise.all(entries.map(async entry => {
90    const kind = kindOf(entry);
91    if (kind === GITLINK) return { name: `${entry.name} @ ${entry.oid.slice(0, 7)}` };
92    const names = kind === TREE ? await collapseLoneDirs(site, entry) : [entry.name];
93    const name = names.join("/") + (kind === TREE ? "/" : kind === SYMLINK ? "@" : "");
94    return { name, href: await href(site, { kind: kind === TREE ? "tree" : "blob", oid, path: [...path, ...names] }) };
95  }));
96  return [
97    commitPanel(commit),
98    await topbar(site, view, stats),
99    <h2 class="file-heading">files</h2>,
100    listing(rows),
101  ];
102}
103
104function listing(rows: Array<{ name: string; href?: string }>): HTMLTableElement {
105  const table = (<table class="list" />) as HTMLTableElement;
106  for (const { name, href } of rows) {
107    table.append(<tr><td>{href ? <a href={href}>{name}</a> : name}</td></tr>);
108  }
109  return table;
110}
111
112/** every file at the commit classified as `language`, flat: a filtered tree
113 * would need the filter carried through every directory link */
114export async function languageView(site: Site, view: View): Promise<Node[]> {
115  const { oid, path: [language] } = view;
116  await site.repo.prefetch([oid], { smart: "tree" });
117  const commit = await site.repo.commit(oid);
118  const paths: string[][] = [];
119  const walk = async (tree: string, prefix: string[]) => {
120    for (const entry of await site.repo.tree(tree)) {
121      const path = [...prefix, entry.name];
122      const kind = kindOf(entry);
123      if (kind === TREE) {
124        // trailing slash so the directory is tested as a parent, not a file
125        if (!looksGenerated(`${path.join("/")}/`)) await walk(entry.oid, path);
126      } else if (kind !== SYMLINK && kind !== GITLINK && !looksGenerated(path.join("/"))) {
127        const grammar = detectLanguage(entry.name);
128        if (grammar && languageGroup(grammar) === language) paths.push(path);
129      }
130    }
131  };
132  await walk(commit.tree, []);
133  const rows = await Promise.all(paths.map(async path => ({
134    name: path.join("/"),
135    href: await href(site, { kind: "blob", oid, path }),
136  })));
137  return [
138    commitPanel(commit),
139    await topbar(site, view, [plural(paths.length, "file")]),
140    <h2 class="file-heading">files</h2>,
141    listing(rows),
142  ];
143}
144
145export async function blobView(site: Site, view: View): Promise<Node[]> {
146  const { oid, path } = view;
147  if (path.length === 0) throw new Error("blob path is empty");
148  const commit = await site.repo.commit(oid);
149  const object = await objectAt(site.repo, commit.tree, path);
150  if (!object || (object.mode & 0o170000) === TREE || (object.mode & 0o170000) === GITLINK) {
151    throw new Error(`${path.join("/")} is not a blob`);
152  }
153  const data = await site.repo.blob(object.oid);
154  const raw = <a class="raw" href={`${site.page.base}/raw/${object.oid}/${encodePath(path)}`}>raw</a>;
155  const symlink = (object.mode & 0o170000) === SYMLINK;
156  const renderable = !symlink && !looksBinary(data) && data.length <= 1 << 20;
157  const text = renderable ? utf8.decode(data) : "";
158  const lines = text.split("\n");
159  if (lines.at(-1) === "") lines.pop();
160  const stats = symlink
161    ? [`symlink → ${utf8.decode(data)}`]
162    : renderable
163    ? [humanSize(data.length), plural(lines.length, "line")]
164    : [looksBinary(data) ? "binary file" : "large file", humanSize(data.length)];
165  const head = [commitPanel(commit), await topbar(site, view, stats, [raw])];
166  if (!renderable) return head;
167
168  const source = (
169    <pre class="code src historical-code">{lines.map((line, i) =>
170      <span class="code-line">
171        <span class="ln">{String(i + 1)}</span>
172        <span class="code-text">{line}</span>
173        {i < lines.length - 1 ? <span class="code-break">{"\n"}</span> : ""}
174      </span>
175    )}</pre>
176  ) as HTMLPreElement;
177  void import("./highlight.ts")
178    .then(module => module.highlightedLines(path.join("/"), text))
179    .then(highlighted => {
180      if (highlighted === null) return;
181      const targets = source.querySelectorAll(".code-text");
182      for (const [i, line] of highlighted.entries()) targets[i]?.replaceChildren(...line);
183    })
184    .catch(err => console.warn("arborium:", err));
185  return [...head, source];
186}
187
188async function* commitHistory(
189  site: Site,
190  start: string,
191  scanned: (count: number) => void,
192): AsyncGenerator<PathChange> {
193  for await (const commit of log(site.repo, [start], HISTORY_PREFETCH)) {
194    scanned(1);
195    yield { commit, object: { oid: commit.tree, mode: TREE } };
196  }
197}
198
199const snapshotKind = (object: LocatedObject): "tree" | "blob" =>
200  (object.mode & 0o170000) === TREE ? "tree" : "blob";
201
202export async function historyView(site: Site, view: View): Promise<Node[]> {
203  const { oid, path } = view;
204  const commitList = path.length === 0;
205  const atTip = await kindAtTip(site, path);
206  const list = (<ol class="log history-log" />) as HTMLOListElement;
207  const status = (<p class="meta history-status" />) as HTMLElement;
208  const control = (<p class="meta log-pagination" />) as HTMLElement;
209  let scanned = 0;
210  const scannedCommits = (count: number) => {
211    scanned += count;
212    status.textContent = `scanned ${plural(scanned, "commit")}`;
213  };
214  paginate<PathChange>({
215    list,
216    control,
217    open: () => {
218      scanned = 0;
219      return commitList
220        ? commitHistory(site, oid, scannedCommits)
221        : pathHistory(site.repo, [oid], path, scannedCommits);
222    },
223    key: change => change.commit.oid,
224    render: change => {
225      // a path's history leads to the path as it was; the commit list, to the commits
226      const target = !commitList && change.object
227        ? canonical(site.page, { kind: snapshotKind(change.object), oid: change.commit.oid, path }, atTip)
228        : `#commit/${change.commit.oid}`;
229      const who = (
230        <span class="who">
231          <span>{change.commit.author.name}</span>
232          <time>{identityDate(change.commit.author).slice(0, 10)}</time>
233        </span>
234      ) as HTMLElement;
235      if (!change.object) who.append(<span class="deleted">deleted</span>);
236      const item = (<li />) as HTMLLIElement;
237      if (change.commit.changeId) {
238        item.append(<a class="cid" href={target}>{change.commit.changeId.slice(0, 8)}</a>, " ");
239      }
240      item.append(
241        <a class="sha" href={target}>{change.commit.oid.slice(0, 7)}</a>,
242        who,
243        <span class="msg">{change.commit.message.split("\n")[0]}</span>,
244      );
245      return item;
246    },
247    onShow: count => {
248      status.replaceChildren(<span>{plural(count, commitList ? "commit" : "change")}</span>);
249    },
250    onError: err => {
251      status.textContent = `history failed: ${String(err)}`;
252    },
253  });
254
255  return [
256    await topbar(site, view, [commitList ? "commit history" : "change history"]),
257    status,
258    list,
259    control,
260  ];
261}