diff --git a/packages/git-ui/package.json b/packages/git-ui/package.json
index d00889c..f297271 100644
--- a/packages/git-ui/package.json
+++ b/packages/git-ui/package.json
@@ -17,6 +17,7 @@
"@pdsjs/npm": "workspace:*",
"@pdsjs/oci": "workspace:*",
"@tanstack/react-query": "^5.101.4",
+ "@tanstack/react-router": "^1.170.32",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"highlight.js": "^11.12.0",
diff --git a/packages/git-ui/src/app.jsx b/packages/git-ui/src/app.jsx
index 8f00a04..c7fc362 100644
--- a/packages/git-ui/src/app.jsx
+++ b/packages/git-ui/src/app.jsx
@@ -1,8 +1,8 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
-import { useEffect, useMemo, useRef, useState } from 'react';
+import { Outlet, useRouterState } from '@tanstack/react-router';
+import { useMemo } from 'react';
import { Avatar } from '#/components/atoms/avatar.jsx';
import { Link } from '#/components/atoms/link.jsx';
-import { Skeleton } from '#/components/atoms/skeleton.jsx';
import { CodeMenu } from '#/components/molecules/code-menu.jsx';
import { RepoListSkeleton } from '#/components/molecules/repo-list-skeleton.jsx';
import { RepoPageSkeleton } from '#/components/molecules/repo-page-skeleton.jsx';
@@ -11,160 +11,18 @@ import { SessionControls } from '#/components/molecules/session-controls.jsx';
import { ThemeMenu } from '#/components/molecules/theme-menu.jsx';
import { initial, loadAuthors, owner, primeAuthors } from '#/lib/authors.js';
import { identicon } from '#/lib/avatars.js';
-import { parsePullPath } from '#/lib/collab.js';
import {
account as accountInfo,
capabilities,
- defaultRef,
discover,
loadCapabilities,
loadRepos,
primeFromCache,
repoRecord,
- splitRefPath,
} from '#/lib/git.js';
-import { NavigationProvider, useLocation } from '#/lib/navigation.jsx';
-import { clearRenewal, completeSignIn } from '#/lib/oauth.js';
-import { useSession } from '#/lib/session.jsx';
import { loadThemes } from '#/lib/theme.js';
import { cn } from '#/lib/utils.js';
-import { CheckPage } from '#/pages/check.jsx';
-import { ChecksPage } from '#/pages/checks.jsx';
-import { CommitPage } from '#/pages/commit.jsx';
-import { CommitsPage } from '#/pages/commits.jsx';
-import { FilePage } from '#/pages/file.jsx';
-import { IssuePage } from '#/pages/issue.jsx';
-import { NewIssuePage } from '#/pages/issue-new.jsx';
-import { IssuesPage } from '#/pages/issues.jsx';
-import { PackagesPage } from '#/pages/packages.jsx';
-import { PullPage } from '#/pages/pull.jsx';
-import { PullsPage } from '#/pages/pulls.jsx';
-import { ReposPage } from '#/pages/repos.jsx';
-import { TreePage } from '#/pages/tree.jsx';
-
-/**
- * Where the authorization server sends the browser back. The code is spent
- * once, so the address bar is cleaned before anything else renders: a reload
- * of this URL would otherwise present a code the server has already refused.
- */
-function CallbackPage({ navigate }) {
- const { setSession } = useSession();
- const [error, setError] = useState(null);
-
- // The exchange consumes the pending sign-in the moment it starts, so it
- // must run once however many times the effect fires. StrictMode fires it
- // twice in development, and a second run would find nothing pending and
- // report a failure over a sign-in that succeeded. The ref survives the
- // simulated remount; a cleanup-cancelled flag would not, and would also
- // cancel the first run's redirect.
- const ran = useRef(false);
- useEffect(() => {
- if (ran.current) return;
- ran.current = true;
- // Read from the address bar rather than from a prop: the code is spent
- // once and this effect must not re-run, so it depends on nothing a render
- // makes anew.
- completeSignIn(new URLSearchParams(window.location.search))
- .then(({ session, returnTo }) => {
- clearRenewal();
- setSession(session);
- navigate(returnTo);
- })
- .catch((err) => setError(err.message));
- }, [navigate, setSession]);
-
- if (error) {
- return (
-
-
{error}
-
- Start again
-
-
- );
- }
- return ;
-}
-
-/**
- * Pick the screen for a path. The repository record is already in hand, which
- * is what lets a ref carrying slashes be told apart from the path after it.
- */
-function Screen({ path }) {
- const segments = path.split('/').filter(Boolean).map(decodeURIComponent);
- if (segments.length === 0) return ;
-
- const [repo, kind, ...rest] = segments;
- const record = repoRecord(repo);
- const tail = rest.join('/');
-
- if (!record) {
- return
No such repository.
;
- }
- // A commit on a contributor's branch is named by whose copy holds it, so
- // this account's own commits keep the shorter address they always had.
- if (kind === 'commit') {
- const from = rest[0]?.startsWith('did:') ? rest[0] : '';
- return ;
- }
- if (kind === 'checks') {
- return tail ? (
-
- ) : (
-
- );
- }
- // A pull request is addressed by the account whose copy holds the work and
- // the branch, so the address survives the rebases that move its commits.
- // parsePullPath reads that address, beside the diffHref that writes it.
- if (kind === 'pull') {
- const { did, branch, tab, commit } = parsePullPath(rest);
- return (
-
- );
- }
- // An issue thread is addressed by its opener: whose repo holds it, and the
- // record key there.
- if (kind === 'issues') {
- // A thread's address is two segments, so one segment is the filing page
- // or the tab of a list, and neither can be mistaken for a thread.
- if (rest[0] === 'new') return ;
- if (rest.length >= 2) {
- return ;
- }
- return ;
- }
- if (kind === 'pulls') return ;
- if (kind === 'packages') return ;
- // A ref name may hold slashes and a range may not, so a segment carrying
- // `..` is a range rather than a branch. Git forbids `..` in a ref name,
- // which is what makes the two tell apart.
- if (kind === 'commits') {
- const from = rest[0]?.startsWith('did:') ? rest[0] : '';
- const last = from ? rest[1] : tail;
- if (last?.includes('..')) {
- return ;
- }
- return ;
- }
- if (kind === 'blob') {
- const { ref, path: filePath } = splitRefPath(record, tail);
- return ;
- }
- if (kind === 'tree') {
- const { ref, path: dirPath } = splitRefPath(record, tail);
- return ;
- }
- return ;
-}
-
/**
* Which section of a repository a path names. The tab bar marks it, and each
* page belongs to exactly one: a commit is part of the history, a run is part
@@ -181,8 +39,13 @@ function sectionOf(kind) {
return 'code';
}
+/**
+ * The shell: the bar, the repository's sections, and the account gate every
+ * screen waits on. The screen itself is the route's, rendered where the
+ * shell leaves room for it.
+ */
export function App() {
- const { path, navigate } = useLocation();
+ const path = useRouterState({ select: (state) => state.location.pathname });
const queryClient = useQueryClient();
const segments = path.split('/').filter(Boolean).map(decodeURIComponent);
const wide = segments[1] === 'commit';
@@ -258,7 +121,7 @@ export function App() {
const filled = segments.length === 0 || known;
return (
-
+ <>
{/* The window is the app: the bar holds its place and each pane below
takes its own scroll, so nothing but the reading area ever moves. */}
Could not reach the PDS. {error.message}
@@ -363,7 +226,7 @@ export function App() {
)
) : filled ? (
-
+
) : (
-
+
)}
-
+ >
);
}
diff --git a/packages/git-ui/src/components/atoms/link.jsx b/packages/git-ui/src/components/atoms/link.jsx
index ce22d67..5ea7e73 100644
--- a/packages/git-ui/src/components/atoms/link.jsx
+++ b/packages/git-ui/src/components/atoms/link.jsx
@@ -1,35 +1,21 @@
-import { useNavigate } from '#/lib/navigation.jsx';
+import { Link as RouterLink } from '@tanstack/react-router';
import { cn } from '#/lib/utils.js';
/**
* An in-app anchor. It is a real href so the link opens in a new tab and
* reads correctly to a crawler, and a plain click routes without a reload.
+ * The router starts the route's loads when the pointer settles on it, so
+ * the page behind a link is often in hand before the click.
*/
export function Link({ href, className, children, ...props }) {
- const navigate = useNavigate();
return (
- {
- // Leave the modified clicks to the browser: they mean a new tab or
- // a download, not navigation in this document.
- if (
- event.defaultPrevented ||
- event.metaKey ||
- event.ctrlKey ||
- event.shiftKey ||
- event.altKey ||
- event.button !== 0
- ) {
- return;
- }
- event.preventDefault();
- navigate(href);
- }}
{...props}
>
{children}
-
+
);
}
diff --git a/packages/git-ui/src/lib/git.js b/packages/git-ui/src/lib/git.js
index 5908297..53f3d44 100644
--- a/packages/git-ui/src/lib/git.js
+++ b/packages/git-ui/src/lib/git.js
@@ -764,6 +764,24 @@ export function readerFor(name, from) {
};
}
+/**
+ * One commit, or as base..tip the changes across a range, from whichever
+ * copy holds it: this account's, or another's read through this server.
+ * What a commit page shows, and what its route warms.
+ * @param {string} repo - repository name, the record rkey
+ * @param {string} sha - one commit, or base..tip
+ * @param {string} [from] - the DID whose copy holds it, empty for this account
+ */
+export function readChange(repo, sha, from) {
+ const copy = from && from !== account.did ? from : undefined;
+ const reader = readerFor(repo, copy);
+ const did = copy ?? account.did;
+ const [base, tip] = sha.includes('..') ? sha.split('..') : ['', sha];
+ return base
+ ? reader.readRange(did, repo, base, tip)
+ : reader.readCommit(did, repo, tip);
+}
+
// ---- reading the record ----
/**
diff --git a/packages/git-ui/src/lib/navigation.jsx b/packages/git-ui/src/lib/navigation.jsx
index 6aea085..e0e1f8a 100644
--- a/packages/git-ui/src/lib/navigation.jsx
+++ b/packages/git-ui/src/lib/navigation.jsx
@@ -1,44 +1,32 @@
import {
- createContext,
- useCallback,
- useContext,
- useEffect,
- useState,
-} from 'react';
-
-const NavigationContext = createContext(() => {});
+ useNavigate as useRouterNavigate,
+ useRouterState,
+} from '@tanstack/react-router';
+import { useCallback } from 'react';
/**
- * Path routing over the History API. The site record serves index.html for
- * every unmatched path, so a reload or a shared link lands on the same screen
- * a click does.
+ * The router's location and navigation, in the shape the pages take: a path
+ * string, and a function that goes to one.
+ *
+ * The pages build their own addresses, since a pull request's address holds
+ * a DID and a branch name with slashes in it, and a route pattern cannot say
+ * where one ends. So a link and a navigation take a path, and the router
+ * matches it.
*/
export function useLocation() {
- const [path, setPath] = useState(() => window.location.pathname);
-
- useEffect(() => {
- const onPop = () => setPath(window.location.pathname);
- window.addEventListener('popstate', onPop);
- return () => window.removeEventListener('popstate', onPop);
- }, []);
-
- const navigate = useCallback((next) => {
- window.history.pushState({}, '', next);
- setPath(new URL(next, window.location.origin).pathname);
- window.scrollTo(0, 0);
- }, []);
-
+ const path = useRouterState({ select: (state) => state.location.pathname });
+ const navigate = useNavigate();
return { path, navigate };
}
-export function NavigationProvider({ navigate, children }) {
- return (
-
- {children}
-
- );
-}
-
+/** @returns {(to: string) => void} */
export function useNavigate() {
- return useContext(NavigationContext);
+ const navigate = useRouterNavigate();
+ return useCallback(
+ (to) => {
+ navigate({ to });
+ window.scrollTo(0, 0);
+ },
+ [navigate],
+ );
}
diff --git a/packages/git-ui/src/main.jsx b/packages/git-ui/src/main.jsx
index d84be77..acc55f1 100644
--- a/packages/git-ui/src/main.jsx
+++ b/packages/git-ui/src/main.jsx
@@ -1,9 +1,10 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { RouterProvider } from '@tanstack/react-router';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
-import { App } from '#/app.jsx';
import { SessionProvider } from '#/lib/session.jsx';
import { applyTheme, themeId } from '#/lib/theme.js';
+import { createAppRouter } from '#/router.jsx';
import './style.css';
// Paint in the remembered palette before the first frame, so a reader who
@@ -23,11 +24,13 @@ const queryClient = new QueryClient({
},
});
+const router = createAppRouter(queryClient);
+
createRoot(document.getElementById('root')).render(
-
+ ,
diff --git a/packages/git-ui/src/pages/commit.jsx b/packages/git-ui/src/pages/commit.jsx
index 4b9a618..47165ae 100644
--- a/packages/git-ui/src/pages/commit.jsx
+++ b/packages/git-ui/src/pages/commit.jsx
@@ -36,7 +36,7 @@ import {
import {
account,
defaultRef,
- readerFor,
+ readChange,
repoRecord,
repoSize,
} from '#/lib/git.js';
@@ -79,16 +79,7 @@ export function CommitPage({
const { data, error } = useQuery({
queryKey: ['commit', repo, sha, from ?? ''],
enabled: Boolean(record),
- queryFn: async () => {
- // Any copy is read through this server, which says whose copy
- // answered. A range reads as one diff across it.
- const copy = from && from !== account.did ? from : undefined;
- const reader = readerFor(repo, copy);
- const did = copy ?? account.did;
- return base
- ? reader.readRange(did, repo, base, tip)
- : reader.readCommit(did, repo, tip);
- },
+ queryFn: () => readChange(repo, sha, from),
});
// The heading waits for the authors, so a face never lands after it.
const authors = useQuery({
diff --git a/packages/git-ui/src/router.jsx b/packages/git-ui/src/router.jsx
new file mode 100644
index 0000000..bdb6b0a
--- /dev/null
+++ b/packages/git-ui/src/router.jsx
@@ -0,0 +1,466 @@
+import {
+ createRootRouteWithContext,
+ createRoute,
+ createRouter,
+} from '@tanstack/react-router';
+import { useEffect, useRef, useState } from 'react';
+import { App } from '#/app.jsx';
+import { Link } from '#/components/atoms/link.jsx';
+import { Skeleton } from '#/components/atoms/skeleton.jsx';
+import { loadAuthors } from '#/lib/authors.js';
+import { loadChecks } from '#/lib/checks.js';
+import {
+ loadPulls,
+ parsePullPath,
+ pullCommits,
+ pullStatements,
+} from '#/lib/collab.js';
+import {
+ account,
+ defaultRef,
+ loadRepos,
+ readChange,
+ repoRecord,
+ splitRefPath,
+} from '#/lib/git.js';
+import { loadIssueStatements } from '#/lib/issues.js';
+import { useNavigate } from '#/lib/navigation.jsx';
+import { clearRenewal, completeSignIn } from '#/lib/oauth.js';
+import { loadPackages } from '#/lib/packages.js';
+import { useSession } from '#/lib/session.jsx';
+import { CheckPage } from '#/pages/check.jsx';
+import { ChecksPage } from '#/pages/checks.jsx';
+import { CommitPage } from '#/pages/commit.jsx';
+import { CommitsPage } from '#/pages/commits.jsx';
+import { FilePage } from '#/pages/file.jsx';
+import { IssuePage } from '#/pages/issue.jsx';
+import { NewIssuePage } from '#/pages/issue-new.jsx';
+import { IssuesPage } from '#/pages/issues.jsx';
+import { PackagesPage } from '#/pages/packages.jsx';
+import { PullPage } from '#/pages/pull.jsx';
+import { PullsPage } from '#/pages/pulls.jsx';
+import { ReposPage } from '#/pages/repos.jsx';
+import { TreePage } from '#/pages/tree.jsx';
+
+/**
+ * The routes, and what each one reads before it renders.
+ *
+ * A route's loader warms the query the page then subscribes to, under the
+ * same key, so the page renders with the answer in hand and a link starts
+ * the read when the pointer settles on it. The page keeps its own query:
+ * that is what re-renders it when a publish invalidates the key.
+ *
+ * The account gate stays in the shell. The last visit's account and
+ * repository rows are read synchronously there, so a loader below can ask
+ * the server for a repository on the first frame.
+ */
+
+/** @typedef {{queryClient: import('@tanstack/react-query').QueryClient}} RouterContext */
+
+const rootRoute = createRootRouteWithContext()({ component: App });
+
+/**
+ * Where the authorization server sends the browser back. The code is spent
+ * once, so the address bar is cleaned before anything else renders: a reload
+ * of this URL would otherwise present a code the server has already refused.
+ */
+function CallbackPage() {
+ const navigate = useNavigate();
+ const { setSession } = useSession();
+ const [error, setError] = useState(null);
+
+ // The exchange consumes the pending sign-in the moment it starts, so it
+ // must run once however many times the effect fires. StrictMode fires it
+ // twice in development, and a second run would find nothing pending and
+ // report a failure over a sign-in that succeeded. The ref survives the
+ // simulated remount; a cleanup-cancelled flag would not, and would also
+ // cancel the first run's redirect.
+ const ran = useRef(false);
+ useEffect(() => {
+ if (ran.current) return;
+ ran.current = true;
+ // Read from the address bar rather than from a prop: the code is spent
+ // once and this effect must not re-run, so it depends on nothing a render
+ // makes anew.
+ completeSignIn(new URLSearchParams(window.location.search))
+ .then(({ session, returnTo }) => {
+ clearRenewal();
+ setSession(session);
+ navigate(returnTo);
+ })
+ .catch((err) => setError(err.message));
+ }, [navigate, setSession]);
+
+ if (error) {
+ return (
+
+
{error}
+
+ Start again
+
+
+ );
+ }
+ return ;
+}
+
+/**
+ * Warm one query. What a loader does: the page subscribes to the same key.
+ * @param {RouterContext} context
+ * @param {unknown[]} queryKey
+ * @param {() => Promise} queryFn
+ */
+const warm = ({ queryClient }, queryKey, queryFn) =>
+ queryClient.ensureQueryData({ queryKey, queryFn });
+
+/**
+ * The DID whose copy an address names, where it names one, and the rest.
+ * A commit on a contributor's branch is addressed by whose copy holds it,
+ * so this account's own commits keep the shorter address they always had.
+ * @param {string} splat
+ */
+function copyOf(splat) {
+ const parts = splat.split('/').filter(Boolean);
+ const from = parts[0]?.startsWith('did:') ? parts[0] : '';
+ return { from, rest: from ? parts.slice(1) : parts };
+}
+
+const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ loader: ({ context }) => warm(context, ['repos'], loadRepos),
+ component: ReposPage,
+});
+
+const callbackRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/callback',
+ component: CallbackPage,
+});
+
+/**
+ * One repository. Every section below reads its refs out of the record, and
+ * a name the account does not hold has no sections.
+ */
+const repoRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/$repo',
+ loader: ({ context }) => warm(context, ['authors'], loadAuthors),
+});
+
+const treeIndexRoute = createRoute({
+ getParentRoute: () => repoRoute,
+ path: '/',
+ component: function TreeIndex() {
+ const { repo } = treeIndexRoute.useParams();
+ const record = repoRecord(repo);
+ if (!record) return ;
+ return ;
+ },
+});
+
+/**
+ * A ref name may hold slashes, so where the ref ends and the path begins is
+ * read against the repository's own refs.
+ */
+const treeRoute = createRoute({
+ getParentRoute: () => repoRoute,
+ path: '/tree/$',
+ component: function Tree() {
+ const { repo, _splat } = treeRoute.useParams();
+ const record = repoRecord(repo);
+ if (!record) return ;
+ const { ref, path } = splitRefPath(record, _splat ?? '');
+ return ;
+ },
+});
+
+const blobRoute = createRoute({
+ getParentRoute: () => repoRoute,
+ path: '/blob/$',
+ component: function Blob() {
+ const { repo, _splat } = blobRoute.useParams();
+ const record = repoRecord(repo);
+ if (!record) return ;
+ const { ref, path } = splitRefPath(record, _splat ?? '');
+ return ;
+ },
+});
+
+/**
+ * A history: a ref's, or the commits across a range, in this account's copy
+ * or another's. A segment carrying `..` is a range rather than a branch,
+ * since git forbids `..` in a ref name.
+ */
+const commitsRoute = createRoute({
+ getParentRoute: () => repoRoute,
+ path: '/commits/$',
+ component: function Commits() {
+ const { repo, _splat } = commitsRoute.useParams();
+ const record = repoRecord(repo);
+ if (!record) return ;
+ const { from, rest } = copyOf(_splat ?? '');
+ const tail = rest.join('/');
+ if (tail.includes('..')) {
+ return ;
+ }
+ return ;
+ },
+});
+
+const commitsIndexRoute = createRoute({
+ getParentRoute: () => repoRoute,
+ path: '/commits',
+ component: function CommitsIndex() {
+ const { repo } = commitsIndexRoute.useParams();
+ const record = repoRecord(repo);
+ if (!record) return ;
+ return ;
+ },
+});
+
+const commitRoute = createRoute({
+ getParentRoute: () => repoRoute,
+ path: '/commit/$',
+ loader: ({ context, params }) => {
+ const { from, rest } = copyOf(params._splat ?? '');
+ const sha = rest.join('/');
+ return warm(context, ['commit', params.repo, sha, from], () =>
+ readChange(params.repo, sha, from),
+ );
+ },
+ component: function Commit() {
+ const { repo, _splat } = commitRoute.useParams();
+ if (!repoRecord(repo)) return ;
+ const { from, rest } = copyOf(_splat ?? '');
+ return ;
+ },
+});
+
+const checksRoute = createRoute({
+ getParentRoute: () => repoRoute,
+ path: '/checks',
+ loader: ({ context, params }) =>
+ warm(context, ['checks', params.repo], () => loadChecks(params.repo)),
+ component: function Checks() {
+ const { repo } = checksRoute.useParams();
+ if (!repoRecord(repo)) return ;
+ return ;
+ },
+});
+
+const checkRoute = createRoute({
+ getParentRoute: () => repoRoute,
+ path: '/checks/$rkey',
+ loader: ({ context, params }) =>
+ warm(context, ['checks', params.repo], () => loadChecks(params.repo)),
+ component: function Check() {
+ const { repo, rkey } = checkRoute.useParams();
+ if (!repoRecord(repo)) return ;
+ return ;
+ },
+});
+
+const pullsRoute = createRoute({
+ getParentRoute: () => repoRoute,
+ path: '/pulls',
+ loader: ({ context, params }) =>
+ warm(context, ['pulls', params.repo], () => loadPulls(params.repo)),
+ component: function Pulls() {
+ const { repo } = pullsRoute.useParams();
+ if (!repoRecord(repo)) return ;
+ return ;
+ },
+});
+
+const pullsTabRoute = createRoute({
+ getParentRoute: () => repoRoute,
+ path: '/pulls/$tab',
+ loader: ({ context, params }) =>
+ warm(context, ['pulls', params.repo], () => loadPulls(params.repo)),
+ component: function PullsTab() {
+ const { repo, tab } = pullsTabRoute.useParams();
+ if (!repoRecord(repo)) return ;
+ return ;
+ },
+});
+
+/**
+ * A pull request is addressed by the account whose copy holds the work and
+ * the branch, so the address survives the rebases that move its commits.
+ * parsePullPath reads that address, beside the diffHref that writes it.
+ *
+ * The list names the pull request, so the loader reads the list and then
+ * warms what its page shows: the diff across it, its commits, and the
+ * statements about it, together rather than one after the other.
+ */
+const pullRoute = createRoute({
+ getParentRoute: () => repoRoute,
+ path: '/pull/$',
+ loader: async ({ context, params }) => {
+ const { repo } = params;
+ const { did, branch, commit } = parsePullPath(
+ (params._splat ?? '').split('/').filter(Boolean),
+ );
+ const pulls = /** @type {import('#/lib/collab.js').Pulls} */ (
+ await warm(context, ['pulls', repo], () => loadPulls(repo))
+ );
+ const pull = [...pulls.open, ...pulls.merged].find(
+ (one) => one.author.did === did && one.branch === branch,
+ );
+ if (!pull) return;
+ const from = pull.author.did === account.did ? '' : pull.author.did;
+ const sha = commit || (pull.base ? `${pull.base}..${pull.sha}` : pull.sha);
+ await Promise.all([
+ warm(context, ['commit', repo, sha, from], () =>
+ readChange(repo, sha, from),
+ ),
+ commit
+ ? Promise.resolve()
+ : warm(context, ['commits', repo, '', sha, from], () =>
+ pullCommits(repo, pull.base, pull.sha, from, 50),
+ ),
+ warm(context, ['statements', repo], () => pullStatements(repo)),
+ ]);
+ },
+ component: function Pull() {
+ const { repo, _splat } = pullRoute.useParams();
+ if (!repoRecord(repo)) return ;
+ const { did, branch, tab, commit } = parsePullPath(
+ (_splat ?? '').split('/').filter(Boolean),
+ );
+ return (
+
+ );
+ },
+});
+
+const issuesRoute = createRoute({
+ getParentRoute: () => repoRoute,
+ path: '/issues',
+ loader: ({ context, params }) =>
+ warm(context, ['issues', params.repo], () =>
+ loadIssueStatements(params.repo),
+ ),
+ component: function Issues() {
+ const { repo } = issuesRoute.useParams();
+ if (!repoRecord(repo)) return ;
+ return ;
+ },
+});
+
+/**
+ * Filing is a page of its own. A DID is never the word `new`, so this and a
+ * thread cannot be mistaken for each other; it is listed before the tab
+ * route so the word is not read as a tab.
+ */
+const issueNewRoute = createRoute({
+ getParentRoute: () => repoRoute,
+ path: '/issues/new',
+ component: function IssueNew() {
+ const { repo } = issueNewRoute.useParams();
+ if (!repoRecord(repo)) return ;
+ return ;
+ },
+});
+
+const issuesTabRoute = createRoute({
+ getParentRoute: () => repoRoute,
+ path: '/issues/$tab',
+ loader: ({ context, params }) =>
+ warm(context, ['issues', params.repo], () =>
+ loadIssueStatements(params.repo),
+ ),
+ component: function IssuesTab() {
+ const { repo, tab } = issuesTabRoute.useParams();
+ if (!repoRecord(repo)) return ;
+ return ;
+ },
+});
+
+/**
+ * An issue thread is addressed by its opener: whose repo holds it, and the
+ * record key there. The pull requests are read too, for the ones whose
+ * commits claim to fix it.
+ */
+const issueRoute = createRoute({
+ getParentRoute: () => repoRoute,
+ path: '/issues/$did/$rkey',
+ loader: ({ context, params }) =>
+ Promise.all([
+ warm(context, ['issues', params.repo], () =>
+ loadIssueStatements(params.repo),
+ ),
+ warm(context, ['pulls', params.repo], () => loadPulls(params.repo)),
+ ]),
+ component: function Issue() {
+ const { repo, did, rkey } = issueRoute.useParams();
+ if (!repoRecord(repo)) return ;
+ return ;
+ },
+});
+
+const packagesRoute = createRoute({
+ getParentRoute: () => repoRoute,
+ path: '/packages',
+ loader: ({ context, params }) =>
+ warm(context, ['packages', params.repo], () => loadPackages(params.repo)),
+ component: function Packages() {
+ const { repo } = packagesRoute.useParams();
+ if (!repoRecord(repo)) return ;
+ return ;
+ },
+});
+
+function NoSuchRepository() {
+ return