forks/sorcery

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

Charlotte Somrouter: scroll to top on client-side navigationc276aa4

grace-patches
10.9 KiB256 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, isPageView, type Page, pageAt, pageView, type Site, type View, type ViewKind, viewAt, viewKey, withinRef } from "./route.ts";
7import { commitTime, humanSize, relativizeTime, timeAgo } from "./util.ts";
8
9if (Promise.withResolvers === undefined) await import("./with-resolvers.ts");
10
11const segments = location.pathname.split("/").filter(Boolean);
12const reserved = new Set(["css", "js", "-"]);
13
14interface LoadingProgress {
15  bar: HTMLProgressElement;
16  amount: Text;
17  transfers: Map<number, { loaded: number; total?: number }>;
18  show: () => void;
19}
20
21function initRouter(repoName: string, base: string) {
22  const main = document.querySelector("main");
23  if (!main) return;
24  const ref = main.dataset.ref ?? null;
25  const tip = main.dataset.tip ?? null;
26  const loadedPathname = location.pathname;
27  let loading: LoadingProgress | null = null;
28  const owners = new Map<number, LoadingProgress>();
29  const showProgress = (transfer: TransferProgress) => {
30    if (transfer.phase === "start" && loading) {
31      owners.set(transfer.id, loading);
32      loading.show();
33    }
34    const owner = owners.get(transfer.id);
35    if (!owner) return;
36    owner.transfers.set(transfer.id, { loaded: transfer.loaded, total: transfer.total });
37    if (transfer.phase === "done") owners.delete(transfer.id);
38    if (owner !== loading) return;
39
40    let loaded = 0;
41    let total = 0;
42    let unknown = false;
43    for (const item of owner.transfers.values()) {
44      loaded += item.loaded;
45      if (item.total === undefined) unknown = true;
46      else total += item.total;
47    }
48    if (unknown) owner.bar.removeAttribute("value");
49    else {
50      owner.bar.max = Math.max(total, 1);
51      owner.bar.value = loaded;
52    }
53    owner.amount.data = unknown
54      ? ` ${humanSize(loaded)}`
55      : ` ${humanSize(loaded)} / ${humanSize(total)}`;
56  };
57  let transfers = new AbortController();
58  let repo: Promise<GitRepo> | undefined;
59  const gitRepo = () =>
60    repo ??= import("./git/repo.ts").then(({ GitRepo, httpFetcher }) =>
61      new GitRepo(httpFetcher(base, showProgress, () => transfers.signal), `sorcery${base}`)
62    );
63
64  // a static page's own hooks; its path exists at the tip by construction
65  const enhance = (root: ParentNode, page: Page) => {
66    if (page.tip) {
67      for (const heading of root.querySelectorAll<HTMLElement>("h2.log-heading")) {
68        const target = canonical(page, { kind: "history", oid: page.tip, path: [] }, "tree");
69        heading.replaceChildren(<a href={target}>{heading.textContent}</a>);
70      }
71      for (const actions of root.querySelectorAll<HTMLElement>(".topbar > .actions")) {
72        const target = canonical(page, { kind: "history", oid: page.tip, path: page.path }, page.kind);
73        actions.prepend(<a href={target}>history</a>);
74      }
75      for (const item of root.querySelectorAll<HTMLElement>(".languages li[data-language]")) {
76        const label = item.querySelector("span")!;
77        const target = canonical(page, { kind: "language", oid: page.tip, path: [item.dataset.language!] }, null);
78        label.replaceWith(<a href={target}>{[...label.childNodes]}</a>);
79      }
80    }
81    for (const span of root.querySelectorAll<HTMLElement>("[data-commit]")) {
82      const oid = span.dataset.commit!;
83      span.replaceWith(<a class={span.className} href={`#commit/${oid}`}>{span.textContent}</a>);
84    }
85    // Plain-mode blob pages ship unhighlighted source; data-hl carries the path.
86    for (const pre of root.querySelectorAll<HTMLPreElement>("pre.src[data-hl]")) {
87      const targets = pre.querySelectorAll(".code-text");
88      const source = [...targets].map(line => line.textContent ?? "").join("\n");
89      void import("./highlight.ts")
90        .then(module => module.highlightedLines(pre.dataset.hl!, source))
91        .then(highlighted => {
92          if (highlighted === null) return;
93          for (const [i, line] of highlighted.entries()) targets[i]?.replaceChildren(...line);
94        })
95        .catch(err => console.warn("arborium:", err));
96    }
97    initLogPagination(gitRepo, root);
98  };
99  const loaded = pageAt(loadedPathname, ref, tip);
100  enhance(main, loaded);
101
102  // the server's blob pages come highlighted, with markdown rendered and
103  // line anchors; cheaper to fetch whole than to render here
104  const staticPage = async (pathname: string, page: Page): Promise<Node[]> => {
105    const response = await fetch(pathname, { signal: transfers.signal });
106    if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
107    const fetched = new DOMParser().parseFromString(await response.text(), "text/html").querySelector("main");
108    if (!fetched) throw new Error("not a repo page");
109    enhance(fetched, page);
110    return [...fetched.children].filter(el => !el.matches("header.repo, noscript"));
111  };
112
113  // the repo header stays; everything else makes way for the view. the
114  // original content is detached rather than hidden: fetched pages reuse
115  // its ids (line anchors, the markdown toggle)
116  const original = [...main.children];
117  const header = original.filter(el => el.matches("header.repo"));
118  // the static content is the loaded page's own view, and stands in for it
119  // wherever else that view is wanted
120  const staticKey = viewKey(pageView(loaded));
121  let wanted = staticKey;
122
123  const route = async () => {
124    // other refs' tips are unknown here; their pages have their own
125    if (location.pathname !== loadedPathname && !withinRef(loaded, location.pathname)) return location.reload();
126    const page = pageAt(location.pathname, ref, tip);
127    const view = viewAt(page, location.hash);
128    const key = viewKey(view);
129    if (key === wanted) return;
130    wanted = key;
131    // the previous view's in-flight fetches would only steal bandwidth now
132    transfers.abort();
133    transfers = new AbortController();
134    loading = null;
135    if (!view || key === staticKey) {
136      main.replaceChildren(...original);
137      return;
138    }
139    // a view the hash adds closes back to the page; the page's own view has the crumbs
140    const head = isPageView(page, view) ? [] : [<a class="back" href={location.pathname}>← close</a>];
141    const status = (<p class="meta">loading {view.kind} {view.oid.slice(0, 12)}…</p>) as HTMLElement;
142    const nextView = (
143      <section class={`${view.kind}-view`}>
144        {head}
145        {status}
146      </section>
147    ) as HTMLElement;
148    const show = () => {
149      if (wanted !== key || main.contains(nextView)) return;
150      main.replaceChildren(...header, nextView);
151    };
152    const bar = document.createElement("progress");
153    const amount = document.createTextNode("");
154    status.append(" ", bar, amount);
155    loading = { bar, amount, transfers: new Map(), show };
156    try {
157      // directories render from tree objects that are small and likely cached
158      const rendered = view.kind === "blob" && isPageView(page, view)
159        ? await staticPage(location.pathname, page)
160        : await render[view.kind]({ repo: await gitRepo(), page, name: repoName }, view);
161      if (wanted === key) {
162        loading = null;
163        nextView.replaceChildren(...head, ...rendered);
164        show();
165      }
166    } catch (err) {
167      if (wanted === key) {
168        loading = null;
169        nextView.replaceChildren(...head, <p class="meta">failed to load {view.kind}: {String(err)}</p>);
170        show();
171      }
172    }
173  };
174  // links within the ref are taken in-app: the page they name is what the
175  // tip renders anyway. same-page hash links are left to the browser so
176  // `#L12` still scrolls
177  document.addEventListener("click", event => {
178    const anchor = (event.target as Element).closest("a[href]");
179    if (
180      !(anchor instanceof HTMLAnchorElement) || event.defaultPrevented || event.button !== 0
181      || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || anchor.target
182    ) return;
183    const url = new URL(anchor.href);
184    if (url.origin !== location.origin || !withinRef(loaded, url.pathname)) return;
185    if (url.pathname === location.pathname && url.hash) return;
186    event.preventDefault();
187    if (url.href !== location.href) history.pushState(null, "", url);
188    void route();
189    scrollTo(0, 0);
190  });
191  addEventListener("popstate", route);
192  addEventListener("hashchange", route);
193  void route();
194}
195
196async function* commitLog(gitRepo: () => Promise<GitRepo>, frontier: string[]): AsyncGenerator<Commit> {
197  const [repo, { log }] = await Promise.all([gitRepo(), import("./git/walk.ts")]);
198  yield* log(repo, frontier, PAGE_SIZE);
199}
200
201function initLogPagination(gitRepo: () => Promise<GitRepo>, root: ParentNode) {
202  for (const control of root.querySelectorAll<HTMLElement>("[data-log-frontier]")) {
203    const sibling = control.previousElementSibling;
204    if (!(sibling instanceof HTMLOListElement)) continue;
205    const items = [...sibling.children] as HTMLElement[];
206    const frontier = control.dataset.logFrontier!.split(" ");
207    paginate<Commit>({
208      list: sibling,
209      control,
210      seed: { items, keys: items.map(li => li.dataset.oid!) },
211      open: () => commitLog(gitRepo, frontier),
212      key: commit => commit.oid,
213      render: commit => {
214        const href = `#commit/${commit.oid}`;
215        const item = (<li dataset={{ oid: commit.oid }} />) as HTMLLIElement;
216        if (commit.changeId) {
217          item.append(<a class="cid" href={href}>{commit.changeId.slice(0, 8)}</a>, " ");
218        }
219        item.append(
220          <a class="sha" href={href}>{commit.oid.slice(0, 7)}</a>,
221          " ",
222          <span class="who">
223            <span>{commit.author.name}</span>
224            {commitTime(commit.author)}
225          </span>,
226          <span class="msg">{commit.message.split("\n")[0]}</span>,
227        );
228        return item;
229      },
230    });
231  }
232}
233
234// each view is its own chunk; the module loader memoizes the imports
235const render: Record<ViewKind, (site: Site, view: View) => Promise<Node[]>> = {
236  commit: (site, view) => import("./commit.tsx").then(m => m.commitView(site, view)),
237  history: (site, view) => import("./history.tsx").then(m => m.historyView(site, view)),
238  tree: (site, view) => import("./tree.tsx").then(m => m.treeView(site, view)),
239  blob: (site, view) => import("./tree.tsx").then(m => m.blobView(site, view)),
240  language: (site, view) => import("./tree.tsx").then(m => m.languageView(site, view)),
241};
242
243// pages are rendered ahead of time, so they carry absolute times: the
244// catalog's are relativized outright, commits' only while recent
245for (const time of document.querySelectorAll<HTMLTimeElement>("time[datetime]")) {
246  if (time.closest(".catalog")) {
247    time.title = time.textContent ?? "";
248    time.textContent = timeAgo(new Date(time.dateTime));
249  } else {
250    relativizeTime(time);
251  }
252}
253
254if (segments.length >= 2 && !reserved.has(segments[0])) {
255  initRouter(segments[1], `/${segments[0]}/${segments[1]}`);
256}