Detailed changes
@@ -16,6 +16,7 @@
},
"dependencies": {
"@apollo/client": "^4.1.6",
+ "@tanstack/react-router": "^1.168.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
@@ -26,7 +27,6 @@
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-markdown": "^10.1.0",
- "react-router": "^7.13.2",
"rehype-autolink-headings": "^7.1.0",
"rehype-external-links": "^3.0.0",
"rehype-raw": "^7.0.0",
@@ -11,6 +11,9 @@ importers:
'@apollo/client':
specifier: ^4.1.6
version: 4.1.6(graphql-ws@6.0.8(graphql@16.13.2)(ws@8.20.0))(graphql@16.13.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rxjs@7.8.2)
+ '@tanstack/react-router':
+ specifier: ^1.168.8
+ version: 1.168.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -41,9 +44,6 @@ importers:
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.14)(react@19.2.4)
- react-router:
- specifier: ^7.13.2
- version: 7.13.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
rehype-autolink-headings:
specifier: ^7.1.0
version: 7.1.0
@@ -1847,6 +1847,31 @@ packages:
peerDependencies:
vite: ^5.2.0 || ^6 || ^7 || ^8
+ '@tanstack/history@1.161.6':
+ resolution: {integrity: sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==}
+ engines: {node: '>=20.19'}
+
+ '@tanstack/react-router@1.168.8':
+ resolution: {integrity: sha512-t0S0QueXubBKmI9eLPcN/A1sLQgTu8/yHerjrvvsGeD12zMdw0uJPKwEKpStQF2OThQtw64cs34uUSYXBUTSNw==}
+ 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.168.7':
+ resolution: {integrity: sha512-z4UEdlzMrFaKBsG4OIxlZEm+wsYBtEp//fnX6kW18jhQpETNcM6u2SXNdX+bcIYp6AaR7ERS3SBENzjC/xxwQQ==}
+ engines: {node: '>=20.19'}
+ hasBin: true
+
+ '@tanstack/store@0.9.3':
+ resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==}
+
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
@@ -2087,9 +2112,8 @@ packages:
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
- cookie@1.1.1:
- resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
- engines: {node: '>=18'}
+ cookie-es@2.0.0:
+ resolution: {integrity: sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==}
cosmiconfig@8.3.6:
resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==}
@@ -2470,6 +2494,10 @@ packages:
resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==}
engines: {node: '>=0.10.0'}
+ isbot@5.1.36:
+ resolution: {integrity: sha512-C/ZtXyJqDPZ7G7JPr06ApWyYoHjYexQbS6hPYD4WYCzpv2Qes6Z+CCEfTX4Owzf+1EJ933PoI2p+B9v7wpGZBQ==}
+ engines: {node: '>=18'}
+
isomorphic-ws@5.0.0:
resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==}
peerDependencies:
@@ -2961,16 +2989,6 @@ packages:
'@types/react':
optional: true
- react-router@7.13.2:
- resolution: {integrity: sha512-tX1Aee+ArlKQP+NIUd7SE6Li+CiGKwQtbS+FfRxPX6Pe4vHOo6nr9d++u5cwg+Z8K/x8tP+7qLmujDtfrAoUJA==}
- engines: {node: '>=20.0.0'}
- peerDependencies:
- react: '>=18'
- react-dom: '>=18'
- peerDependenciesMeta:
- react-dom:
- optional: true
-
react-style-singleton@2.2.3:
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
engines: {node: '>=10'}
@@ -3072,8 +3090,15 @@ packages:
sentence-case@3.0.4:
resolution: {integrity: sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg==}
- set-cookie-parser@2.7.2:
- resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
+ seroval-plugins@1.5.1:
+ resolution: {integrity: sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ seroval: ^1.0
+
+ seroval@1.5.1:
+ resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==}
+ engines: {node: '>=10'}
shell-quote@1.8.3:
resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
@@ -5163,6 +5188,33 @@ snapshots:
tailwindcss: 4.2.2
vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(jiti@2.6.1)(yaml@2.8.3)
+ '@tanstack/history@1.161.6': {}
+
+ '@tanstack/react-router@1.168.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ dependencies:
+ '@tanstack/history': 1.161.6
+ '@tanstack/react-store': 0.9.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@tanstack/router-core': 1.168.7
+ isbot: 5.1.36
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+
+ '@tanstack/react-store@0.9.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ dependencies:
+ '@tanstack/store': 0.9.3
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+ use-sync-external-store: 1.6.0(react@19.2.4)
+
+ '@tanstack/router-core@1.168.7':
+ dependencies:
+ '@tanstack/history': 1.161.6
+ cookie-es: 2.0.0
+ seroval: 1.5.1
+ seroval-plugins: 1.5.1(seroval@1.5.1)
+
+ '@tanstack/store@0.9.3': {}
+
'@tybys/wasm-util@0.10.1':
dependencies:
tslib: 2.8.1
@@ -5403,7 +5455,7 @@ snapshots:
convert-source-map@2.0.0: {}
- cookie@1.1.1: {}
+ cookie-es@2.0.0: {}
cosmiconfig@8.3.6(typescript@6.0.2):
dependencies:
@@ -5778,6 +5830,8 @@ snapshots:
is-windows@1.0.2: {}
+ isbot@5.1.36: {}
+
isomorphic-ws@5.0.0(ws@8.20.0):
dependencies:
ws: 8.20.0
@@ -6556,14 +6610,6 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
- react-router@7.13.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
- dependencies:
- cookie: 1.1.1
- react: 19.2.4
- set-cookie-parser: 2.7.2
- optionalDependencies:
- react-dom: 19.2.4(react@19.2.4)
-
react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.4):
dependencies:
get-nonce: 1.0.1
@@ -6718,7 +6764,11 @@ snapshots:
tslib: 2.8.1
upper-case-first: 2.0.2
- set-cookie-parser@2.7.2: {}
+ seroval-plugins@1.5.1(seroval@1.5.1):
+ dependencies:
+ seroval: 1.5.1
+
+ seroval@1.5.1: {}
shell-quote@1.8.3: {}
@@ -1,4 +1,4 @@
-import { createBrowserRouter, RouterProvider } from "react-router";
+import { createRootRoute, createRoute, createRouter, RouterProvider } from "@tanstack/react-router";
import { Shell } from "@/components/layout/Shell";
import { RepoShell } from "@/lib/repo";
@@ -12,35 +12,103 @@ import { NewBugPage } from "@/pages/NewBugPage";
import { RepoPickerPage } from "@/pages/RepoPickerPage";
import { UserProfilePage } from "@/pages/UserProfilePage";
-// Route structure:
-// / → repo picker (or redirect if single repo)
-// /:repo → code browser (repo home)
-// /:repo/issues → issue list
-// /auth/select-identity → OAuth identity adoption (first-time login)
-const router = createBrowserRouter([
- {
- path: "/",
- element: <Shell />,
- errorElement: <ErrorPage />,
- children: [
- { index: true, element: <RepoPickerPage /> },
- { path: "auth/select-identity", element: <IdentitySelectPage /> },
- {
- path: ":repo",
- element: <RepoShell />,
- children: [
- { index: true, element: <CodePage /> },
- { path: "issues", element: <BugListPage /> },
- { path: "issues/new", element: <NewBugPage /> },
- { path: "issues/:id", element: <BugDetailPage /> },
- { path: "user/:id", element: <UserProfilePage /> },
- { path: "commit/:hash", element: <CommitPage /> },
- ],
- },
- ],
- },
+// ── Route tree ───────────────────────────────────────────────────────────────
+
+const rootRoute = createRootRoute({
+ component: Shell,
+ errorComponent: ErrorPage,
+});
+
+const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: "/",
+ component: RepoPickerPage,
+});
+
+const authSelectIdentityRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: "/auth/select-identity",
+ component: IdentitySelectPage,
+});
+
+const repoRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: "/$repo",
+ component: RepoShell,
+});
+
+export type CodePageSearch = {
+ ref: string;
+ path: string;
+ type: "tree" | "blob" | "commits";
+};
+
+const repoIndexRoute = createRoute({
+ getParentRoute: () => repoRoute,
+ path: "/",
+ component: CodePage,
+ validateSearch: (search: Record<string, unknown>): CodePageSearch => ({
+ ref: (search.ref as string) ?? "",
+ path: (search.path as string) ?? "",
+ type: ["tree", "blob", "commits"].includes(search.type as string)
+ ? (search.type as CodePageSearch["type"])
+ : "tree",
+ }),
+});
+
+const bugListRoute = createRoute({
+ getParentRoute: () => repoRoute,
+ path: "/issues",
+ component: BugListPage,
+});
+
+const newBugRoute = createRoute({
+ getParentRoute: () => repoRoute,
+ path: "/issues/new",
+ component: NewBugPage,
+});
+
+const bugDetailRoute = createRoute({
+ getParentRoute: () => repoRoute,
+ path: "/issues/$id",
+ component: BugDetailPage,
+});
+
+const userProfileRoute = createRoute({
+ getParentRoute: () => repoRoute,
+ path: "/user/$id",
+ component: UserProfilePage,
+});
+
+const commitRoute = createRoute({
+ getParentRoute: () => repoRoute,
+ path: "/commit/$hash",
+ component: CommitPage,
+});
+
+const routeTree = rootRoute.addChildren([
+ indexRoute,
+ authSelectIdentityRoute,
+ repoRoute.addChildren([
+ repoIndexRoute,
+ bugListRoute,
+ newBugRoute,
+ bugDetailRoute,
+ userProfileRoute,
+ commitRoute,
+ ]),
]);
+// ── Router instance ──────────────────────────────────────────────────────────
+
+const router = createRouter({ routeTree });
+
+declare module "@tanstack/react-router" {
+ interface Register {
+ router: typeof router;
+ }
+}
+
export function App() {
return <RouterProvider router={router} />;
}
@@ -1,6 +1,6 @@
+import { Link } from "@tanstack/react-router";
import { formatDistanceToNow } from "date-fns";
import { MessageSquare, CircleDot, CircleCheck } from "lucide-react";
-import { Link } from "react-router";
import { Status } from "@/__generated__/graphql";
@@ -1,7 +1,7 @@
+import { Link } from "@tanstack/react-router";
import { formatDistanceToNow } from "date-fns";
import { Tag, GitPullRequestClosed, Pencil, CircleDot } from "lucide-react";
import { useState } from "react";
-import { Link } from "react-router";
import {
Status,
@@ -98,7 +98,8 @@ function CommentItem({ item, bugPrefix }: { item: CommentItem; bugPrefix: string
<div className="border-border min-w-0 flex-1 rounded-md border">
<div className="border-border bg-muted/40 flex items-center gap-2 border-b px-4 py-2 text-sm">
<Link
- to={repo ? `/${repo}/user/${item.author.humanId}` : `/user/${item.author.humanId}`}
+ to="/$repo/user/$id"
+ params={{ repo: repo!, id: item.author.humanId }}
className="text-foreground font-medium hover:underline"
>
{item.author.displayName}
@@ -177,7 +178,8 @@ function LabelChangeItem({ item }: { item: LabelChangeItem }) {
<EventRow icon={<Tag className="size-4" />}>
<span>
<Link
- to={repo ? `/${repo}/user/${item.author.humanId}` : `/user/${item.author.humanId}`}
+ to="/$repo/user/$id"
+ params={{ repo: repo!, id: item.author.humanId }}
className="text-foreground font-medium hover:underline"
>
{item.author.displayName}
@@ -219,7 +221,8 @@ function StatusChangeItem({ item }: { item: StatusChangeItem }) {
>
<span>
<Link
- to={repo ? `/${repo}/user/${item.author.humanId}` : `/user/${item.author.humanId}`}
+ to="/$repo/user/$id"
+ params={{ repo: repo!, id: item.author.humanId }}
className="text-foreground font-medium hover:underline"
>
{item.author.displayName}
@@ -237,7 +240,8 @@ function TitleChangeItem({ item }: { item: TitleChangeItem }) {
<EventRow icon={<Pencil className="size-4" />}>
<span>
<Link
- to={repo ? `/${repo}/user/${item.author.humanId}` : `/user/${item.author.humanId}`}
+ to="/$repo/user/$id"
+ params={{ repo: repo!, id: item.author.humanId }}
className="text-foreground font-medium hover:underline"
>
{item.author.displayName}
@@ -3,10 +3,10 @@
import { gql } from "@apollo/client";
import { useQuery } from "@apollo/client/react";
+import { Link } from "@tanstack/react-router";
import { formatDistanceToNow } from "date-fns";
import { GitCommit } from "lucide-react";
import { useEffect, useState } from "react";
-import { Link } from "react-router";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
@@ -1,6 +1,6 @@
+import { Link } from "@tanstack/react-router";
import { formatDistanceToNow } from "date-fns";
import { Folder, File } from "lucide-react";
-import { Link } from "react-router";
import type { GitTreeEntry } from "@/__generated__/graphql";
import { Skeleton } from "@/components/ui/skeleton";
@@ -84,9 +84,8 @@ function FileTreeRow({
<td className="text-muted-foreground hidden max-w-xs truncate px-3 py-2 md:table-cell">
{entry.lastCommit && (
<Link
- to={
- repo ? `/${repo}/commit/${entry.lastCommit.hash}` : `/commit/${entry.lastCommit.hash}`
- }
+ to="/$repo/commit/$hash"
+ params={{ repo: repo!, hash: entry.lastCommit.hash }}
className="hover:text-foreground hover:underline"
onClick={(e) => e.stopPropagation()}
>
@@ -6,8 +6,8 @@
// In external mode, shows a "Sign in" button when logged out and a sign-out
// action when logged in.
+import { Link, useMatchRoute, useParams } from "@tanstack/react-router";
import { Bug, Plus, Sun, Moon, LogIn, LogOut } from "lucide-react";
-import { Link, useMatch, NavLink } from "react-router";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
@@ -34,15 +34,21 @@ export function Header() {
const { user, mode, loginProviders } = useAuth();
const { theme, toggle } = useTheme();
- // Detect if we're inside a /:repo route and grab the slug.
- // useMatch works from any component in the tree, unlike useParams which is
- // scoped to the nearest Route element.
- const repoMatch = useMatch({ path: "/:repo/*", end: false });
- const repo = repoMatch?.params.repo ?? null;
+ // Detect if we're inside a /$repo route and grab the slug.
+ const params = useParams({ strict: false });
+ const repo = params.repo ?? null;
// Don't show repo nav on the /auth/* pages.
const effectiveRepo = repo === "auth" ? null : repo;
+ const matchRoute = useMatchRoute();
+ const isCodeActive = effectiveRepo
+ ? !!matchRoute({ to: "/$repo", params: { repo: effectiveRepo }, fuzzy: false })
+ : false;
+ const isIssuesActive = effectiveRepo
+ ? !!matchRoute({ to: "/$repo/issues", params: { repo: effectiveRepo }, fuzzy: true })
+ : false;
+
return (
<header className="border-border bg-background/95 sticky top-0 z-50 border-b backdrop-blur">
<div className="mx-auto flex h-14 max-w-screen-xl items-center gap-6 px-4">
@@ -55,33 +61,31 @@ export function Header() {
{/* Repo-scoped nav links — only shown when inside a repo */}
{effectiveRepo && (
<nav className="flex items-center gap-1">
- <NavLink
- to={`/${effectiveRepo}`}
- end
- className={({ isActive }) =>
- cn(
- "rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
- isActive
- ? "bg-accent text-accent-foreground"
- : "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
- )
- }
+ <Link
+ to="/$repo"
+ params={{ repo: effectiveRepo }}
+ search={{ ref: "", path: "", type: "tree" as const }}
+ className={cn(
+ "rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
+ isCodeActive
+ ? "bg-accent text-accent-foreground"
+ : "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
+ )}
>
Code
- </NavLink>
- <NavLink
- to={`/${effectiveRepo}/issues`}
- className={({ isActive }) =>
- cn(
- "rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
- isActive
- ? "bg-accent text-accent-foreground"
- : "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
- )
- }
+ </Link>
+ <Link
+ to="/$repo/issues"
+ params={{ repo: effectiveRepo }}
+ className={cn(
+ "rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
+ isIssuesActive
+ ? "bg-accent text-accent-foreground"
+ : "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
+ )}
>
Issues
- </NavLink>
+ </Link>
</nav>
)}
@@ -107,12 +111,12 @@ export function Header() {
{user && effectiveRepo && (
<>
<Button asChild size="sm">
- <Link to={`/${effectiveRepo}/issues/new`}>
+ <Link to="/$repo/issues/new" params={{ repo: effectiveRepo }}>
<Plus className="size-4" />
New issue
</Link>
</Button>
- <Link to={`/${effectiveRepo}/user/${user.humanId}`}>
+ <Link to="/$repo/user/$id" params={{ repo: effectiveRepo, id: user.humanId }}>
<Avatar className="size-7">
<AvatarImage src={user.avatarUrl ?? undefined} alt={user.displayName} />
<AvatarFallback className="text-xs">
@@ -1,4 +1,4 @@
-import { Outlet } from "react-router";
+import { Outlet } from "@tanstack/react-router";
import { Header } from "./Header";
@@ -1,20 +1,20 @@
-// Provides the current repository slug (the :repo URL segment) to all
-// components rendered inside a /:repo/* route.
+// Provides the current repository slug (the $repo URL segment) to all
+// components rendered inside a /$repo/* route.
//
// Usage:
-// - Wrap the /:repo route subtree with <RepoShell /> as the route element.
+// - Wrap the /$repo route subtree with <RepoShell /> as the route element.
// - Read the current slug in any child component with useRepo().
// - Pass the slug as `ref` to all GraphQL repository queries.
+import { Outlet, useParams } from "@tanstack/react-router";
import { createContext, useContext } from "react";
-import { useParams, Outlet } from "react-router";
const RepoContext = createContext<string | null>(null);
-// Route element for /:repo routes. Reads the :repo param and provides it
+// Route element for /$repo routes. Reads the $repo param and provides it
// via context so any descendant can call useRepo() without prop drilling.
export function RepoShell() {
- const { repo } = useParams<{ repo: string }>();
+ const { repo } = useParams({ strict: false });
return (
<RepoContext.Provider value={repo ?? null}>
<Outlet />
@@ -23,7 +23,7 @@ export function RepoShell() {
}
// Returns the current repo slug from the nearest RepoShell ancestor.
-// Returns null when rendered outside of a /:repo route (e.g. the picker page).
+// Returns null when rendered outside of a /$repo route (e.g. the picker page).
export function useRepo(): string | null {
return useContext(RepoContext);
}
@@ -1,6 +1,6 @@
+import { useParams, Link } from "@tanstack/react-router";
import { formatDistanceToNow } from "date-fns";
import { ArrowLeft } from "lucide-react";
-import { useParams, Link } from "react-router";
import { useBugDetailQuery } from "@/__generated__/graphql";
import { CommentBox } from "@/components/bugs/CommentBox";
@@ -16,7 +16,7 @@ import { useRepo } from "@/lib/repo";
// Issue detail page (/:repo/issues/:id). Shows title, status, timeline of
// comments and events, and a sidebar with labels and participants.
export function BugDetailPage() {
- const { id } = useParams<{ id: string }>();
+ const { id } = useParams({ strict: false });
const repo = useRepo();
const { data, loading, error } = useBugDetailQuery({
variables: { ref: repo, prefix: id! },
@@ -3,9 +3,9 @@
import { gql } from "@apollo/client";
import { useQuery } from "@apollo/client/react";
+import { useNavigate, useSearch } from "@tanstack/react-router";
import { AlertCircle, GitCommit } from "lucide-react";
import { useEffect } from "react";
-import { useSearchParams } from "react-router";
import type { GitRef, GitTreeEntry, GitBlob, GitLastCommit } from "@/__generated__/graphql";
import { CodeBreadcrumb } from "@/components/code/CodeBreadcrumb";
@@ -104,15 +104,14 @@ interface BlobQueryData {
} | null;
}
-type ViewMode = "tree" | "blob" | "commits";
+import type { CodePageSearch } from "@/App";
+
+type ViewMode = CodePageSearch["type"];
export function CodePage() {
const repo = useRepo();
- const [searchParams, setSearchParams] = useSearchParams();
-
- const currentRef = searchParams.get("ref") ?? "";
- const currentPath = searchParams.get("path") ?? "";
- const viewMode: ViewMode = (searchParams.get("type") as ViewMode) ?? "tree";
+ const navigate = useNavigate({ from: "/$repo/" });
+ const { ref: currentRef, path: currentPath, type: viewMode } = useSearch({ from: "/$repo/" });
const {
data: refsData,
@@ -125,16 +124,13 @@ export function CodePage() {
// Set default ref from query result once loaded
useEffect(() => {
- if (refsLoading || refs.length === 0 || searchParams.get("ref")) return;
+ if (refsLoading || refs.length === 0 || currentRef) return;
const defaultRef = refs.find((r: GitRef) => r.isDefault) ?? refs[0];
if (defaultRef) {
- setSearchParams(
- (prev) => {
- prev.set("ref", defaultRef.shortName);
- return prev;
- },
- { replace: true },
- );
+ void navigate({
+ search: (prev) => ({ ...prev, ref: defaultRef.shortName }),
+ replace: true,
+ });
}
}, [refsLoading, refs.length]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -182,32 +178,23 @@ export function CodePage() {
const repoName = refsData?.repository?.name ?? repo ?? "default-repo";
- function navigate(path: string, type: ViewMode = "tree") {
- setSearchParams((prev) => {
- prev.set("path", path);
- prev.set("type", type);
- return prev;
- });
+ function navigateTo(path: string, type: ViewMode = "tree") {
+ void navigate({ search: (prev) => ({ ...prev, path, type }) });
}
function handleEntryClick(entry: TreeEntryWithCommit) {
const newPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
- navigate(newPath, entry.type === "BLOB" ? "blob" : "tree");
+ navigateTo(newPath, entry.type === "BLOB" ? "blob" : "tree");
}
function handleNavigateUp() {
const parts = currentPath.split("/").filter(Boolean);
parts.pop();
- navigate(parts.join("/"), "tree");
+ navigateTo(parts.join("/"), "tree");
}
function handleRefSelect(ref: GitRef) {
- setSearchParams((prev) => {
- prev.set("ref", ref.shortName);
- prev.set("path", "");
- prev.set("type", "tree");
- return prev;
- });
+ void navigate({ search: { ref: ref.shortName, path: "", type: "tree" } });
}
if (refsError) {
@@ -230,7 +217,7 @@ export function CodePage() {
repoName={repoName}
ref={currentRef}
path={currentPath}
- onNavigate={(p) => navigate(p, "tree")}
+ onNavigate={(p) => navigateTo(p, "tree")}
/>
)}
<div className="flex items-center gap-2">
@@ -238,7 +225,7 @@ export function CodePage() {
<Button
variant={viewMode === "commits" ? "secondary" : "outline"}
size="sm"
- onClick={() => navigate(currentPath, viewMode === "commits" ? "tree" : "commits")}
+ onClick={() => navigateTo(currentPath, viewMode === "commits" ? "tree" : "commits")}
>
<GitCommit className="size-3.5" />
History
@@ -3,9 +3,9 @@
import { gql } from "@apollo/client";
import { useQuery } from "@apollo/client/react";
+import { Link, useParams } from "@tanstack/react-router";
import { format } from "date-fns";
import { ArrowLeft, GitCommit } from "lucide-react";
-import { Link, useParams, useNavigate } from "react-router";
import { FileDiffView } from "@/components/code/FileDiffView";
import { Skeleton } from "@/components/ui/skeleton";
@@ -54,8 +54,7 @@ interface CommitQueryData {
}
export function CommitPage() {
- const { hash } = useParams<{ hash: string }>();
- const navigate = useNavigate();
+ const { hash } = useParams({ strict: false });
const repo = useRepo();
const { data, loading, error } = useQuery<CommitQueryData>(COMMIT_QUERY, {
@@ -83,7 +82,7 @@ export function CommitPage() {
<div>
<button
onClick={() => {
- void navigate(-1);
+ window.history.back();
}}
className="text-muted-foreground hover:text-foreground mb-6 flex items-center gap-1.5 text-sm"
>
@@ -119,7 +118,8 @@ export function CommitPage() {
<span key={p} className="text-muted-foreground">
parent{" "}
<Link
- to={repo ? `/${repo}/commit/${p}` : `/commit/${p}`}
+ to="/$repo/commit/$hash"
+ params={{ repo: repo!, hash: p }}
className="text-foreground font-mono hover:underline"
>
{p.slice(0, 7)}
@@ -1,35 +1,33 @@
-// Global error boundary page. Rendered by React Router when a route throws
-// or when navigation results in a 404. Replaces the default "Unexpected
-// Application Error!" screen.
+// Global error boundary page. Rendered by TanStack Router when a route throws.
+import { Link, useRouter } from "@tanstack/react-router";
import { AlertTriangle } from "lucide-react";
-import { useRouteError, isRouteErrorResponse, Link } from "react-router";
import { Button } from "@/components/ui/button";
-export function ErrorPage() {
- const error = useRouteError();
+export function ErrorPage({ error }: { error?: Error }) {
+ const router = useRouter();
- let status: number | undefined;
- let message: string;
-
- if (isRouteErrorResponse(error)) {
- status = error.status;
- message = error.statusText || error.data;
- } else if (error instanceof Error) {
- message = error.message;
- } else {
- message = "An unexpected error occurred.";
- }
+ const message = error?.message ?? "An unexpected error occurred.";
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 text-center">
<AlertTriangle className="text-muted-foreground size-10" />
- {status && <p className="text-5xl font-bold tracking-tight">{status}</p>}
<p className="text-muted-foreground text-sm">{message}</p>
- <Button variant="outline" size="sm" asChild>
- <Link to="/">Go home</Link>
- </Button>
+ <div className="flex gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ void router.invalidate();
+ }}
+ >
+ Try again
+ </Button>
+ <Button variant="outline" size="sm" asChild>
+ <Link to="/">Go home</Link>
+ </Button>
+ </div>
</div>
);
}
@@ -1,6 +1,6 @@
+import { useNavigate, Link } from "@tanstack/react-router";
import { ArrowLeft } from "lucide-react";
import { useState } from "react";
-import { useNavigate, Link } from "react-router";
import { useBugCreateMutation } from "@/__generated__/graphql";
import { Markdown } from "@/components/content/Markdown";
@@ -27,7 +27,7 @@ export function NewBugPage() {
});
const humanId = result.data?.bugCreate.bug.humanId;
if (humanId) {
- void navigate(repo ? `/${repo}/issues/${humanId}` : `/issues/${humanId}`);
+ void navigate({ to: repo ? `/${repo}/issues/${humanId}` : `/issues/${humanId}` });
}
}
@@ -115,7 +115,7 @@ export function NewBugPage() {
type="button"
variant="ghost"
onClick={() => {
- void navigate(issuesHref);
+ void navigate({ to: issuesHref });
}}
disabled={loading}
>
@@ -1,9 +1,9 @@
// Repository picker page (/). Auto-redirects when there is exactly one repo.
// Shows a list when multiple repos are registered.
+import { Link, useNavigate } from "@tanstack/react-router";
import { GitFork, FolderOpen, AlertCircle } from "lucide-react";
import { useEffect } from "react";
-import { Link, useNavigate } from "react-router";
import { useRepositoriesQuery } from "@/__generated__/graphql";
import { Skeleton } from "@/components/ui/skeleton";
@@ -23,7 +23,12 @@ export function RepoPickerPage() {
// Auto-redirect when there is exactly one repo — no need to pick.
useEffect(() => {
if (data?.repositories.nodes.length === 1) {
- void navigate("/" + repoSlug(data.repositories.nodes[0].name), { replace: true });
+ void navigate({
+ to: "/$repo",
+ params: { repo: repoSlug(data.repositories.nodes[0].name) },
+ search: { ref: "", path: "", type: "tree" as const },
+ replace: true,
+ });
}
}, [data, navigate]);
@@ -53,7 +58,9 @@ export function RepoPickerPage() {
{data?.repositories.nodes.map((repo) => (
<Link
key={repoSlug(repo.name)}
- to={`/${repoSlug(repo.name)}`}
+ to="/$repo"
+ params={{ repo: repoSlug(repo.name) }}
+ search={{ ref: "", path: "", type: "tree" as const }}
className="hover:bg-muted/50 flex items-center gap-3 px-4 py-4 transition-colors"
>
<FolderOpen className="text-muted-foreground size-5 shrink-0" />
@@ -6,6 +6,7 @@
// The :id param is treated as a humanId prefix and passed directly to the
// identity(prefix) and allBugs(query:"author:...") GraphQL arguments.
+import { useParams, Link } from "@tanstack/react-router";
import { formatDistanceToNow } from "date-fns";
import {
ArrowLeft,
@@ -17,7 +18,6 @@ import {
ChevronRight,
} from "lucide-react";
import { useState } from "react";
-import { useParams, Link } from "react-router";
import { Status, useUserProfileQuery } from "@/__generated__/graphql";
import { LabelBadge } from "@/components/bugs/LabelBadge";
@@ -30,7 +30,7 @@ import { cn } from "@/lib/utils";
const PAGE_SIZE = 25;
export function UserProfilePage() {
- const { id } = useParams<{ id: string }>();
+ const { id } = useParams({ strict: false });
const repo = useRepo();
const [statusFilter, setStatusFilter] = useState<"open" | "closed">("open");
@@ -91,12 +91,11 @@ export function UserProfilePage() {
setCursors((prev) => prev.slice(0, -1));
}
- const issuesHref = repo ? `/${repo}/issues` : "/issues";
-
return (
<div>
<Link
- to={issuesHref}
+ to="/$repo/issues"
+ params={{ repo: repo! }}
className="text-muted-foreground hover:text-foreground mb-6 flex items-center gap-1.5 text-sm"
>
<ArrowLeft className="size-3.5" />
@@ -214,7 +213,8 @@ export function UserProfilePage() {
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-2">
<Link
- to={repo ? `/${repo}/issues/${bug.humanId}` : `/issues/${bug.humanId}`}
+ to="/$repo/issues/$id"
+ params={{ repo: repo!, id: bug.humanId }}
className="text-foreground hover:text-primary font-medium hover:underline"
>
{bug.title}