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