From 8fa215209697b98ed302e420a3ff045ab3bf2993 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Mur=C3=A9?= Date: Fri, 13 Mar 2026 23:50:19 +0100 Subject: [PATCH] snapshot --- 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(-) create mode 100644 webui2/src/components/code/FileDiffView.tsx diff --git a/api/http/git_browse_handler.go b/api/http/git_browse_handler.go index 84933510dd3b2d54be48c8c24bccd8a856f83fd6..6996769bb254cdeb38fe7e7d939d9c4e057e3b4a 100644 --- a/api/http/git_browse_handler.go +++ b/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"` diff --git a/commands/webui.go b/commands/webui.go index 9c19bb2863df0d076b51e3120b6e5bb7ed10a649..af18d746031bb7f28ac7c4fd8c4e3dea2d1cf998 100644 --- a/commands/webui.go +++ b/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)) diff --git a/repository/gogit.go b/repository/gogit.go index 0aa6e4251cbad2154dabed65b1373420cfb6d445..fcf857d97fd861905367e1fa1001d7d928cc34d7 100644 --- a/repository/gogit.go +++ b/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() diff --git a/repository/repo.go b/repository/repo.go index 181a447d9d2d4b00b1de0c81b4a528e88c57ee4b..a451cdfbc2334371b1421941bdf2459d186f65ae 100644 --- a/repository/repo.go +++ b/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. diff --git a/webui2/README.md b/webui2/README.md index 1122989de8cb2f7bf972e38b1214e64e39389060..d97bb5b6ad6e1dbbe48d113d7a2553e8a58dc26a 100644 --- a/webui2/README.md +++ b/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 ├── 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 diff --git a/webui2/src/components/code/FileDiffView.tsx b/webui2/src/components/code/FileDiffView.tsx new file mode 100644 index 0000000000000000000000000000000000000000..470b5ea1034681c68970071845e38ca2e148e031 --- /dev/null +++ b/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: , + deleted: , + modified: , + renamed: , +} + +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(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(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 ( +
+ {/* File header row — always visible, click to toggle */} + + + {/* Diff body */} + {open && ( +
+ {loading && ( +
Loading diff…
+ )} + {error && ( +
Failed to load diff: {error}
+ )} + {diff && ( + diff.isBinary ? ( +
Binary file
+ ) : diff.hunks.length === 0 ? ( +
No changes
+ ) : ( + diff.hunks.map((hunk, i) => ) + ) + )} +
+ )} +
+ ) +} + +function Hunk({ hunk }: { hunk: DiffHunk }) { + return ( +
+ {/* Hunk header */} +
+ @@ -{hunk.oldStart},{hunk.oldLines} +{hunk.newStart},{hunk.newLines} @@ +
+ {hunk.lines.map((line, i) => ( +
+ {/* Old line number */} + + {line.oldLine || ''} + + {/* New line number */} + + {line.newLine || ''} + + {/* Sign */} + + {line.type === 'added' ? '+' : line.type === 'deleted' ? '-' : ' '} + + {/* Content */} +
+            {line.content}
+          
+
+ ))} +
+ ) +} diff --git a/webui2/src/lib/gitApi.ts b/webui2/src/lib/gitApi.ts index 9a54c15a06ffb0d6144a1cb11e6b34343b51347c..4d93b462a71c51fad8e6cc01567e5f47bd370e0b 100644 --- a/webui2/src/lib/gitApi.ts +++ b/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(path: string, params: Record = {}): Promise { @@ -97,3 +121,7 @@ export function getCommits( export function getCommit(sha: string): Promise { return get(`/git/commits/${sha}`) } + +export function getCommitDiff(sha: string, path: string): Promise { + return get(`/git/commits/${sha}/diff`, { path }) +} diff --git a/webui2/src/pages/CommitPage.tsx b/webui2/src/pages/CommitPage.tsx index 965f9992bf4a7d0cace9c8819f4878007ad06045..fd26e801c076fbc97621b6fc157050d8a7f60506 100644 --- a/webui2/src/pages/CommitPage.tsx +++ b/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: , - deleted: , - modified: , - renamed: , -} - -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() { - {/* Changed files */} + {/* Changed files — each row is collapsible and loads its diff lazily */}

{commit.files.length} file{commit.files.length !== 1 ? 's' : ''} changed @@ -114,28 +104,13 @@ export function CommitPage() {

No file changes.

)} {commit.files.map((file) => ( -
- - {statusIcon[file.status]} - - - {file.status === 'renamed' ? ( - <> - {file.oldPath} - {' → '} - {file.path} - - ) : ( - file.path - )} - - - {statusLabel[file.status]} - -
+ ))}