refactor(web): delete useRepo, pass repo as prop to shared components

Quentin Gliech and Claude Opus 4.6 (1M context) created

remove src/lib/repo.tsx entirely — the router context already provides
the resolved ref via $repo's beforeLoad

route components read repo from Route.useRouteContext(), then pass
it as a prop to shared components (Timeline, FileTree, CommitList,
FileDiffView) instead of those components reaching into router context

this keeps shared components decoupled from the routing layer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Change summary

webui2/src/components/bugs/Timeline.tsx     | 32 ++++++++++++----------
webui2/src/components/code/CommitList.tsx   |  5 +--
webui2/src/components/code/FileDiffView.tsx |  5 +--
webui2/src/components/code/FileTree.tsx     | 16 ++++++++--
webui2/src/lib/repo.tsx                     | 12 --------
webui2/src/routes/$repo/commit/$hash.tsx    |  4 +-
webui2/src/routes/$repo/index.tsx           |  6 ++--
webui2/src/routes/$repo/issues/$id.tsx      |  5 +--
webui2/src/routes/$repo/issues/index.tsx    |  3 -
webui2/src/routes/$repo/issues/new.tsx      |  3 -
webui2/src/routes/$repo/user/$id.tsx        |  3 -
11 files changed, 44 insertions(+), 50 deletions(-)

Detailed changes

webui2/src/components/bugs/Timeline.tsx 🔗

@@ -14,7 +14,6 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
 import { Button } from "@/components/ui/button";
 import { Textarea } from "@/components/ui/textarea";
 import { useAuth } from "@/lib/auth";
-import { useRepo } from "@/lib/repo";
 
 import { LabelBadge } from "./LabelBadge";
 
@@ -23,6 +22,7 @@ type TimelineNode = NonNullable<
 >;
 
 interface TimelineProps {
+  repo: string | null;
   bugPrefix: string;
   items: TimelineNode[];
 }
