snapshot

Michael Muré created

Change summary

api/auth/middleware.go                    |  13 +
api/http/git_browse_handler.go            | 183 +++++++++++++++---------
api/http/git_file_handler.go              |  10 
api/http/git_file_upload_handler.go       |  19 --
commands/webui.go                         |  31 +++-
webui2/src/__generated__/graphql.ts       |  62 ++++++++
webui2/src/components/code/FileViewer.tsx |   6 
webui2/src/lib/gitApi.ts                  |  25 ++-
webui2/src/pages/CodePage.tsx             |   2 
webui2/src/pages/IdentitySelectPage.tsx   |   2 
webui2/tsconfig.app.tsbuildinfo           |   0 
11 files changed, 242 insertions(+), 111 deletions(-)

Detailed changes

api/auth/middleware.go 🔗

@@ -18,6 +18,19 @@ func Middleware(fixedUserId entity.Id) func(http.Handler) http.Handler {
 	}
 }
 
+// RequireAuth is middleware that rejects unauthenticated requests with 401.
+// Use this on subrouters that must never be accessible without a valid session
+// (e.g. the REST API in oauth mode when the server is publicly deployed).
+func RequireAuth(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if _, ok := r.Context().Value(identityCtxKey).(entity.Id); !ok {
+			http.Error(w, "authentication required", http.StatusUnauthorized)
+			return
+		}
+		next.ServeHTTP(w, r)
+	})
+}
+
 // SessionMiddleware reads the session cookie on every request and, when a
 // valid session exists, injects the corresponding identity ID into the context.
 //

api/http/git_browse_handler.go 🔗

@@ -7,6 +7,8 @@ import (
 	"strconv"
 	"strings"
 
+	"github.com/gorilla/mux"
+
 	"github.com/git-bug/git-bug/cache"
 	"github.com/git-bug/git-bug/repository"
 )
@@ -20,10 +22,20 @@ func writeJSON(w http.ResponseWriter, v any) {
 	}
 }
 
