refactor(web): replace imperative navigation with proper Links

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

FileTree:
- replace onClick handlers with typed Links on entry names and ".."
- remove onNavigate/onNavigateUp callback props
- pass repo/currentRef/currentPath for Link params construction

BugRow:
- replace string-interpolated hrefs with typed Link params
- change repo prop from string | null to string

CommitPage:
- use useCanGoBack() + router.history.back() instead of
  window.history.back(), only show Back when there's history

all Links now use typed to/params for proper preloading and
accessible navigation (right-click, cmd+click, etc.)

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

Change summary

webui2/src/components/bugs/BugRow.tsx         | 15 ++--
webui2/src/components/code/FileTree.tsx       | 68 ++++++++++++--------
webui2/src/routes/$repo/_code/tree/$ref/$.tsx | 32 --------
webui2/src/routes/$repo/commit/$hash.tsx      | 22 +++---
4 files changed, 63 insertions(+), 74 deletions(-)

Detailed changes

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

@@ -15,8 +15,7 @@ interface BugRowProps {
   author: { humanId: string; displayName: string; avatarUrl?: string | null };
   createdAt: string;
   commentCount: number;
-  /** Current repo slug, used to build /:repo/issues/:id and /:repo/user/:id links. */
-  repo: string | null;
+  repo: string;
   onLabelClick?: (name: string) => void;
 }
 
