char/sorcery

static-files based git repo viewer

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

Charlotte Somfeat: give commits absolute <time> timestamps, relativized while recentc166af0

main
9.8 KiB223 linesraw
1// snapshot views: a commit's tree, one of its blobs, or its files of one
2// language. only the tree is rendered at the tip: the static blob page is
3// richer than what the client can make
4import type { Commit, TreeEntry } from "./git/types.ts";
5import { objectAt } from "./git/walk.ts";
6import { detectLanguage, languageGroup } from "./languages.ts";
7import { canonical, encodePath, isPageView, type Site, type View } from "./route.ts";
8import { commitTime, humanSize, isImage, looksBinary, looksGenerated, plural, utf8 } from "./util.ts";
9
10const TREE = 0o040000;
11const SYMLINK = 0o120000;
12const GITLINK = 0o160000;
13const kindOf = (entry: TreeEntry) => entry.mode & 0o170000;
14
15export async function kindAtTip(site: Site, path: string[]): Promise<"tree" | "blob" | null> {
16  if (!site.page.tip) return null;
17  if (path.length === 0) return "tree";
18  const commit = await site.repo.commit(site.page.tip);
19  const object = await objectAt(site.repo, commit.tree, path);
20  if (!object) return null;
21  const kind = object.mode & 0o170000;
22  return kind === TREE ? "tree" : kind === GITLINK ? null : "blob";
23}
24
25export async function href(site: Site, view: View): Promise<string> {
26  const needsTip = view.kind === "tree" || view.kind === "blob" || view.kind === "history";
27  return canonical(site.page, view, needsTip ? await kindAtTip(site, view.path) : null);
28}
29
30async function entriesAt(site: Site, commit: Commit, path: string[]): Promise<TreeEntry[]> {
31  const object = await objectAt(site.repo, commit.tree, path);
32  if (!object || (object.mode & 0o170000) !== TREE) throw new Error(`${path.join("/")} is not a tree`);
33  return site.repo.tree(object.oid);
34}
35
36/** `a/b/c` when `a` holds only `b`, which holds only `c`: a chain of lone
37 * directories reads better as one link than as three clicks */
38async function collapseLoneDirs(site: Site, entry: TreeEntry): Promise<string[]> {
39  const names = [entry.name];
40  let oid = entry.oid;
41  for (;;) {
42    const [only, ...rest] = await site.repo.tree(oid);
43    if (rest.length > 0 || !only || kindOf(only) !== TREE) return names;
44    names.push(only.name);
45    oid = only.oid;
46  }
47}
48
49/** the same shape as the static topbar. the ref switcher is baked into the
50 * static page and only applies at the tip, so it is borrowed from there;
51 * elsewhere the commit panel above says which commit this is */
52export async function topbar(site: Site, view: View, stats: string[], actions: Node[] = []): Promise<HTMLElement> {
53  const { name } = site;
54  const { kind, oid, path } = view;
55  const crumbs = (<nav class="crumbs" />) as HTMLElement;
56  const isRoot = kind !== "language" && kind !== "commit" && path.length === 0;
57  if (isRoot) crumbs.append(<span class="cur">{name}</span>);
58  else crumbs.append(<a href={await href(site, { kind: "tree", oid, path: [] })}>{name}</a>);
59  if (kind === "language") crumbs.append(<span class="label">language:</span>, " ", <span class="cur">{path[0]}</span>);
60  else if (kind === "commit") crumbs.append(<span class="label">commit:</span>, " ", <span class="cur">{oid.slice(0, 7)}</span>);
61  else {
62    for (let i = 0; i < path.length; i++) {
63      crumbs.append(" / ");
64      if (i < path.length - 1) {
65        crumbs.append(<a href={await href(site, { kind: "tree", oid, path: path.slice(0, i + 1) })}>{path[i]}</a>);
66      } else {
67        crumbs.append(<span class="cur">{path[i]}</span>);
68        if (kind === "tree") crumbs.append(" /");
69      }
70    }
71  }
72
73  if (kind === "tree" || kind === "blob") {
74    actions.unshift(<a href={await href(site, { kind: "history", oid, path })}>history</a>);
75  }
76  const switcher = isPageView(site.page, view)
77    ? document.querySelector("main > .topbar > .switcher")?.cloneNode(true) as HTMLElement | undefined
78    : undefined;
79  return (
80    <header class="topbar">
81      {switcher}
82      {crumbs}
83      <span class="view-stats">{stats.map(stat => <span>{stat}</span>)}</span>
84      <span class="actions">{actions}</span>
85    </header>
86  ) as HTMLElement;
87}
88
89function commitPanel(commit: Commit): HTMLElement {
90  return (
91    <p class="snapshot-commit">
92      <span class="commit-author">{commit.author.name}</span>
93      <a class="commit-message" href={`#commit/${commit.oid}`}>{commit.message.split("\n")[0]}</a>
94      <a class="snapshot-sha sha" href={`#commit/${commit.oid}`}>{commit.oid.slice(0, 7)}</a>
95      {commitTime(commit.author)}
96    </p>
97  ) as HTMLElement;
98}
99
100export async function treeView(site: Site, view: View): Promise<Node[]> {
101  const { oid, path } = view;
102  await site.repo.prefetch([oid], { smart: "tree" });
103  const commit = await site.repo.commit(oid);
104  const entries = await entriesAt(site, commit, path);
105  // directories first, then byte order, as git and the static pages
106  entries.sort((a, b) => Number(kindOf(a) !== TREE) - Number(kindOf(b) !== TREE) || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
107  const folders = entries.filter(entry => kindOf(entry) === TREE).length;
108  const files = entries.length - folders;
109  const stats = [folders ? plural(folders, "folder") : "", files ? plural(files, "file") : ""].filter(Boolean);
110  const rows = await Promise.all(entries.map(async entry => {
111    const kind = kindOf(entry);
112    if (kind === GITLINK) return { name: `${entry.name} @ ${entry.oid.slice(0, 7)}` };
113    const names = kind === TREE ? await collapseLoneDirs(site, entry) : [entry.name];
114    const name = names.join("/") + (kind === TREE ? "/" : kind === SYMLINK ? "@" : "");
115    return { name, href: await href(site, { kind: kind === TREE ? "tree" : "blob", oid, path: [...path, ...names] }) };
116  }));
117  return [
118    commitPanel(commit),
119    await topbar(site, view, stats),
120    <h2 class="file-heading">files</h2>,
121    listing(rows),
122  ];
123}
124
125function listing(rows: Array<{ name: string; href?: string }>): HTMLTableElement {
126  const table = (<table class="list" />) as HTMLTableElement;
127  for (const { name, href } of rows) {
128    table.append(<tr><td>{href ? <a href={href}>{name}</a> : name}</td></tr>);
129  }
130  return table;
131}
132
133/** every file at the commit classified as `language`, flat: a filtered tree
134 * would need the filter carried through every directory link */
135export async function languageView(site: Site, view: View): Promise<Node[]> {
136  const { oid, path: [language] } = view;
137  await site.repo.prefetch([oid], { smart: "tree" });
138  const commit = await site.repo.commit(oid);
139  const paths: string[][] = [];
140  // the smart tree bundle caps at 128 objects, so load further
141  // levels' subtrees in batches rather than one at a time
142  let level = [{ oid: commit.tree, prefix: [] as string[] }];
143  while (level.length > 0) {
144    await site.repo.prefetch(level.map(tree => tree.oid), { depth: 0 });
145    const next: typeof level = [];
146    for (const { oid: tree, prefix } of level) {
147      for (const entry of await site.repo.tree(tree)) {
148        const path = [...prefix, entry.name];
149        const kind = kindOf(entry);
150        if (kind === TREE) {
151          // trailing slash so the directory is tested as a parent, not a file
152          if (!looksGenerated(`${path.join("/")}/`)) next.push({ oid: entry.oid, prefix: path });
153        } else if (kind !== SYMLINK && kind !== GITLINK && !looksGenerated(path.join("/"))) {
154          const grammar = detectLanguage(entry.name);
155          if (grammar && languageGroup(grammar) === language) paths.push(path);
156        }
157      }
158    }
159    level = next;
160  }
161  paths.sort((a, b) => (a.join("/") < b.join("/") ? -1 : 1));
162  const rows = await Promise.all(paths.map(async path => ({
163    name: path.join("/"),
164    href: await href(site, { kind: "blob", oid, path }),
165  })));
166  return [
167    commitPanel(commit),
168    await topbar(site, view, [plural(paths.length, "file")]),
169    <h2 class="file-heading">files</h2>,
170    listing(rows),
171  ];
172}
173
174export async function blobView(site: Site, view: View): Promise<Node[]> {
175  const { oid, path } = view;
176  if (path.length === 0) throw new Error("blob path is empty");
177  const commit = await site.repo.commit(oid);
178  const object = await objectAt(site.repo, commit.tree, path);
179  if (!object || (object.mode & 0o170000) === TREE || (object.mode & 0o170000) === GITLINK) {
180    throw new Error(`${path.join("/")} is not a blob`);
181  }
182  const data = await site.repo.blob(object.oid);
183  const rawURL = `${site.page.base}/raw/${object.oid}/${encodePath(path)}`;
184  const raw = <a class="raw" href={rawURL}>raw</a>;
185  const symlink = (object.mode & 0o170000) === SYMLINK;
186  const image = !symlink && isImage(path.at(-1)!);
187  const renderable = !symlink && !image && !looksBinary(data) && data.length <= 1 << 20;
188  const text = renderable ? utf8.decode(data) : "";
189  const lines = text.split("\n");
190  if (lines.at(-1) === "") lines.pop();
191  const stats = symlink
192    ? [`symlink → ${utf8.decode(data)}`]
193    : renderable
194    ? [humanSize(data.length), plural(lines.length, "line")]
195    : [image ? "image" : looksBinary(data) ? "binary file" : "large file", humanSize(data.length)];
196  const head = [commitPanel(commit), await topbar(site, view, stats, [raw])];
197  if (image) return [...head, <div class="image-preview"><img src={rawURL} alt={path.join("/")} /></div>];
198  if (!renderable) return symlink ? head : [...head,
199    <div class="file-download">
200      <p>This file cannot be previewed.</p>
201      <a href={rawURL} download={path.at(-1)!}>download raw file</a>
202    </div>,
203  ];
204
205  const source = (
206    <pre class="code src">{lines.map((line, i) =>
207      <span class="code-line">
208        <span class="ln">{String(i + 1)}</span>
209        <span class="code-text">{line}</span>
210        {i < lines.length - 1 ? <span class="code-break">{"\n"}</span> : ""}
211      </span>
212    )}</pre>
213  ) as HTMLPreElement;
214  void import("./highlight.ts")
215    .then(module => module.highlightedLines(path.join("/"), text))
216    .then(highlighted => {
217      if (highlighted === null) return;
218      const targets = source.querySelectorAll(".code-text");
219      for (const [i, line] of highlighted.entries()) targets[i]?.replaceChildren(...line);
220    })
221    .catch(err => console.warn("arborium:", err));
222  return [...head, source];
223}