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
9.2 KiB227 linesraw
1// a plain repo page needs none of the git machinery, so it all lives behind
2// dynamic imports: the object store loads on first use, each view in its own chunk
3import type { GitRepo, TransferProgress } from "./git/repo.ts";
4import type { Commit } from "./git/types.ts";
5import { PAGE_SIZE, paginate } from "./pagination.tsx";
6import { canonical, pageAt, type Site, viewAt } from "./route.ts";
7import { humanSize, identityDate } from "./util.ts";
8
9const segments = location.pathname.split("/").filter(Boolean);
10const reserved = new Set(["css", "js", "-"]);
11
12interface LoadingProgress {
13  bar: HTMLProgressElement;
14  amount: Text;
15  transfers: Map<number, { loaded: number; total?: number }>;
16  show: () => void;
17}
18
19function initRouter(repoName: string, base: string) {
20  const main = document.querySelector("main");
21  if (!main) return;
22  const ref = main.dataset.ref ?? null;
23  const tip = main.dataset.tip ?? null;
24  const loadedPathname = location.pathname;
25  let loading: LoadingProgress | null = null;
26  const owners = new Map<number, LoadingProgress>();
27  const showProgress = (transfer: TransferProgress) => {
28    if (transfer.phase === "start" && loading) {
29      owners.set(transfer.id, loading);
30      loading.show();
31    }
32    const owner = owners.get(transfer.id);
33    if (!owner) return;
34    owner.transfers.set(transfer.id, { loaded: transfer.loaded, total: transfer.total });
35    if (transfer.phase === "done") owners.delete(transfer.id);
36    if (owner !== loading) return;
37
38    let loaded = 0;
39    let total = 0;
40    let unknown = false;
41    for (const item of owner.transfers.values()) {
42      loaded += item.loaded;
43      if (item.total === undefined) unknown = true;
44      else total += item.total;
45    }
46    if (unknown) owner.bar.removeAttribute("value");
47    else {
48      owner.bar.max = Math.max(total, 1);
49      owner.bar.value = loaded;
50    }
51    owner.amount.data = unknown
52      ? ` ${humanSize(loaded)}`
53      : ` ${humanSize(loaded)} / ${humanSize(total)}`;
54  };
55  let transfers = new AbortController();
56  let repo: Promise<GitRepo> | undefined;
57  const gitRepo = () =>
58    repo ??= import("./git/repo.ts").then(({ GitRepo, httpFetcher }) =>
59      new GitRepo(httpFetcher(base, showProgress, () => transfers.signal), `sorcery${base}`)
60    );
61
62  // the static page's own hooks; its path exists at the tip by construction
63  const page = pageAt(loadedPathname, ref, tip);
64  if (tip) {
65    for (const heading of main.querySelectorAll<HTMLElement>("h2.log-heading")) {
66      const target = canonical(page, { kind: "history", oid: tip, path: [] }, "tree");
67      heading.replaceChildren(<a href={target}>{heading.textContent}</a>);
68    }
69    for (const actions of main.querySelectorAll<HTMLElement>(".topbar > .actions")) {
70      const target = canonical(page, { kind: "history", oid: tip, path: page.path }, page.kind);
71      actions.prepend(<a href={target}>history</a>);
72    }
73    for (const item of main.querySelectorAll<HTMLElement>(".languages li[data-language]")) {
74      const label = item.querySelector("span")!;
75      const target = canonical(page, { kind: "language", oid: tip, path: [item.dataset.language!] }, null);
76      label.replaceWith(<a href={target}>{[...label.childNodes]}</a>);
77    }
78  }
79  for (const span of main.querySelectorAll<HTMLElement>("[data-commit]")) {
80    const oid = span.dataset.commit!;
81    span.replaceWith(<a class={span.className} href={`#commit/${oid}`}>{span.textContent}</a>);
82  }
83  // Plain-mode blob pages ship unhighlighted source; data-hl carries the path.
84  for (const pre of main.querySelectorAll<HTMLPreElement>("pre.src[data-hl]")) {
85    const targets = pre.querySelectorAll(".code-text");
86    const source = [...targets].map(line => line.textContent ?? "").join("\n");
87    void import("./highlight.ts")
88      .then(module => module.highlightedLines(pre.dataset.hl!, source))
89      .then(highlighted => {
90        if (highlighted === null) return;
91        for (const [i, line] of highlighted.entries()) targets[i]?.replaceChildren(...line);
92      })
93      .catch(err => console.warn("arborium:", err));
94  }
95  initLogPagination(gitRepo, main);
96
97  // the repo header stays; everything else makes way for the view
98  const original = ([...main.children] as HTMLElement[]).filter(el => !el.matches("header.repo"));
99  let view: HTMLElement | null = null;
100  let current = "";
101
102  const route = async () => {
103    if (location.href === current) return;
104    current = location.href;
105    // the previous view's in-flight fetches would only steal bandwidth now
106    transfers.abort();
107    transfers = new AbortController();
108    const target = location.href;
109    const parsed = viewAt(pageAt(location.pathname, ref, tip), location.hash);
110    loading = null;
111    if (!parsed) {
112      // only the loaded page's static content is here to show
113      if (location.pathname !== loadedPathname) return location.reload();
114      view?.remove();
115      view = null;
116      for (const el of original) el.style.display = "";
117      return;
118    }
119    const close = <a class="back" href={location.pathname}> close</a>;
120    const status = (<p class="meta">loading {parsed.kind} {parsed.oid.slice(0, 12)}</p>) as HTMLElement;
121    const nextView = (
122      <section class={parsed.kind === "commit" ? "commit-view" : "historical-view"}>
123        {close}
124        {status}
125      </section>
126    ) as HTMLElement;
127    let shown = false;
128    const show = () => {
129      if (shown || location.href !== target) return;
130      shown = true;
131      for (const el of original) el.style.display = "none";
132      view?.remove();
133      view = nextView;
134      main.append(view);
135    };
136    const bar = document.createElement("progress");
137    const amount = document.createTextNode("");
138    status.append(" ", bar, amount);
139    loading = { bar, amount, transfers: new Map(), show };
140    try {
141      const site: Site = { repo: await gitRepo(), page: pageAt(location.pathname, ref, tip), name: repoName };
142      const rendered = parsed.kind === "commit"
143        ? await (await import("./commit.tsx")).commitView(site, parsed)
144        : parsed.kind === "tree"
145        ? await (await historicalView()).treeView(site, parsed)
146        : parsed.kind === "blob"
147        ? await (await historicalView()).blobView(site, parsed)
148        : parsed.kind === "language"
149        ? await (await historicalView()).languageView(site, parsed)
150        : await (await historicalView()).historyView(site, parsed);
151      if (location.href === target) {
152        loading = null;
153        nextView.replaceChildren(close, ...rendered);
154        show();
155      }
156    } catch (err) {
157      if (location.href === target) {
158        loading = null;
159        nextView.replaceChildren(close, <p class="meta">failed to load {parsed.kind}: {String(err)}</p>);
160        show();
161      }
162    }
163  };
164  // canonical links may change the pathname: take those in-app rather than
165  // loading the fallback page, unless the link *is* the fallback page
166  document.addEventListener("click", event => {
167    const anchor = (event.target as Element).closest("a[href]");
168    if (
169      !(anchor instanceof HTMLAnchorElement) || event.defaultPrevented || event.button !== 0
170      || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || anchor.target
171    ) return;
172    const url = new URL(anchor.href);
173    if (url.origin !== location.origin || url.pathname === location.pathname || !url.hash) return;
174    if (!url.pathname.startsWith(`${base}/`)) return;
175    event.preventDefault();
176    history.pushState(null, "", url);
177    void route();
178  });
179  addEventListener("popstate", route);
180  addEventListener("hashchange", route);
181  void route();
182}
183
184async function* commitLog(gitRepo: () => Promise<GitRepo>, frontier: string[]): AsyncGenerator<Commit> {
185  const [repo, { log }] = await Promise.all([gitRepo(), import("./git/walk.ts")]);
186  yield* log(repo, frontier, PAGE_SIZE);
187}
188
189function initLogPagination(gitRepo: () => Promise<GitRepo>, main: HTMLElement) {
190  for (const control of main.querySelectorAll<HTMLElement>("[data-log-frontier]")) {
191    const sibling = control.previousElementSibling;
192    if (!(sibling instanceof HTMLOListElement)) continue;
193    const items = [...sibling.children] as HTMLElement[];
194    const frontier = control.dataset.logFrontier!.split(" ");
195    paginate<Commit>({
196      list: sibling,
197      control,
198      seed: { items, keys: items.map(li => li.dataset.oid!) },
199      open: () => commitLog(gitRepo, frontier),
200      key: commit => commit.oid,
201      render: commit => {
202        const href = `#commit/${commit.oid}`;
203        const item = (<li dataset={{ oid: commit.oid }} />) as HTMLLIElement;
204        if (commit.changeId) {
205          item.append(<a class="cid" href={href}>{commit.changeId.slice(0, 8)}</a>, " ");
206        }
207        item.append(
208          <a class="sha" href={href}>{commit.oid.slice(0, 7)}</a>,
209          " ",
210          <span class="who">
211            <span>{commit.author.name}</span>
212            <time>{identityDate(commit.author).slice(0, 10)}</time>
213          </span>,
214          <span class="msg">{commit.message.split("\n")[0]}</span>,
215        );
216        return item;
217      },
218    });
219  }
220}
221
222// dynamic imports are memoized by the module loader, so no caching needed
223const historicalView = () => import("./historical.tsx");
224
225if (segments.length >= 2 && !reserved.has(segments[0])) {
226  initRouter(segments[1], `/${segments[0]}/${segments[1]}`);
227}