@@ -36,9 +35,6 @@ export function BugRow({
   const isOpen = status === Status.Open;
   const StatusIcon = isOpen ? CircleDot : CircleCheck;
 
-  const issueHref = repo ? `/${repo}/issues/${humanId}` : `/issues/${humanId}`;
-  const authorHref = repo ? `/${repo}/user/${author.humanId}` : `/user/${author.humanId}`;
-
   return (
     <div className="border-border hover:bg-muted/30 flex items-start gap-3 border-b px-4 py-3 last:border-0">
       <StatusIcon
@@ -52,7 +48,8 @@ export function BugRow({
       <div className="min-w-0 flex-1">
         <div className="flex flex-wrap items-baseline gap-2">
           <Link
-            to={issueHref}
+            to="/$repo/issues/$id"
+            params={{ repo, id: humanId }}
             className="text-foreground hover:text-primary font-medium hover:underline"
           >
             {title}
@@ -68,7 +65,11 @@ export function BugRow({
         </div>
         <p className="text-muted-foreground mt-0.5 text-xs">
           #{humanId} opened {formatDistanceToNow(new Date(createdAt), { addSuffix: true })} by{" "}
-          <Link to={authorHref} className="hover:underline">
+          <Link
+            to="/$repo/user/$id"
+            params={{ repo, id: author.humanId }}
+            className="hover:underline"
+          >
             {author.displayName}
           </Link>
         </p>

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

@@ -15,24 +15,16 @@ export interface TreeEntryWithCommit extends GitTreeEntry {
 }
 
 interface FileTreeProps {
-  repo: string | null;
+  repo: string;
+  currentRef: string;
+  currentPath: string;
   entries: TreeEntryWithCommit[];
-  path: string;
   loading?: boolean;
-  onNavigate: (entry: TreeEntryWithCommit) => void;
-  onNavigateUp: () => void;
 }
 
 // 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({
-  repo,
-  entries,
-  path,
-  loading,
-  onNavigate,
-  onNavigateUp,
-}: FileTreeProps) {
+export function FileTree({ repo, currentRef, currentPath, entries, loading }: 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;
@@ -45,18 +37,32 @@ export function FileTree({
     <div className="border-border overflow-hidden rounded-md border">
       <table className="w-full text-sm">
         <tbody className="divide-border divide-y">
-          {path && (
-            <tr className="hover:bg-muted/40 cursor-pointer" onClick={onNavigateUp}>
-              <td className="w-6 py-2 pl-4">
-                <Folder className="size-4 text-blue-500 dark:text-blue-400" />
+          {currentPath && (
+            <tr className="hover:bg-muted/40">
+              <td colSpan={4}>
+                <Link
+                  to="/$repo/tree/$ref/$"
+                  params={{
+                    repo,
+                    ref: currentRef,
+                    _splat: currentPath.split("/").slice(0, -1).join("/"),
+                  }}
+                  className="flex items-center gap-3 py-2 pl-4"
+                >
+                  <Folder className="size-4 text-blue-500 dark:text-blue-400" />
+                  <span className="text-muted-foreground font-mono">..</span>
+                </Link>
               </td>
-              <td className="text-muted-foreground px-3 py-2 font-mono">..</td>
-              <td className="text-muted-foreground hidden px-3 py-2 md:table-cell" />
-              <td className="text-muted-foreground hidden px-4 py-2 text-right md:table-cell" />
             </tr>
           )}
           {sorted.map((entry) => (
-            <FileTreeRow key={entry.name} entry={entry} repo={repo} onNavigate={onNavigate} />
+            <FileTreeRow
+              key={entry.name}
+              entry={entry}
+              repo={repo}
+              currentRef={currentRef}
+              currentPath={currentPath}
+            />
           ))}
         </tbody>
       </table>
@@ -67,16 +73,23 @@ export function FileTree({
 function FileTreeRow({
   entry,
   repo,
-  onNavigate,
+  currentRef,
+  currentPath,
 }: {
   entry: TreeEntryWithCommit;
-  repo: string | null;
-  onNavigate: (entry: TreeEntryWithCommit) => void;
+  repo: string;
+  currentRef: string;
+  currentPath: string;
 }) {
   const isDir = entry.type === GitObjectType.Tree;
+  const entryPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
+
+  const entryLink = isDir
+    ? { to: "/$repo/tree/$ref/$" as const, params: { repo, ref: currentRef, _splat: entryPath } }
+    : { to: "/$repo/blob/$ref/$" as const, params: { repo, ref: currentRef, _splat: entryPath } };
 
   return (
-    <tr className="hover:bg-muted/40 cursor-pointer" onClick={() => onNavigate(entry)}>
+    <tr className="hover:bg-muted/40">
       <td className="w-6 py-2 pl-4">
         {isDir ? (
           <Folder className="size-4 text-blue-500 dark:text-blue-400" />
@@ -85,17 +98,16 @@ function FileTreeRow({
         )}
       </td>
       <td className="px-3 py-2">
-        <span className={`font-mono ${isDir ? "text-foreground font-medium" : "text-foreground"}`}>
+        <Link {...entryLink} className={`font-mono ${isDir ? "font-medium" : ""} hover:underline`}>
           {entry.name}
-        </span>
+        </Link>
       </td>
       <td className="text-muted-foreground hidden max-w-xs truncate px-3 py-2 md:table-cell">
         {entry.lastCommit && (
           <Link
             to="/$repo/commit/$hash"
-            params={{ repo: repo!, hash: entry.lastCommit.hash }}
+            params={{ repo, hash: entry.lastCommit.hash }}
             className="hover:text-foreground hover:underline"
-            onClick={(e) => e.stopPropagation()}
           >
             {entry.lastCommit.message}
           </Link>

webui2/src/routes/$repo/_code/tree/$ref/$.tsx 🔗

@@ -2,7 +2,7 @@
 
 import { gql } from "@apollo/client";
 import { useQuery } from "@apollo/client/react";
-import { createFileRoute, useNavigate } from "@tanstack/react-router";
+import { createFileRoute } from "@tanstack/react-router";
 
 import {
   GitObjectType,
@@ -70,7 +70,6 @@ export const Route = createFileRoute("/$repo/_code/tree/$ref/$")({
 function TreeView() {
   const { repo, ref: currentRef, _splat: currentPath = "" } = Route.useParams();
   const { ref: repoRef } = Route.useRouteContext();
-  const navigate = useNavigate();
 
   const { data: treeData, loading: treeLoading } = useQuery<TreeQueryData>(TREE_QUERY, {
     variables: { repo: repoRef, ref: currentRef, path: currentPath || null },
@@ -104,39 +103,14 @@ function TreeView() {
   });
   const readme: string | null = readmeBlobData?.repository?.blob?.text ?? null;
 
-  function handleEntryClick(entry: TreeEntryWithCommit) {
-    const newPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
-    if (entry.type === GitObjectType.Blob) {
-      void navigate({
-        to: "/$repo/blob/$ref/$",
-        params: { repo, ref: currentRef, _splat: newPath },
-      });
-    } else {
-      void navigate({
-        to: "/$repo/tree/$ref/$",
-        params: { repo, ref: currentRef, _splat: newPath },
-      });
-    }
-  }
-
-  function handleNavigateUp() {
-    const parts = currentPath.split("/").filter(Boolean);
-    parts.pop();
-    void navigate({
-      to: "/$repo/tree/$ref/$",
-      params: { repo, ref: currentRef, _splat: parts.join("/") },
-    });
-  }
-
   return (
     <>
       <FileTree
         repo={repo}
+        currentRef={currentRef}
+        currentPath={currentPath}
         entries={entriesWithCommits}
-        path={currentPath}
         loading={treeLoading}
-        onNavigate={handleEntryClick}
-        onNavigateUp={handleNavigateUp}
       />
       {readme && (
         <div className="rounded-md border">

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

@@ -3,7 +3,7 @@
 
 import { gql } from "@apollo/client";
 import { useReadQuery } from "@apollo/client/react";
-import { createFileRoute, Link } from "@tanstack/react-router";
+import { createFileRoute, Link, useCanGoBack, useRouter } from "@tanstack/react-router";
 import { format } from "date-fns";
 import { ArrowLeft, GitCommit } from "lucide-react";
 
@@ -68,6 +68,8 @@ function RouteComponent() {
   const { repo } = Route.useParams();
   const { commitRef } = Route.useLoaderData();
   const { data } = useReadQuery(commitRef);
+  const canGoBack = useCanGoBack();
+  const router = useRouter();
 
   const commit = data?.repository?.commit;
   if (!commit) return null;
@@ -77,15 +79,15 @@ function RouteComponent() {
 
   return (
     <div>
-      <button
-        onClick={() => {
-          window.history.back();
-        }}
-        className="text-muted-foreground hover:text-foreground mb-6 flex items-center gap-1.5 text-sm"
-      >
-        <ArrowLeft className="size-3.5" />
-        Back
-      </button>
+      {canGoBack && (
+        <button
+          onClick={() => router.history.back()}
+          className="text-muted-foreground hover:text-foreground mb-6 flex items-center gap-1.5 text-sm"
+        >
+          <ArrowLeft className="size-3.5" />
+          Back
+        </button>
+      )}
 
       <div className="border-border mb-6 rounded-md border p-5">
         <div className="mb-1 flex items-start gap-3">