snapshot

Michael Muré created

Change summary

api/http/git_browse_handler.go              |  73 +++++++++
commands/webui.go                           |   1 
repository/gogit.go                         | 172 +++++++++++++++++++++++
repository/repo.go                          |  32 ++++
webui2/README.md                            |  68 +++++++-
webui2/src/components/code/FileDiffView.tsx | 140 ++++++++++++++++++
webui2/src/lib/gitApi.ts                    |  28 +++
webui2/src/pages/CommitPage.tsx             |  45 +----
8 files changed, 512 insertions(+), 47 deletions(-)

Detailed changes

api/http/git_browse_handler.go 🔗

@@ -449,6 +449,79 @@ func NewGitCommitHandler(mrc *cache.MultiRepoCache) http.Handler {
 	return &gitCommitHandler{mrc: mrc}
 }
 
+// ── GET /api/repos/{owner}/{repo}/git/commits/{sha}/diff?path= ───────────────
+
+type gitCommitDiffHandler struct{ mrc *cache.MultiRepoCache }
+
+func NewGitCommitDiffHandler(mrc *cache.MultiRepoCache) http.Handler {
+	return &gitCommitDiffHandler{mrc: mrc}
+}
+
+func (h *gitCommitDiffHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	sha := mux.Vars(r)["sha"]
+	filePath := r.URL.Query().Get("path")
+	if filePath == "" {
+		http.Error(w, "missing path", http.StatusBadRequest)
+		return
+	}
+
+	_, br, err := browseRepo(h.mrc, r)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	fd, err := br.CommitFileDiff(repository.Hash(sha), filePath)
+	if err == repository.ErrNotFound {
+		http.Error(w, "not found", http.StatusNotFound)
+		return
+	}
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	type diffLineResp struct {
+		Type    string `json:"type"`
+		Content string `json:"content"`
+		OldLine int    `json:"oldLine,omitempty"`
+		NewLine int    `json:"newLine,omitempty"`
+	}
+	type diffHunkResp struct {
+		OldStart int            `json:"oldStart"`
+		OldLines int            `json:"oldLines"`
+		NewStart int            `json:"newStart"`
+		NewLines int            `json:"newLines"`
+		Lines    []diffLineResp `json:"lines"`
+	}
+	type fileDiffResp struct {
+		Path     string         `json:"path"`
+		OldPath  string         `json:"oldPath,omitempty"`
+		IsBinary bool           `json:"isBinary"`
+		IsNew    bool           `json:"isNew"`
+		IsDelete bool           `json:"isDelete"`
+		Hunks    []diffHunkResp `json:"hunks"`
+	}
+
+	hunks := make([]diffHunkResp, len(fd.Hunks))
+	for i, h := range fd.Hunks {
+		lines := make([]diffLineResp, len(h.Lines))
+		for j, l := range h.Lines {
+			lines[j] = diffLineResp{Type: l.Type, Content: l.Content, OldLine: l.OldLine, NewLine: l.NewLine}
+		}
+		hunks[i] = diffHunkResp{OldStart: h.OldStart, OldLines: h.OldLines, NewStart: h.NewStart, NewLines: h.NewLines, Lines: lines}
+	}
+
+	writeJSON(w, fileDiffResp{
+		Path:     fd.Path,
+		OldPath:  fd.OldPath,
+		IsBinary: fd.IsBinary,
+		IsNew:    fd.IsNew,
+		IsDelete: fd.IsDelete,
+		Hunks:    hunks,
+	})
+}
+
 type changedFileResponse struct {
 	Path    string `json:"path"`
 	OldPath string `json:"oldPath,omitempty"`

commands/webui.go 🔗

@@ -193,6 +193,7 @@ func runWebUI(env *execenv.Env, opts webUIOptions) error {
 	apiRepos.Path("/git/raw/{ref}/{path:.*}").Methods("GET").Handler(httpapi.NewGitRawHandler(mrc))
 	apiRepos.Path("/git/commits").Methods("GET").Handler(httpapi.NewGitCommitsHandler(mrc))
 	apiRepos.Path("/git/commits/{sha}").Methods("GET").Handler(httpapi.NewGitCommitHandler(mrc))
+	apiRepos.Path("/git/commits/{sha}/diff").Methods("GET").Handler(httpapi.NewGitCommitDiffHandler(mrc))
 	apiRepos.Path("/file/{hash}").Methods("GET").Handler(httpapi.NewGitFileHandler(mrc))
 	apiRepos.Path("/upload").Methods("POST").Handler(httpapi.NewGitUploadFileHandler(mrc))
 

repository/gogit.go 🔗

@@ -19,6 +19,7 @@ import (
 	"github.com/go-git/go-git/v5/config"
 	"github.com/go-git/go-git/v5/plumbing"
 	"github.com/go-git/go-git/v5/plumbing/filemode"
+	"github.com/go-git/go-git/v5/plumbing/format/diff"
 	"github.com/go-git/go-git/v5/plumbing/object"
 	"golang.org/x/sync/errgroup"
 	"golang.org/x/sys/execabs"
@@ -1109,6 +1110,177 @@ func (repo *GoGitRepo) CommitDetail(hash Hash) (CommitDetail, error) {
 	return detail, nil
 }
 
+// CommitFileDiff returns the structured diff for a single file in a commit.
+func (repo *GoGitRepo) CommitFileDiff(hash Hash, filePath string) (FileDiff, error) {
+	repo.rMutex.Lock()
+	defer repo.rMutex.Unlock()
+
+	commit, err := repo.r.CommitObject(plumbing.NewHash(hash.String()))
+	if err == plumbing.ErrObjectNotFound {
+		return FileDiff{}, ErrNotFound
+	}
+	if err != nil {
+		return FileDiff{}, err
+	}
+
+	tree, err := commit.Tree()
+	if err != nil {
+		return FileDiff{}, err
+	}
+
+	var parentTree *object.Tree
+	if len(commit.ParentHashes) > 0 {
+		if parent, err := commit.Parent(0); err == nil {
+			parentTree, _ = parent.Tree()
+		}
+	}
+	if parentTree == nil {
+		parentTree = &object.Tree{}
+	}
+
+	changes, err := object.DiffTree(parentTree, tree)
+	if err != nil {
+		return FileDiff{}, err
+	}
+
+	for _, change := range changes {
+		from, to := change.From.Name, change.To.Name
+		if to != filePath && from != filePath {
+			continue
+		}
+
+		patch, err := change.Patch()
+		if err != nil {
+			return FileDiff{}, err
+		}
+
+		fps := patch.FilePatches()
+		if len(fps) == 0 {
+			return FileDiff{}, ErrNotFound
+		}
+		fp := fps[0]
+
+		fromFile, toFile := fp.Files()
+		fd := FileDiff{
+			IsBinary: fp.IsBinary(),
+			IsNew:    fromFile == nil,
+			IsDelete: toFile == nil,
+		}
+		if toFile != nil {
+			fd.Path = toFile.Path()
+		} else if fromFile != nil {
+			fd.Path = fromFile.Path()
+		}
+		if fromFile != nil && toFile != nil && fromFile.Path() != toFile.Path() {
+			fd.OldPath = fromFile.Path()
+		}
+
+		if !fd.IsBinary {
+			fd.Hunks = buildDiffHunks(fp.Chunks())
+		}
+		return fd, nil
+	}
+
+	return FileDiff{}, ErrNotFound
+}
+
+// buildDiffHunks converts go-git diff chunks into DiffHunks with context lines.
+func buildDiffHunks(chunks []diff.Chunk) []DiffHunk {
+	const ctx = 3
+
+	type line struct {
+		op      diff.Operation
+		content string
+		oldLine int
+		newLine int
+	}
+
+	// Expand chunks into individual lines.
+	var lines []line
+	oldN, newN := 1, 1
+	for _, chunk := range chunks {
+		parts := strings.Split(chunk.Content(), "\n")
+		// Split always produces a trailing empty element if content ends with \n.
+		if len(parts) > 0 && parts[len(parts)-1] == "" {
+			parts = parts[:len(parts)-1]
+		}
+		for _, p := range parts {
+			l := line{op: chunk.Type(), content: p}
+			switch chunk.Type() {
+			case diff.Equal:
+				l.oldLine, l.newLine = oldN, newN
+				oldN++
+				newN++
+			case diff.Add:
+				l.newLine = newN
+				newN++
+			case diff.Delete:
+				l.oldLine = oldN
+				oldN++
+			}
+			lines = append(lines, l)
+		}
+	}
+
+	// Collect indices of changed lines.
+	var changed []int
+	for i, l := range lines {
+		if l.op != diff.Equal {
+			changed = append(changed, i)
+		}
+	}
+	if len(changed) == 0 {
+		return nil
+	}
+
+	// Merge overlapping/adjacent change windows into hunk ranges.
+	type hunkRange struct{ start, end int }
+	var ranges []hunkRange
+	i := 0
+	for i < len(changed) {
+		start := max(0, changed[i]-ctx)
+		end := changed[i]
+		j := i
+		for j < len(changed) && changed[j] <= end+ctx {
+			end = changed[j]
+			j++
+		}
+		end = min(len(lines)-1, end+ctx)
+		ranges = append(ranges, hunkRange{start, end})
+		i = j
+	}
+
+	// Build DiffHunks from ranges.
+	hunks := make([]DiffHunk, 0, len(ranges))
+	for _, r := range ranges {
+		hunk := DiffHunk{}
+		for _, l := range lines[r.start : r.end+1] {
+			if hunk.OldStart == 0 && l.oldLine > 0 {
+				hunk.OldStart = l.oldLine
+			}
+			if hunk.NewStart == 0 && l.newLine > 0 {
+				hunk.NewStart = l.newLine
+			}
+			dl := DiffLine{Content: l.content, OldLine: l.oldLine, NewLine: l.newLine}
+			switch l.op {
+			case diff.Equal:
+				dl.Type = "context"
+				hunk.OldLines++
+				hunk.NewLines++
+			case diff.Add:
+				dl.Type = "added"
+				hunk.NewLines++
+			case diff.Delete:
+				dl.Type = "deleted"
+				hunk.OldLines++
+			}
+			hunk.Lines = append(hunk.Lines, dl)
+		}
+		hunks = append(hunks, hunk)
+	}
+	return hunks
+}
+
 func (repo *GoGitRepo) AllClocks() (map[string]lamport.Clock, error) {
 	repo.clocksMutex.Lock()
 	defer repo.clocksMutex.Unlock()

repository/repo.go 🔗

@@ -235,6 +235,38 @@ type RepoBrowse interface {
 	// CommitDetail returns the full metadata for a single commit plus the list
 	// of files it changed relative to its first parent.
 	CommitDetail(hash Hash) (CommitDetail, error)
+
+	// CommitFileDiff returns the structured diff for a single file in a commit
+	// relative to its first parent. path is the current file path (or old path
+	// for deletions).
+	CommitFileDiff(hash Hash, path string) (FileDiff, error)
+}
+
+// DiffLine is a single line in a diff hunk.
+type DiffLine struct {
+	Type    string // "context" | "added" | "deleted"
+	Content string
+	OldLine int // 0 for added lines
+	NewLine int // 0 for deleted lines
+}
+
+// DiffHunk is a contiguous block of changes with surrounding context.
+type DiffHunk struct {
+	OldStart int
+	OldLines int
+	NewStart int
+	NewLines int
+	Lines    []DiffLine
+}
+
+// FileDiff holds the diff for a single file in a commit.
+type FileDiff struct {
+	Path     string
+	OldPath  string // non-empty for renames
+	IsBinary bool
+	IsNew    bool
+	IsDelete bool
+	Hunks    []DiffHunk
 }
 
 // ChangedFile describes a single file changed by a commit.

webui2/README.md 🔗

@@ -8,50 +8,94 @@ You need two processes running:
 
 ```bash
 # 1. Go backend (from repo root)
-go run . webui --port 3000
+go run . webui --no-open --port 3000
 
 # 2. Vite dev server (from this directory)
 npm install
 npm run dev
 ```
 
-Open http://localhost:5173. Vite proxies `/graphql`, `/api`, `/gitfile`, and `/upload` to the Go server on port 3000.
+Open http://localhost:5173. Vite proxies `/graphql`, `/api`, and `/auth` to the Go server on port 3000.
 
 Node 22 is required. If you use asdf, `.tool-versions` pins the right version automatically.
 
+## Routes
+
+| Path                    | Page                                                     |
+|-------------------------|----------------------------------------------------------|
+| `/`                     | Repo picker — auto-redirects when there is only one repo |
+| `/_`                    | Default repo (issues + code browser)                     |
+| `/_/issues`             | Issue list with search and label filtering               |
+| `/_/issues/new`         | New issue form                                           |
+| `/_/issues/:id`         | Issue detail and timeline                                |
+| `/_/user/:id`           | User profile                                             |
+| `/_/commit/:hash`       | Commit detail with collapsible file diffs                |
+| `/auth/select-identity` | OAuth identity adoption (first-time login)               |
+
+`_` is the URL segment for the default (unnamed) repository. Named repositories use their registered name.
+
 ## Code structure
 
 ```
 src/
-├── pages/          # One file per route (BugList, BugDetail, NewBug,
-│                   #   UserProfile, Code, Commit)
+├── pages/          # One file per route
 ├── components/
-│   ├── bugs/       # Issue components (BugRow, Timeline, CommentBox,
-│   │               #   LabelEditor, TitleEditor, LabelBadge, StatusBadge)
-│   ├── code/       # Code browser (FileTree, FileViewer, CommitList,
-│   │               #   RefSelector, CodeBreadcrumb)
+│   ├── bugs/       # Issue components (BugRow, Timeline, ...)
+│   ├── code/       # Code browser (FileTree, FileViewer, ...)
 │   ├── content/    # Markdown renderer
 │   ├── layout/     # Header + Shell
 │   └── ui/         # shadcn/ui — never edit manually
 │                   # Update with: npx shadcn update <component>
 ├── graphql/        # .graphql source files — edit these, then run codegen
 ├── __generated__/  # Generated typed hooks — do not edit
-└── lib/            # apollo.ts, auth.tsx, theme.tsx, gitApi.ts, utils.ts
+└── lib/            # apollo.ts, auth.tsx, theme.tsx, gitApi.ts, repo.tsx, utils.ts
 ```
 
 ## Data flow
 
-**Bug tracking** uses GraphQL (`/graphql`). Queries and mutations are defined in `src/graphql/*.graphql` and codegen produces typed React hooks into `src/__generated__/graphql.ts`. After changing any `.graphql` file, run:
+**Bug tracking** uses GraphQL (`/graphql`). Queries and mutations are defined in `src/graphql/*.graphql` and codegen produces typed React hooks into `src/__generated__/graphql.ts`. After changing any `.graphql` file run:
 
 ```bash
 npm run codegen
 ```
 
-**Code browser** uses REST endpoints at `/api/git/*` implemented in Go (`api/http/git_browse_handler.go`). The TypeScript client is `src/lib/gitApi.ts`.
+**Code browser** uses REST endpoints at `/api/repos/{owner}/{repo}/git/*` implemented in `api/http/git_browse_handler.go`. `_` is used for both owner and repo (local single-user setup). The TypeScript client is `src/lib/gitApi.ts`.
+
+| Endpoint                              | Description                             |
+|---------------------------------------|-----------------------------------------|
+| `GET /git/refs`                       | List branches and tags                  |
+| `GET /git/trees/{ref}?path=`          | Directory listing with last-commit info |
+| `GET /git/blobs/{ref}?path=`          | File content                            |
+| `GET /git/raw/{ref}/{path}`           | Raw file download                       |
+| `GET /git/commits?ref=&limit=&after=` | Paginated commit log                    |
+| `GET /git/commits/{sha}`              | Commit metadata + changed file list     |
+| `GET /git/commits/{sha}/diff?path=`   | Per-file structured diff (lazy-loaded)  |
 
 ## Auth
 
-`AuthContext` (`src/lib/auth.tsx`) fetches `repository.userIdentity` on load. If the query returns null the UI enters read-only mode and all write actions are hidden. The context is designed for a future OAuth provider: swap `AuthProvider` for an `OAuthAuthProvider` without touching any other component.
+Three modes, configured at server start:
+
+- **`local`** — single user derived from git config; all writes enabled, no login UI.
+- **`oauth`** — multi-user via external providers; all API endpoints require a valid session; unauthenticated requests get 401.
+- **`readonly`** — no identity; all write actions hidden in the UI.
+
+`AuthContext` (`src/lib/auth.tsx`) fetches `serverConfig` + `userIdentity` on load and exposes `{ user, mode, oauthProviders }` to the whole tree.
+
+## Build for production
+
+The Go binary embeds the compiled frontend via `//go:embed all:dist` in `webui2/handler.go`. The Makefile `build-webui2` target runs the Vite build before compiling Go:
+
+```bash
+# From repo root
+make build
+```
+
+Or manually:
+
+```bash
+npm run build        # outputs to webui2/dist/
+cd .. && go build .  # embeds dist/ into the binary
+```
 
 ## Theming
 

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

@@ -0,0 +1,140 @@
+// Collapsible diff view for a single file in a commit.
+// Diff is fetched lazily on first expand to avoid loading large diffs upfront.
+
+import { useState } from 'react'
+import { ChevronRight, FilePlus, FileMinus, FileEdit } from 'lucide-react'
+import { cn } from '@/lib/utils'
+import { getCommitDiff } from '@/lib/gitApi'
+import type { FileDiff, DiffHunk } from '@/lib/gitApi'
+
+interface FileDiffViewProps {
+  sha: string
+  path: string
+  oldPath?: string
+  status: 'added' | 'modified' | 'deleted' | 'renamed'
+}
+
+const statusIcon = {
+  added:    <FilePlus  className="size-3.5 text-green-600 dark:text-green-400" />,
+  deleted:  <FileMinus className="size-3.5 text-red-500  dark:text-red-400" />,
+  modified: <FileEdit  className="size-3.5 text-yellow-500 dark:text-yellow-400" />,
+  renamed:  <FileEdit  className="size-3.5 text-blue-500  dark:text-blue-400" />,
+}
+
+const statusBadge = { added: 'A', deleted: 'D', modified: 'M', renamed: 'R' }
+
+export function FileDiffView({ sha, path, oldPath, status }: FileDiffViewProps) {
+  const [open, setOpen] = useState(false)
+  const [diff, setDiff] = useState<FileDiff | null>(null)
+  const [loading, setLoading] = useState(false)
+  const [error, setError] = useState<string | null>(null)
+
+  function toggle() {
+    if (!open && diff === null && !loading) {
+      setLoading(true)
+      getCommitDiff(sha, path)
+        .then(setDiff)
+        .catch((e: Error) => setError(e.message))
+        .finally(() => setLoading(false))
+    }
+    setOpen((v) => !v)
+  }
+
+  return (
+    <div className="divide-y divide-border">
+      {/* File header row — always visible, click to toggle */}
+      <button
+        onClick={toggle}
+        className="flex w-full items-center gap-3 px-4 py-2.5 text-left hover:bg-muted/50 transition-colors"
+      >
+        <ChevronRight
+          className={cn(
+            'size-3.5 shrink-0 text-muted-foreground transition-transform duration-150',
+            open && 'rotate-90',
+          )}
+        />
+        {statusIcon[status]}
+        <span className="min-w-0 flex-1 font-mono text-sm">
+          {status === 'renamed' ? (
+            <>
+              <span className="text-muted-foreground line-through">{oldPath}</span>
+              {' → '}
+              <span>{path}</span>
+            </>
+          ) : path}
+        </span>
+        <span className="shrink-0 rounded border border-border px-1.5 py-0.5 font-mono text-xs text-muted-foreground">
+          {statusBadge[status]}
+        </span>
+      </button>
+
+      {/* Diff body */}
+      {open && (
+        <div className="overflow-x-auto">
+          {loading && (
+            <div className="px-4 py-3 text-xs text-muted-foreground">Loading diff…</div>
+          )}
+          {error && (
+            <div className="px-4 py-3 text-xs text-destructive">Failed to load diff: {error}</div>
+          )}
+          {diff && (
+            diff.isBinary ? (
+              <div className="px-4 py-3 text-xs text-muted-foreground">Binary file</div>
+            ) : diff.hunks.length === 0 ? (
+              <div className="px-4 py-3 text-xs text-muted-foreground">No changes</div>
+            ) : (
+              diff.hunks.map((hunk, i) => <Hunk key={i} hunk={hunk} />)
+            )
+          )}
+        </div>
+      )}
+    </div>
+  )
+}
+
+function Hunk({ hunk }: { hunk: DiffHunk }) {
+  return (
+    <div className="font-mono text-xs leading-5">
+      {/* Hunk header */}
+      <div className="bg-blue-50 px-4 py-0.5 text-blue-600 dark:bg-blue-950/40 dark:text-blue-400 select-none">
+        @@ -{hunk.oldStart},{hunk.oldLines} +{hunk.newStart},{hunk.newLines} @@
+      </div>
+      {hunk.lines.map((line, i) => (
+        <div
+          key={i}
+          className={cn(
+            'flex',
+            line.type === 'added'   && 'bg-green-50  dark:bg-green-950/30',
+            line.type === 'deleted' && 'bg-red-50    dark:bg-red-950/30',
+          )}
+        >
+          {/* Old line number */}
+          <span className="w-10 shrink-0 select-none border-r border-border/50 px-2 text-right text-muted-foreground/50">
+            {line.oldLine || ''}
+          </span>
+          {/* New line number */}
+          <span className="w-10 shrink-0 select-none border-r border-border/50 px-2 text-right text-muted-foreground/50">
+            {line.newLine || ''}
+          </span>
+          {/* Sign */}
+          <span className={cn(
+            'w-5 shrink-0 select-none text-center',
+            line.type === 'added'   && 'text-green-600 dark:text-green-400',
+            line.type === 'deleted' && 'text-red-500   dark:text-red-400',
+            line.type === 'context' && 'text-muted-foreground/40',
+          )}>
+            {line.type === 'added' ? '+' : line.type === 'deleted' ? '-' : ' '}
+          </span>
+          {/* Content */}
+          <pre className={cn(
+            'flex-1 overflow-visible whitespace-pre px-2',
+            line.type === 'added'   && 'text-green-900 dark:text-green-200',
+            line.type === 'deleted' && 'text-red-900   dark:text-red-200',
+          )}>
+            {line.content}
+          </pre>
+        </div>
+      ))}
+    </div>
+  )
+}

webui2/src/lib/gitApi.ts 🔗

@@ -53,6 +53,30 @@ export interface GitCommitDetail extends GitCommit {
   }>
 }
 
+export interface DiffLine {
+  type: 'context' | 'added' | 'deleted'
+  content: string
+  oldLine: number
+  newLine: number
+}
+
+export interface DiffHunk {
+  oldStart: number
+  oldLines: number
+  newStart: number
+  newLines: number
+  lines: DiffLine[]
+}
+
+export interface FileDiff {
+  path: string
+  oldPath?: string
+  isBinary: boolean
+  isNew: boolean
+  isDelete: boolean
+  hunks: DiffHunk[]
+}
+
 // ── Fetch helpers ─────────────────────────────────────────────────────────────
 
 async function get<T>(path: string, params: Record<string, string> = {}): Promise<T> {
@@ -97,3 +121,7 @@ export function getCommits(
 export function getCommit(sha: string): Promise<GitCommitDetail> {
   return get(`/git/commits/${sha}`)
 }
+
+export function getCommitDiff(sha: string, path: string): Promise<FileDiff> {
+  return get(`/git/commits/${sha}/diff`, { path })
+}

webui2/src/pages/CommitPage.tsx 🔗

@@ -1,22 +1,12 @@
 import { useState, useEffect } from 'react'
 import { Link, useParams, useNavigate } from 'react-router-dom'
 import { format } from 'date-fns'
-import { ArrowLeft, FilePlus, FileMinus, FileEdit, GitCommit } from 'lucide-react'
+import { ArrowLeft, GitCommit } from 'lucide-react'
 import { Skeleton } from '@/components/ui/skeleton'
 import { getCommit } from '@/lib/gitApi'
 import type { GitCommitDetail } from '@/lib/gitApi'
 import { useRepo } from '@/lib/repo'
-
-const statusIcon = {
-  added:    <FilePlus className="size-4 text-green-600 dark:text-green-400" />,
-  deleted:  <FileMinus className="size-4 text-red-500 dark:text-red-400" />,
-  modified: <FileEdit className="size-4 text-yellow-500 dark:text-yellow-400" />,
-  renamed:  <FileEdit className="size-4 text-blue-500 dark:text-blue-400" />,
-}
-
-const statusLabel = {
-  added: 'A', deleted: 'D', modified: 'M', renamed: 'R',
-}
+import { FileDiffView } from '@/components/code/FileDiffView'
 
 // Commit detail page (/:repo/commit/:hash). Shows commit metadata, full message,
 // parent links, and the list of files changed with add/modify/delete/rename status.
@@ -104,7 +94,7 @@ export function CommitPage() {
         </div>
       </div>
 
-      {/* Changed files */}
+      {/* Changed files — each row is collapsible and loads its diff lazily */}
       <div>
         <h2 className="mb-3 text-sm font-semibold text-muted-foreground">
           {commit.files.length} file{commit.files.length !== 1 ? 's' : ''} changed
@@ -114,28 +104,13 @@ export function CommitPage() {
             <p className="px-4 py-4 text-sm text-muted-foreground">No file changes.</p>
           )}
           {commit.files.map((file) => (
-            <div key={file.path} className="flex items-center gap-3 px-4 py-2.5">
-              <span
-                className="w-4 shrink-0 text-center font-mono text-xs font-bold"
-                title={file.status}
-              >
-                {statusIcon[file.status]}
-              </span>
-              <span className="min-w-0 flex-1 font-mono text-sm">
-                {file.status === 'renamed' ? (
-                  <>
-                    <span className="text-muted-foreground line-through">{file.oldPath}</span>
-                    {' → '}
-                    <span>{file.path}</span>
-                  </>
-                ) : (
-                  file.path
-                )}
-              </span>
-              <span className="shrink-0 rounded border border-border px-1.5 py-0.5 font-mono text-xs text-muted-foreground">
-                {statusLabel[file.status]}
-              </span>
-            </div>
+            <FileDiffView
+              key={file.path}
+              sha={commit.hash}
+              path={file.path}
+              oldPath={file.oldPath}
+              status={file.status}
+            />
           ))}
         </div>
       </div>