@@ -30,20 +30,20 @@ interface TimelineProps {
 // Ordered sequence of events on a bug: comments (create and add-comment) and
 // inline events (label changes, status changes, title edits). Comment items
 // support inline editing for the logged-in user.
-export function Timeline({ bugPrefix, items }: TimelineProps) {
+export function Timeline({ repo, bugPrefix, items }: TimelineProps) {
   return (
     <div className="space-y-4">
       {items.map((item) => {
         switch (item.__typename) {
           case "BugCreateTimelineItem":
           case "BugAddCommentTimelineItem":
-            return <CommentItem key={item.id} item={item} bugPrefix={bugPrefix} />;
+            return <CommentItem key={item.id} item={item} bugPrefix={bugPrefix} repo={repo} />;
           case "BugLabelChangeTimelineItem":
-            return <LabelChangeItem key={item.id} item={item} />;
+            return <LabelChangeItem key={item.id} item={item} repo={repo} />;
           case "BugSetStatusTimelineItem":
-            return <StatusChangeItem key={item.id} item={item} />;
+            return <StatusChangeItem key={item.id} item={item} repo={repo} />;
           case "BugSetTitleTimelineItem":
-            return <TitleChangeItem key={item.id} item={item} />;
+            return <TitleChangeItem key={item.id} item={item} repo={repo} />;
           default:
             return null;
         }
@@ -59,9 +59,16 @@ type CommentItem = Extract<
   { __typename: "BugCreateTimelineItem" | "BugAddCommentTimelineItem" }
 >;
 
-function CommentItem({ item, bugPrefix }: { item: CommentItem; bugPrefix: string }) {
+function CommentItem({
+  item,
+  bugPrefix,
+  repo,
+}: {
+  item: CommentItem;
+  bugPrefix: string;
+  repo: string | null;
+}) {
   const { user } = useAuth();
-  const repo = useRepo();
   const [editing, setEditing] = useState(false);
   const [editValue, setEditValue] = useState(item.message ?? "");
 
@@ -172,8 +179,7 @@ function EventRow({ icon, children }: { icon: React.ReactNode; children: React.R
   );
 }
 
-function LabelChangeItem({ item }: { item: LabelChangeItem }) {
-  const repo = useRepo();
+function LabelChangeItem({ item, repo }: { item: LabelChangeItem; repo: string | null }) {
   return (
     <EventRow icon={<Tag className="size-4" />}>
       <span>
@@ -206,8 +212,7 @@ function LabelChangeItem({ item }: { item: LabelChangeItem }) {
   );
 }
 
-function StatusChangeItem({ item }: { item: StatusChangeItem }) {
-  const repo = useRepo();
+function StatusChangeItem({ item, repo }: { item: StatusChangeItem; repo: string | null }) {
   const isOpen = item.status === Status.Open;
   return (
     <EventRow
@@ -234,8 +239,7 @@ function StatusChangeItem({ item }: { item: StatusChangeItem }) {
   );
 }
 
-function TitleChangeItem({ item }: { item: TitleChangeItem }) {
-  const repo = useRepo();
+function TitleChangeItem({ item, repo }: { item: TitleChangeItem; repo: string | null }) {
   return (
     <EventRow icon={<Pencil className="size-4" />}>
       <span>

webui2/src/components/code/CommitList.tsx 🔗

@@ -10,7 +10,6 @@ import { useEffect, useState } from "react";
 
 import { Button } from "@/components/ui/button";
 import { Skeleton } from "@/components/ui/skeleton";
-import { useRepo } from "@/lib/repo";
 
 const COMMITS_QUERY = gql`
   query CommitList($repo: String, $ref: String!, $path: String, $after: String, $first: Int) {
@@ -44,6 +43,7 @@ interface CommitListQueryData {
 }
 
 interface CommitListProps {
+  repo: string | null;
   ref_: string;
   path?: string;
 }
@@ -56,8 +56,7 @@ type CommitNode = {
   date: string;
 };
 
-export function CommitList({ ref_, path }: CommitListProps) {
-  const repo = useRepo();
+export function CommitList({ repo, ref_, path }: CommitListProps) {
   const [cursor, setCursor] = useState<string | null>(null);
   const [allCommits, setAllCommits] = useState<CommitNode[]>([]);
 

webui2/src/components/code/FileDiffView.tsx 🔗

@@ -6,7 +6,6 @@ import { useLazyQuery } from "@apollo/client/react";
 import { ChevronRight, FilePlus, FileMinus, FileEdit } from "lucide-react";
 import { useState } from "react";
 
-import { useRepo } from "@/lib/repo";
 import { cn } from "@/lib/utils";
 
 const DIFF_QUERY = gql`
@@ -53,6 +52,7 @@ interface DiffQueryData {
 }
 
 interface FileDiffViewProps {
+  repo: string | null;
   hash: string;
   path: string;
   oldPath?: string;
@@ -72,8 +72,7 @@ const statusBadge: Record<string, string> = {
   RENAMED: "R",
 };
 
-export function FileDiffView({ hash, path, oldPath, status }: FileDiffViewProps) {
-  const repo = useRepo();
+export function FileDiffView({ repo, hash, path, oldPath, status }: FileDiffViewProps) {
   const [open, setOpen] = useState(false);
   const [fetchDiff, { data, loading, error }] = useLazyQuery<DiffQueryData>(DIFF_QUERY);
 

webui2/src/components/code/FileTree.tsx 🔗

@@ -4,7 +4,6 @@ import { Folder, File } from "lucide-react";
 
 import { GitObjectType, type GitTreeEntry } from "@/__generated__/graphql";
 import { Skeleton } from "@/components/ui/skeleton";
-import { useRepo } from "@/lib/repo";
 
 export interface TreeEntryWithCommit extends GitTreeEntry {
   lastCommit?: {
@@ -16,6 +15,7 @@ export interface TreeEntryWithCommit extends GitTreeEntry {
 }
 
 interface FileTreeProps {
+  repo: string | null;
   entries: TreeEntryWithCommit[];
   path: string;
   loading?: boolean;
@@ -25,7 +25,14 @@ interface FileTreeProps {
 
 // Directory listing table for the code browser. Shows each entry's icon,
 // name, last-commit message (linked to commit detail), and relative date.
-export function FileTree({ entries, path, loading, onNavigate, onNavigateUp }: FileTreeProps) {
+export function FileTree({
+  repo,
+  entries,
+  path,
+  loading,
+  onNavigate,
+  onNavigateUp,
+}: FileTreeProps) {
   // Directories first, then files — each group alphabetical
   const sorted = entries.toSorted((a, b) => {
     if (a.type !== b.type) return a.type === GitObjectType.Tree ? -1 : 1;
@@ -49,7 +56,7 @@ export function FileTree({ entries, path, loading, onNavigate, onNavigateUp }: F
             </tr>
           )}
           {sorted.map((entry) => (
-            <FileTreeRow key={entry.name} entry={entry} onNavigate={onNavigate} />
+            <FileTreeRow key={entry.name} entry={entry} repo={repo} onNavigate={onNavigate} />
           ))}
         </tbody>
       </table>
@@ -59,13 +66,14 @@ export function FileTree({ entries, path, loading, onNavigate, onNavigateUp }: F
 
 function FileTreeRow({
   entry,
+  repo,
   onNavigate,
 }: {
   entry: TreeEntryWithCommit;
+  repo: string | null;
   onNavigate: (entry: TreeEntryWithCommit) => void;
 }) {
   const isDir = entry.type === GitObjectType.Tree;
-  const repo = useRepo();
 
   return (
     <tr className="hover:bg-muted/40 cursor-pointer" onClick={() => onNavigate(entry)}>

webui2/src/lib/repo.tsx 🔗

@@ -1,12 +0,0 @@
-// Returns the resolved repo ref from the router context.
-// Returns null when rendered outside of a /$repo route (e.g. the picker page).
-//
-// The $repo route's beforeLoad normalizes the slug ("_" → null) and provides
-// it as context.ref, so callers don't need to handle the "_" case.
-
-import { useRouteContext } from "@tanstack/react-router";
-
-export function useRepo(): string | null {
-  const context = useRouteContext({ strict: false });
-  return (context as { ref?: string | null }).ref ?? null;
-}

webui2/src/routes/$repo/commit/$hash.tsx 🔗

@@ -9,7 +9,6 @@ import { ArrowLeft, GitCommit } from "lucide-react";
 
 import { FileDiffView } from "@/components/code/FileDiffView";
 import { Skeleton } from "@/components/ui/skeleton";
-import { useRepo } from "@/lib/repo";
 
 const COMMIT_QUERY = gql`
   query CommitPageDetail($repo: String, $hash: String!) {
@@ -65,7 +64,7 @@ export const Route = createFileRoute("/$repo/commit/$hash")({
 });
 
 function RouteComponent() {
-  const repo = useRepo();
+  const { ref: repo } = Route.useRouteContext();
   const { commitRef } = Route.useLoaderData();
   const { data } = useReadQuery(commitRef);
 
@@ -137,6 +136,7 @@ function RouteComponent() {
           {files.map((file: { path: string; oldPath?: string | null; status: string }) => (
             <FileDiffView
               key={file.path}
+              repo={repo}
               hash={commit.hash}
               path={file.path}
               oldPath={file.oldPath ?? undefined}

webui2/src/routes/$repo/index.tsx 🔗

@@ -24,7 +24,6 @@ import { RefSelector } from "@/components/code/RefSelector";
 import { Markdown } from "@/components/content/Markdown";
 import { ButtonLink } from "@/components/ui/button-link";
 import { Skeleton } from "@/components/ui/skeleton";
-import { useRepo } from "@/lib/repo";
 
 const REFS_QUERY = gql`
   query CodePageRefs($repo: String) {
@@ -134,7 +133,7 @@ export const Route = createFileRoute("/$repo/")({
 });
 
 function RouteComponent() {
-  const repo = useRepo();
+  const { ref: repo } = Route.useRouteContext();
   const navigate = useNavigate({ from: "/$repo/" });
   const { ref: currentRef, path: currentPath, type: viewMode } = useSearch({ from: "/$repo/" });
 
@@ -257,10 +256,11 @@ function RouteComponent() {
       </div>
 
       {viewMode === "commits" ? (
-        <CommitList ref_={currentRef} path={currentPath || undefined} />
+        <CommitList repo={repo} ref_={currentRef} path={currentPath || undefined} />
       ) : viewMode === "tree" || !blob ? (
         <>
           <FileTree
+            repo={repo}
             entries={entriesWithCommits}
             path={currentPath}
             loading={treeLoading}

webui2/src/routes/$repo/issues/$id.tsx 🔗

@@ -12,7 +12,6 @@ import { TitleEditor } from "@/components/bugs/TitleEditor";
 import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
 import { Separator } from "@/components/ui/separator";
 import { Skeleton } from "@/components/ui/skeleton";
-import { useRepo } from "@/lib/repo";
 
 export const Route = createFileRoute("/$repo/issues/$id")({
   component: RouteComponent,
@@ -28,7 +27,7 @@ export const Route = createFileRoute("/$repo/issues/$id")({
 // Issue detail page (/:repo/issues/:id). Shows title, status, timeline of
 // comments and events, and a sidebar with labels and participants.
 function RouteComponent() {
-  const repo = useRepo();
+  const { ref: repo } = Route.useRouteContext();
   const { bugDetailRef } = Route.useLoaderData();
   const { labelsRef } = Route.useRouteContext();
   const { data } = useReadQuery(bugDetailRef);
@@ -76,7 +75,7 @@ function RouteComponent() {
       <div className="flex gap-8">
         {/* Timeline + comment box */}
         <div className="min-w-0 flex-1 space-y-4">
-          <Timeline bugPrefix={bug.humanId} items={bug.timeline.nodes} />
+          <Timeline repo={repo} bugPrefix={bug.humanId} items={bug.timeline.nodes} />
           <CommentBox bugPrefix={bug.humanId} bugStatus={bug.status} ref_={repo} />
         </div>
 

webui2/src/routes/$repo/issues/index.tsx 🔗

@@ -12,7 +12,6 @@ import { QueryInput } from "@/components/bugs/QueryInput";
 import { Button } from "@/components/ui/button";
 import { ButtonLink } from "@/components/ui/button-link";
 import { Skeleton } from "@/components/ui/skeleton";
-import { useRepo } from "@/lib/repo";
 import { cn } from "@/lib/utils";
 
 const issuesSearchSchema = v.object({
@@ -47,7 +46,7 @@ const PAGE_SIZE = 25;
 type StatusFilter = "open" | "closed";
 
 function RouteComponent() {
-  const repo = useRepo();
+  const { ref: repo } = Route.useRouteContext();
   const navigate = useNavigate({ from: "/$repo/issues/" });
   const { q, after } = Route.useSearch();
 

webui2/src/routes/$repo/issues/new.tsx 🔗

@@ -8,7 +8,6 @@ import { Button } from "@/components/ui/button";
 import { ButtonLink } from "@/components/ui/button-link";
 import { Input } from "@/components/ui/input";
 import { Textarea } from "@/components/ui/textarea";
-import { useRepo } from "@/lib/repo";
 
 export const Route = createFileRoute("/$repo/issues/new")({
   component: RouteComponent,
@@ -17,7 +16,7 @@ export const Route = createFileRoute("/$repo/issues/new")({
 // New issue form (/:repo/issues/new). Title + body with write/preview tabs.
 function RouteComponent() {
   const navigate = useNavigate();
-  const repo = useRepo();
+  const { ref: repo } = Route.useRouteContext();
   const [title, setTitle] = useState("");
   const [message, setMessage] = useState("");
   const [preview, setPreview] = useState(false);

webui2/src/routes/$repo/user/$id.tsx 🔗

@@ -24,7 +24,6 @@ import { LabelBadge } from "@/components/bugs/LabelBadge";
 import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
 import { Button } from "@/components/ui/button";
 import { Skeleton } from "@/components/ui/skeleton";
-import { useRepo } from "@/lib/repo";
 import { cn } from "@/lib/utils";
 
 export const Route = createFileRoute("/$repo/user/$id")({
@@ -35,7 +34,7 @@ const PAGE_SIZE = 25;
 
 function RouteComponent() {
   const { id } = useParams({ strict: false });
-  const repo = useRepo();
+  const { ref: repo } = Route.useRouteContext();
   const [statusFilter, setStatusFilter] = useState<"open" | "closed">("open");
 
   // Cursor-stack pagination: cursors[i] is the `after` value to fetch page i.