-// browseRepo resolves the default repo from the cache and type-asserts to
-// RepoBrowse. All four handlers use this helper.
-func browseRepo(mrc *cache.MultiRepoCache) (repository.ClockedRepo, repository.RepoBrowse, error) {
-	rc, err := mrc.DefaultRepo()
+// repoFromPath resolves the repository from the {owner} and {repo} mux path
+// variables. "_" is the wildcard value: owner is always ignored (single-owner
+// for now), and repo "_" resolves to the default repository.
+func repoFromPath(mrc *cache.MultiRepoCache, r *http.Request) (*cache.RepoCache, error) {
+	repoVar := mux.Vars(r)["repo"]
+	if repoVar == "_" {
+		return mrc.DefaultRepo()
+	}
+	return mrc.ResolveRepo(repoVar)
+}
+
+// browseRepo resolves the repository and asserts it implements RepoBrowse.
+func browseRepo(mrc *cache.MultiRepoCache, r *http.Request) (repository.ClockedRepo, repository.RepoBrowse, error) {
+	rc, err := repoFromPath(mrc, r)
 	if err != nil {
 		return nil, nil, err
 	}
@@ -35,8 +47,18 @@ func browseRepo(mrc *cache.MultiRepoCache) (repository.ClockedRepo, repository.R
 	return underlying, br, nil
 }
 
+// resolveRef tries refs/heads/<ref>, refs/tags/<ref>, then a raw hash.
+func resolveRef(repo repository.ClockedRepo, ref string) (repository.Hash, error) {
+	for _, prefix := range []string{"refs/heads/", "refs/tags/", ""} {
+		h, err := repo.ResolveRef(prefix + ref)
+		if err == nil {
+			return h, nil
+		}
+	}
+	return "", repository.ErrNotFound
+}
+
 // resolveTreeAtPath walks the git tree of a commit down to the given path.
-// path may be empty (returns root tree entries) or a slash-separated directory path.
 func resolveTreeAtPath(repo repository.ClockedRepo, commitHash repository.Hash, path string) ([]repository.TreeEntry, error) {
 	commit, err := repo.ReadCommit(commitHash)
 	if err != nil {
@@ -92,7 +114,17 @@ func resolveBlobAtPath(repo repository.ClockedRepo, commitHash repository.Hash,
 	return entry.Hash, nil
 }
 
-// ── GET /api/git/refs ─────────────────────────────────────────────────────────
+// isBinaryContent returns true if data contains a null byte (simple heuristic).
+func isBinaryContent(data []byte) bool {
+	for _, b := range data {
+		if b == 0 {
+			return true
+		}
+	}
+	return false
+}
+
+// ── GET /api/repos/{owner}/{repo}/git/refs ────────────────────────────────────
 
 type gitRefsHandler struct{ mrc *cache.MultiRepoCache }
 
@@ -109,7 +141,7 @@ type refResponse struct {
 }
 
 func (h *gitRefsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	repo, br, err := browseRepo(h.mrc)
+	repo, br, err := browseRepo(h.mrc, r)
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
@@ -118,7 +150,6 @@ func (h *gitRefsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	defaultBranch, _ := br.GetDefaultBranch()
 
 	var refs []refResponse
-
 	for _, prefix := range []string{"refs/heads/", "refs/tags/"} {
 		names, err := repo.ListRefs(prefix)
 		if err != nil {
@@ -148,7 +179,7 @@ func (h *gitRefsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	writeJSON(w, refs)
 }
 
-// ── GET /api/git/tree ─────────────────────────────────────────────────────────
+// ── GET /api/repos/{owner}/{repo}/git/trees/{ref}?path= ──────────────────────
 
 type gitTreeHandler struct{ mrc *cache.MultiRepoCache }
 
@@ -165,15 +196,10 @@ type treeEntryResponse struct {
 }
 
 func (h *gitTreeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	ref := r.URL.Query().Get("ref")
+	ref := mux.Vars(r)["ref"]
 	path := r.URL.Query().Get("path")
 
-	if ref == "" {
-		http.Error(w, "missing ref", http.StatusBadRequest)
-		return
-	}
-
-	repo, br, err := browseRepo(h.mrc)
+	repo, br, err := browseRepo(h.mrc, r)
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
@@ -181,7 +207,7 @@ func (h *gitTreeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 	commitHash, err := resolveRef(repo, ref)
 	if err != nil {
-		http.Error(w, "ref not found: "+ref, http.StatusNotFound)
+		http.Error(w, "ref not found", http.StatusNotFound)
 		return
 	}
 
@@ -195,12 +221,11 @@ func (h *gitTreeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	// Collect entry names and fetch last commits in one shallow history pass.
 	names := make([]string, len(entries))
 	for i, e := range entries {
 		names[i] = e.Name
 	}
-	lastCommits, _ := br.LastCommitForEntries(ref, path, names) // best-effort
+	lastCommits, _ := br.LastCommitForEntries(ref, path, names)
 
 	resp := make([]treeEntryResponse, 0, len(entries))
 	for _, e := range entries {
@@ -210,7 +235,6 @@ func (h *gitTreeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 			objType = "tree"
 			mode = "040000"
 		}
-
 		item := treeEntryResponse{
 			Name: e.Name,
 			Type: objType,
@@ -220,14 +244,13 @@ func (h *gitTreeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		if cm, ok := lastCommits[e.Name]; ok {
 			item.LastCommit = toCommitMetaResponse(cm)
 		}
-
 		resp = append(resp, item)
 	}
 
 	writeJSON(w, resp)
 }
 
-// ── GET /api/git/blob ─────────────────────────────────────────────────────────
+// ── GET /api/repos/{owner}/{repo}/git/blobs/{ref}?path= ──────────────────────
 
 type gitBlobHandler struct{ mrc *cache.MultiRepoCache }
 
@@ -243,15 +266,15 @@ type blobResponse struct {
 }
 
 func (h *gitBlobHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	ref := r.URL.Query().Get("ref")
+	ref := mux.Vars(r)["ref"]
 	path := r.URL.Query().Get("path")
 
-	if ref == "" || path == "" {
-		http.Error(w, "missing ref or path", http.StatusBadRequest)
+	if path == "" {
+		http.Error(w, "missing path", http.StatusBadRequest)
 		return
 	}
 
-	repo, _, err := browseRepo(h.mrc)
+	repo, _, err := browseRepo(h.mrc, r)
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
@@ -259,7 +282,7 @@ func (h *gitBlobHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 	commitHash, err := resolveRef(repo, ref)
 	if err != nil {
-		http.Error(w, "ref not found: "+ref, http.StatusNotFound)
+		http.Error(w, "ref not found", http.StatusNotFound)
 		return
 	}
 
@@ -293,7 +316,62 @@ func (h *gitBlobHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	})
 }
 
-// ── GET /api/git/commits ──────────────────────────────────────────────────────
+// ── GET /api/repos/{owner}/{repo}/git/raw/{ref}/{path} ───────────────────────
+// Serves the raw file content for download. ref and path are both in the URL
+// path, producing human-readable download URLs like:
+//
+//	/api/repos/_/_/git/raw/main/src/foo/bar.go
+
+type gitRawHandler struct{ mrc *cache.MultiRepoCache }
+
+func NewGitRawHandler(mrc *cache.MultiRepoCache) http.Handler {
+	return &gitRawHandler{mrc: mrc}
+}
+
+func (h *gitRawHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ref := mux.Vars(r)["ref"]
+	path := mux.Vars(r)["path"]
+
+	if path == "" {
+		http.Error(w, "missing path", http.StatusBadRequest)
+		return
+	}
+
+	repo, _, err := browseRepo(h.mrc, r)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	commitHash, err := resolveRef(repo, ref)
+	if err != nil {
+		http.Error(w, "ref not found", http.StatusNotFound)
+		return
+	}
+
+	blobHash, err := resolveBlobAtPath(repo, commitHash, path)
+	if err == repository.ErrNotFound {
+		http.Error(w, "path not found", http.StatusNotFound)
+		return
+	}
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	data, err := repo.ReadData(blobHash)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	fileName := path[strings.LastIndex(path, "/")+1:]
+	w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, fileName))
+	w.Header().Set("Content-Type", "application/octet-stream")
+	w.Write(data)
+}
+
+// ── GET /api/repos/{owner}/{repo}/git/commits?ref=&path=&limit=&after= ───────
 
 type gitCommitsHandler struct{ mrc *cache.MultiRepoCache }
 
@@ -302,12 +380,12 @@ func NewGitCommitsHandler(mrc *cache.MultiRepoCache) http.Handler {
 }
 
 type commitMetaResponse struct {
-	Hash        string `json:"hash"`
-	ShortHash   string `json:"shortHash"`
-	Message     string `json:"message"`
-	AuthorName  string `json:"authorName"`
-	AuthorEmail string `json:"authorEmail"`
-	Date        string `json:"date"` // RFC3339
+	Hash        string   `json:"hash"`
+	ShortHash   string   `json:"shortHash"`
+	Message     string   `json:"message"`
+	AuthorName  string   `json:"authorName"`
+	AuthorEmail string   `json:"authorEmail"`
+	Date        string   `json:"date"` // RFC3339
 	Parents     []string `json:"parents"`
 }
 
@@ -344,7 +422,7 @@ func (h *gitCommitsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	_, br, err := browseRepo(h.mrc)
+	_, br, err := browseRepo(h.mrc, r)
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
@@ -363,7 +441,7 @@ func (h *gitCommitsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	writeJSON(w, resp)
 }
 
-// ── GET /api/git/commit ───────────────────────────────────────────────────────
+// ── GET /api/repos/{owner}/{repo}/git/commits/{sha} ──────────────────────────
 
 type gitCommitHandler struct{ mrc *cache.MultiRepoCache }
 
@@ -384,19 +462,15 @@ type commitDetailResponse struct {
 }
 
 func (h *gitCommitHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	hash := r.URL.Query().Get("hash")
-	if hash == "" {
-		http.Error(w, "missing hash", http.StatusBadRequest)
-		return
-	}
+	sha := mux.Vars(r)["sha"]
 
-	_, br, err := browseRepo(h.mrc)
+	_, br, err := browseRepo(h.mrc, r)
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
 
-	detail, err := br.CommitDetail(repository.Hash(hash))
+	detail, err := br.CommitDetail(repository.Hash(sha))
 	if err == repository.ErrNotFound {
 		http.Error(w, "commit not found", http.StatusNotFound)
 		return
@@ -417,26 +491,3 @@ func (h *gitCommitHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		Files:              files,
 	})
 }
-
-// ── utilities ─────────────────────────────────────────────────────────────────
-
-// resolveRef tries refs/heads/<ref>, refs/tags/<ref>, then raw hash.
-func resolveRef(repo repository.ClockedRepo, ref string) (repository.Hash, error) {
-	for _, prefix := range []string{"refs/heads/", "refs/tags/", ""} {
-		h, err := repo.ResolveRef(prefix + ref)
-		if err == nil {
-			return h, nil
-		}
-	}
-	return "", repository.ErrNotFound
-}
-
-// isBinaryContent returns true if data contains a null byte (simple heuristic).
-func isBinaryContent(data []byte) bool {
-	for _, b := range data {
-		if b == 0 {
-			return true
-		}
-	}
-	return false
-}

api/http/git_file_handler.go 🔗

@@ -14,8 +14,9 @@ import (
 // implement a http.Handler that will read and server git blob.
 //
 // Expected gorilla/mux parameters:
-//   - "repo" : the ref of the repo or "" for the default one
-//   - "hash" : the git hash of the file to retrieve
+//   - "owner" : ignored (reserved for future multi-owner support); "_" for local
+//   - "repo"  : the name of the repo, or "_" for the default one
+//   - "hash"  : the git hash of the file to retrieve
 type gitFileHandler struct {
 	mrc *cache.MultiRepoCache
 }
@@ -29,10 +30,9 @@ func (gfh *gitFileHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
 	var err error
 
 	repoVar := mux.Vars(r)["repo"]
-	switch repoVar {
-	case "":
+	if repoVar == "_" {
 		repo, err = gfh.mrc.DefaultRepo()
-	default:
+	} else {
 		repo, err = gfh.mrc.ResolveRepo(repoVar)
 	}
 

api/http/git_file_upload_handler.go 🔗

@@ -2,20 +2,19 @@ package http
 
 import (
 	"encoding/json"
-	"fmt"
 	"io"
 	"net/http"
 
 	"github.com/gorilla/mux"
 
-	"github.com/git-bug/git-bug/api/auth"
 	"github.com/git-bug/git-bug/cache"
 )
 
 // implement a http.Handler that will accept and store content into git blob.
 //
 // Expected gorilla/mux parameters:
-//   - "repo" : the ref of the repo or "" for the default one
+//   - "owner" : ignored (reserved for future multi-owner support); "_" for local
+//   - "repo"  : the name of the repo, or "_" for the default one
 type gitUploadFileHandler struct {
 	mrc *cache.MultiRepoCache
 }
@@ -29,10 +28,9 @@ func (gufh *gitUploadFileHandler) ServeHTTP(rw http.ResponseWriter, r *http.Requ
 	var err error
 
 	repoVar := mux.Vars(r)["repo"]
-	switch repoVar {
-	case "":
+	if repoVar == "_" {
 		repo, err = gufh.mrc.DefaultRepo()
-	default:
+	} else {
 		repo, err = gufh.mrc.ResolveRepo(repoVar)
 	}
 
@@ -41,15 +39,6 @@ func (gufh *gitUploadFileHandler) ServeHTTP(rw http.ResponseWriter, r *http.Requ
 		return
 	}
 
-	_, err = auth.UserFromCtx(r.Context(), repo)
-	if err == auth.ErrNotAuthenticated {
-		http.Error(rw, "read-only mode or not logged in", http.StatusForbidden)
-		return
-	} else if err != nil {
-		http.Error(rw, fmt.Sprintf("loading identity: %v", err), http.StatusInternalServerError)
-		return
-	}
-
 	// 100MB (github limit)
 	var maxUploadSize int64 = 100 * 1000 * 1000
 	r.Body = http.MaxBytesReader(rw, r.Body, maxUploadSize)

commands/webui.go 🔗

@@ -172,17 +172,30 @@ func runWebUI(env *execenv.Env, opts webUIOptions) error {
 		router.Path("/auth/adopt").Methods("POST").HandlerFunc(ah.HandleAdopt)
 	}
 
-	// Routes
+	// Top-level API routes
 	router.Path("/playground").Handler(playground.Handler("git-bug", "/graphql"))
 	router.Path("/graphql").Handler(graphqlHandler)
-	router.Path("/gitfile/{repo}/{hash}").Handler(httpapi.NewGitFileHandler(mrc))
-	router.Path("/upload/{repo}").Methods("POST").Handler(httpapi.NewGitUploadFileHandler(mrc))
-	// Git browsing API (used by the code browser UI)
-	router.Path("/api/git/refs").Methods("GET").Handler(httpapi.NewGitRefsHandler(mrc))
-	router.Path("/api/git/tree").Methods("GET").Handler(httpapi.NewGitTreeHandler(mrc))
-	router.Path("/api/git/blob").Methods("GET").Handler(httpapi.NewGitBlobHandler(mrc))
-	router.Path("/api/git/commits").Methods("GET").Handler(httpapi.NewGitCommitsHandler(mrc))
-	router.Path("/api/git/commit").Methods("GET").Handler(httpapi.NewGitCommitHandler(mrc))
+
+	// /api/repos/{owner}/{repo}/ subrouter.
+	// owner is reserved for future use; "_" means "local".
+	// repo "_" resolves to the default repository.
+	//
+	// In oauth mode all API endpoints require a valid session, making the
+	// server safe to deploy publicly. In local and readonly modes the
+	// middleware only injects identity without blocking.
+	apiRepos := router.PathPrefix("/api/repos/{owner}/{repo}").Subrouter()
+	if authMode == "oauth" {
+		apiRepos.Use(auth.RequireAuth)
+	}
+	apiRepos.Path("/git/refs").Methods("GET").Handler(httpapi.NewGitRefsHandler(mrc))
+	apiRepos.Path("/git/trees/{ref}").Methods("GET").Handler(httpapi.NewGitTreeHandler(mrc))
+	apiRepos.Path("/git/blobs/{ref}").Methods("GET").Handler(httpapi.NewGitBlobHandler(mrc))
+	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("/file/{hash}").Methods("GET").Handler(httpapi.NewGitFileHandler(mrc))
+	apiRepos.Path("/upload").Methods("POST").Handler(httpapi.NewGitUploadFileHandler(mrc))
+
 	router.PathPrefix("/").Handler(webui.NewHandler())
 
 	srv := &http.Server{

webui2/src/__generated__/graphql.ts 🔗

@@ -26,7 +26,7 @@ export type Authored = {
   author: Identity;
 };
 
-export type Bug = Authored & {
+export type Bug = Authored & Entity & {
   __typename?: 'Bug';
   /** The actors of the bug. Actors are Identity that have interacted with the bug. */
   actors: IdentityConnection;
@@ -356,6 +356,12 @@ export type BugEditCommentPayload = {
   operation: BugEditCommentOperation;
 };
 
+export type BugEvent = {
+  __typename?: 'BugEvent';
+  bug: Bug;
+  type: EntityEventType;
+};
+
 export type BugLabelChangeOperation = Authored & Operation & {
   __typename?: 'BugLabelChangeOperation';
   added: Array<Label>;
@@ -515,8 +521,28 @@ export type Color = {
   R: Scalars['Int']['output'];
 };
 
+/** An entity (identity, bug, ...). */
+export type Entity = {
+  /** The human version (truncated) identifier for this entity */
+  humanId: Scalars['String']['output'];
+  /** The identifier for this entity */
+  id: Scalars['ID']['output'];
+};
+
+export type EntityEvent = {
+  __typename?: 'EntityEvent';
+  entity?: Maybe<Entity>;
+  type: EntityEventType;
+};
+
+export enum EntityEventType {
+  Created = 'CREATED',
+  Removed = 'REMOVED',
+  Updated = 'UPDATED'
+}
+
 /** Represents an identity */
-export type Identity = {
+export type Identity = Entity & {
   __typename?: 'Identity';
   /** An url to an avatar */
   avatarUrl?: Maybe<Scalars['String']['output']>;
@@ -553,6 +579,12 @@ export type IdentityEdge = {
   node: Identity;
 };
 
+export type IdentityEvent = {
+  __typename?: 'IdentityEvent';
+  identity: Identity;
+  type: EntityEventType;
+};
+
 /** Label for a bug. */
 export type Label = {
   __typename?: 'Label';
@@ -811,6 +843,32 @@ export enum Status {
   Open = 'OPEN'
 }
 
+export type Subscription = {
+  __typename?: 'Subscription';
+  /** Subscribe to events on all entities. For events on a specific repo you can provide a repo reference. Without it, you get the unique default repo or all repo events. */
+  allEvents: EntityEvent;
+  /** Subscribe to bug entity events. For events on a specific repo you can provide a repo reference. Without it, you get the unique default repo or all repo events. */
+  bugEvents: BugEvent;
+  /** Subscribe to identity entity events. For events on a specific repo you can provide a repo reference. Without it, you get the unique default repo or all repo events. */
+  identityEvents: IdentityEvent;
+};
+
+
+export type SubscriptionAllEventsArgs = {
+  repoRef?: InputMaybe<Scalars['String']['input']>;
+  typename?: InputMaybe<Scalars['String']['input']>;
+};
+
+
+export type SubscriptionBugEventsArgs = {
+  repoRef?: InputMaybe<Scalars['String']['input']>;
+};
+
+
+export type SubscriptionIdentityEventsArgs = {
+  repoRef?: InputMaybe<Scalars['String']['input']>;
+};
+
 export type AllIdentitiesQueryVariables = Exact<{
   ref?: InputMaybe<Scalars['String']['input']>;
 }>;

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

@@ -3,16 +3,18 @@ import hljs from 'highlight.js'
 import { Copy, Download } from 'lucide-react'
 import { Button } from '@/components/ui/button'
 import { Skeleton } from '@/components/ui/skeleton'
+import { getRawUrl } from '@/lib/gitApi'
 import type { GitBlob } from '@/lib/gitApi'
 
 interface FileViewerProps {
   blob: GitBlob
+  ref: string
   loading?: boolean
 }
 
 // Syntax-highlighted file viewer with line numbers, copy, and download buttons.
 // Uses highlight.js for highlighting; binary files show a placeholder.
-export function FileViewer({ blob, loading }: FileViewerProps) {
+export function FileViewer({ blob, ref, loading }: FileViewerProps) {
   const { html, lineCount } = useMemo(() => {
     if (blob.isBinary || !blob.content) return { html: '', lineCount: 0 }
     const ext = blob.path.split('.').pop() ?? ''
@@ -49,7 +51,7 @@ export function FileViewer({ blob, loading }: FileViewerProps) {
             <Copy className="size-3.5" />
           </Button>
           <Button variant="ghost" size="icon" className="size-7" asChild title="Download">
-            <a href={`/gitfile/default/${blob.path}`} download>
+            <a href={getRawUrl(ref, blob.path)} download>
               <Download className="size-3.5" />
             </a>
           </Button>

webui2/src/lib/gitApi.ts 🔗

@@ -1,5 +1,8 @@
 // REST API client for git repository browsing.
-// Endpoints are served by the Go backend at /api/git/*.
+// Endpoints are served by the Go backend under /api/repos/{owner}/{repo}/git/*.
+// "_" is the wildcard value for both owner and repo (resolves to local / default).
+
+const BASE = '/api/repos/_/_'
 
 export interface GitRef {
   name: string       // full ref: "refs/heads/main"
@@ -54,8 +57,8 @@ export interface GitCommitDetail extends GitCommit {
 
 async function get<T>(path: string, params: Record<string, string> = {}): Promise<T> {
   const search = new URLSearchParams(params).toString()
-  const url = `/api/git${path}${search ? `?${search}` : ''}`
-  const res = await fetch(url)
+  const url = `${BASE}${path}${search ? `?${search}` : ''}`
+  const res = await fetch(url, { credentials: 'include' })
   if (!res.ok) {
     const text = await res.text().catch(() => res.statusText)
     throw new Error(text || res.statusText)
@@ -66,15 +69,19 @@ async function get<T>(path: string, params: Record<string, string> = {}): Promis
 // ── API calls ─────────────────────────────────────────────────────────────────
 
 export function getRefs(): Promise<GitRef[]> {
-  return get('/refs')
+  return get('/git/refs')
 }
 
 export function getTree(ref: string, path: string): Promise<GitTreeEntry[]> {
-  return get('/tree', { ref, path })
+  return get(`/git/trees/${encodeURIComponent(ref)}`, path ? { path } : {})
 }
 
 export function getBlob(ref: string, path: string): Promise<GitBlob> {
-  return get('/blob', { ref, path })
+  return get(`/git/blobs/${encodeURIComponent(ref)}`, { path })
+}
+
+export function getRawUrl(ref: string, path: string): string {
+  return `${BASE}/git/raw/${encodeURIComponent(ref)}/${path}`
 }
 
 export function getCommits(
@@ -84,9 +91,9 @@ export function getCommits(
   const params: Record<string, string> = { ref, limit: String(opts.limit ?? 20) }
   if (opts.path) params.path = opts.path
   if (opts.after) params.after = opts.after
-  return get('/commits', params)
+  return get('/git/commits', params)
 }
 
-export function getCommit(hash: string): Promise<GitCommitDetail> {
-  return get('/commit', { hash })
+export function getCommit(sha: string): Promise<GitCommitDetail> {
+  return get(`/git/commits/${sha}`)
 }

webui2/src/pages/CodePage.tsx 🔗

@@ -172,7 +172,7 @@ export function CodePage() {
           onNavigateUp={handleNavigateUp}
         />
       ) : (
-        <FileViewer blob={blob} loading={contentLoading} />
+        <FileViewer blob={blob} ref={currentRef} loading={contentLoading} />
       )}
     </div>
   )

webui2/src/pages/IdentitySelectPage.tsx 🔗

@@ -7,7 +7,6 @@
 // profile.
 
 import { useEffect, useState } from 'react'
-import { useNavigate } from 'react-router-dom'
 import { UserCircle, Plus, AlertCircle } from 'lucide-react'
 import { Button } from '@/components/ui/button'
 import { Skeleton } from '@/components/ui/skeleton'
@@ -22,7 +21,6 @@ interface IdentityItem {
 }
 
 export function IdentitySelectPage() {
-  const navigate = useNavigate()
   const [identities, setIdentities] = useState<IdentityItem[] | null>(null)
   const [error, setError] = useState<string | null>(null)
   const [working, setWorking] = useState(false)