From e2ead67830d5e68fd0c24b85c516bfecc2bb6113 Mon Sep 17 00:00:00 2001 From: Chad Miller Date: Thu, 27 Aug 2026 13:42:07 -0700 Subject: [PATCH] feat(git-ui): the pages are routes, and each reads what it needs first The shell split the path itself and chose a page with a chain of ifs, and every page began reading on render. The router names each page as a route with the read it needs as its loader, warming the query the page then subscribes to under the same key: the answer is in hand before the page renders, a pull request's diff, commits and statements are read together rather than one after the other, and a link starts its page's read when the pointer settles on it. The addressing the pages own stays theirs. A pull request's address holds a DID and a branch with slashes in it, and a ref may hold slashes before a path, so those routes take the rest of the path whole and parse it with the same functions as before. The navigation module is an adapter over the router in the shape the pages take, so a page calls what it called. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RomHvaB4nyAckbRD7W36Ue --- packages/git-ui/package.json | 1 + packages/git-ui/src/app.jsx | 163 +----- packages/git-ui/src/components/atoms/link.jsx | 28 +- packages/git-ui/src/lib/git.js | 18 + packages/git-ui/src/lib/navigation.jsx | 56 +-- packages/git-ui/src/main.jsx | 7 +- packages/git-ui/src/pages/commit.jsx | 13 +- packages/git-ui/src/router.jsx | 466 ++++++++++++++++++ pnpm-lock.yaml | 81 +++ 9 files changed, 615 insertions(+), 218 deletions(-) create mode 100644 packages/git-ui/src/router.jsx 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. */}
@@ -349,7 +212,7 @@ export function App() { )} > {path === '/callback' ? ( - + ) : error && !ready ? (

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

No such repository.

; +} + +const routeTree = rootRoute.addChildren([ + indexRoute, + callbackRoute, + repoRoute.addChildren([ + treeIndexRoute, + treeRoute, + blobRoute, + commitsIndexRoute, + commitsRoute, + commitRoute, + checksRoute, + checkRoute, + pullsRoute, + pullsTabRoute, + pullRoute, + issuesRoute, + issueNewRoute, + issuesTabRoute, + issueRoute, + packagesRoute, + ]), +]); + +/** + * @param {import('@tanstack/react-query').QueryClient} queryClient + */ +export function createAppRouter(queryClient) { + return createRouter({ + routeTree, + context: { queryClient }, + // A link's page is read when the pointer settles on it. The query + // client says whether the answer is fresh, so the router never re-reads + // what the client already holds. + defaultPreload: 'intent', + defaultPreloadStaleTime: 0, + // A loader that answers from the cache renders at once; one that goes to + // the network shows the page's own placeholder after a beat. + defaultPendingMs: 150, + defaultPendingMinMs: 0, + scrollRestoration: false, + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 07b772d..5c673b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -315,6 +315,9 @@ importers: '@tanstack/react-query': specifier: ^5.101.4 version: 5.101.4(react@19.2.8) + '@tanstack/react-router': + specifier: ^1.170.32 + version: 1.170.32(react-dom@19.2.8(react@19.2.8))(react@19.2.8) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -1470,6 +1473,10 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@tanstack/history@1.162.1': + resolution: {integrity: sha512-DR9t6lfLVdrjgCwpglrR9DR7Ok8/HlXjcOE+goWXF3zyuLUO/ug7vMbSFxTqrQTtbRghJfyhmIZ0S6LhPIy44w==} + engines: {node: '>=20.19'} + '@tanstack/query-core@5.101.4': resolution: {integrity: sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==} @@ -1478,6 +1485,26 @@ packages: peerDependencies: react: ^18 || ^19 + '@tanstack/react-router@1.170.32': + resolution: {integrity: sha512-SIpxvaTKco100a5ZR3ePmArbhtm3XOx+w1dpGYY9gxHDta4iXSKDdQuhLonwJbIMkVJsU1rwXf0UDHMrF/1snw==} + engines: {node: '>=20.19'} + peerDependencies: + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + + '@tanstack/react-store@0.9.3': + resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/router-core@1.171.27': + resolution: {integrity: sha512-wDwSLvoLwIaNcnx9UNcN9Mb7Y8QwCYq1U1RQZwyN186gnkIoIYI2SOxy8VqH1vFigbkHkk4FmwMAQlghPgDK2g==} + engines: {node: '>=20.19'} + + '@tanstack/store@0.9.3': + resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} + '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} @@ -1779,6 +1806,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-es@3.1.1: + resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + cookie@1.1.1: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} @@ -1985,6 +2015,10 @@ packages: resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} engines: {node: '>=0.10.0'} + isbot@5.2.2: + resolution: {integrity: sha512-iQcBXcd+Rv/pkubRyGh2utW2j1oPG5hZY6TUhVPpqK4G+o3IbxpJNx04hgksjc/N7GK5pEorUxDeg31cFgEk/w==} + engines: {node: '>=18'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -2416,6 +2450,16 @@ packages: engines: {node: '>=10'} hasBin: true + seroval-plugins@1.6.4: + resolution: {integrity: sha512-R0f1U9hmn38+dFMz6b6ab8lwucmw4AtiY7St+JPWudy1dm+Bs3g884nyrsH9Cy6rKpZKLYayXuMda9GZ/fl8JQ==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.6.4: + resolution: {integrity: sha512-LErWMNS2RRFdu2RMA5u/PA59/IWs0XsikyEXGQ2/36iEWFrdG0ABmg17E17cikrv76891kOAMq3TkTFXpwAHXw==} + engines: {node: '>=10'} + sharp@0.35.2: resolution: {integrity: sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==} engines: {node: '>=20.9.0'} @@ -3513,6 +3557,8 @@ snapshots: tailwindcss: 4.3.3 vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) + '@tanstack/history@1.162.1': {} + '@tanstack/query-core@5.101.4': {} '@tanstack/react-query@5.101.4(react@19.2.8)': @@ -3520,6 +3566,31 @@ snapshots: '@tanstack/query-core': 5.101.4 react: 19.2.8 + '@tanstack/react-router@1.170.32(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@tanstack/history': 1.162.1 + '@tanstack/react-store': 0.9.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@tanstack/router-core': 1.171.27 + isbot: 5.2.2 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + '@tanstack/react-store@0.9.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@tanstack/store': 0.9.3 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + use-sync-external-store: 1.6.0(react@19.2.8) + + '@tanstack/router-core@1.171.27': + dependencies: + '@tanstack/history': 1.162.1 + cookie-es: 3.1.1 + seroval: 1.6.4 + seroval-plugins: 1.6.4(seroval@1.6.4) + + '@tanstack/store@0.9.3': {} + '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 @@ -3762,6 +3833,8 @@ snapshots: convert-source-map@2.0.0: {} + cookie-es@3.1.1: {} + cookie@1.1.1: {} cross-spawn@7.0.6: @@ -3964,6 +4037,8 @@ snapshots: is-windows@1.0.2: {} + isbot@5.2.2: {} + isexe@2.0.0: {} istanbul-lib-coverage@3.2.2: {} @@ -4374,6 +4449,12 @@ snapshots: semver@7.8.5: {} + seroval-plugins@1.6.4(seroval@1.6.4): + dependencies: + seroval: 1.6.4 + + seroval@1.6.4: {} + sharp@0.35.2: dependencies: '@img/colour': 1.1.0 -- 2.51.2