snapshot

Michael Muré created

Change summary

.gitignore                                |  1 
Makefile                                  | 22 ++----
api/graphql/graph/repository.generated.go | 85 -------------------------
api/graphql/graph/root.generated.go       |  2 
api/graphql/graph/root_.generated.go      | 14 ---
api/graphql/handler.go                    | 13 ---
api/graphql/resolvers/repo.go             |  9 -
api/graphql/schema/repository.graphql     |  6 -
api/http/auth_handler.go                  |  6 
cache/multi_repo_cache.go                 | 13 +--
cache/repo_cache_common.go                | 21 +----
commands/webui.go                         |  9 --
go.mod                                    |  1 
webui2/handler.go                         | 34 ++++++++++
webui2/package.json                       |  4 
webui2/src/App.tsx                        | 17 +---
webui2/src/__generated__/graphql.ts       | 10 --
webui2/src/components/code/CommitList.tsx | 11 ++-
webui2/src/components/code/FileTree.tsx   |  4 
webui2/src/components/code/FileViewer.tsx | 37 +++++++---
webui2/src/components/layout/Header.tsx   |  4 
webui2/src/graphql/Repositories.graphql   |  1 
webui2/src/pages/ErrorPage.tsx            | 36 ++++++++++
webui2/src/pages/IdentitySelectPage.tsx   |  2 
webui2/src/pages/RepoPickerPage.tsx       | 32 +++++++--
webui2/tsconfig.app.tsbuildinfo           |  0 
webui2/vite.config.ts                     | 14 ++++
27 files changed, 184 insertions(+), 224 deletions(-)

Detailed changes

.gitignore 🔗

@@ -17,4 +17,3 @@ coverage.txt
 
 # nix output directory from `nix build` commands
 /result
-webui2

Makefile 🔗

@@ -19,24 +19,28 @@ list-checks:
 		dasel -r json -w plain '.checks.x86_64-linux.keys().all()' |\
 		xargs -I NAME printf '\t%-20s %s\n' "NAME" "nix build .#checks.linux.NAME"
 
+.PHONY: build-webui
+build-webui:
+	cd webui2 && npm run build
+
 .PHONY: build
-build:
+build: build-webui
 	go generate
 	go build -ldflags "$(LDFLAGS)" .
 
 # produce a debugger-friendly build
 .PHONY: build/debug
-build/debug:
+build/debug: build-webui
 	go generate
 	go build -ldflags "$(LDFLAGS)" -gcflags=all="-N -l" .
 
 .PHONY: install
-install:
+install: build-webui
 	go generate
 	go install -ldflags "$(LDFLAGS)" .
 
 .PHONY: releases
-releases:
+releases: build-webui
 	go generate
 	go run github.com/mitchellh/gox@v1.0.1 -ldflags "$(LDFLAGS)" -osarch '!darwin/386' -output "dist/{{.Dir}}_{{.OS}}_{{.Arch}}"
 
@@ -48,16 +52,6 @@ secure:
 test:
 	go test -v -bench=. ./...
 
-.PHONY: pack-webui
-pack-webui:
-	npm run --prefix webui build
-	go run webui/pack_webui.go
-
-# produce a build that will fetch the web UI from the filesystem instead of from the binary
-.PHONY: debug-webui
-debug-webui:
-	go build -ldflags "$(LDFLAGS)" -tags=debugwebui
-
 .PHONY: clean-local-bugs
 clean-local-bugs:
 	git for-each-ref refs/bugs/ | cut -f 2 | $(XARGS) -n 1 git update-ref -d

api/graphql/graph/repository.generated.go 🔗

@@ -19,7 +19,6 @@ import (
 
 type RepositoryResolver interface {
 	Name(ctx context.Context, obj *models.Repository) (*string, error)
-	Slug(ctx context.Context, obj *models.Repository) (string, error)
 	AllBugs(ctx context.Context, obj *models.Repository, after *string, before *string, first *int, last *int, query *string) (*models.BugConnection, error)
 	Bug(ctx context.Context, obj *models.Repository, prefix string) (models.BugWrapper, error)
 	AllIdentities(ctx context.Context, obj *models.Repository, after *string, before *string, first *int, last *int) (*models.IdentityConnection, error)
@@ -451,50 +450,6 @@ func (ec *executionContext) fieldContext_Repository_name(_ context.Context, fiel
 	return fc, nil
 }
 
-func (ec *executionContext) _Repository_slug(ctx context.Context, field graphql.CollectedField, obj *models.Repository) (ret graphql.Marshaler) {
-	fc, err := ec.fieldContext_Repository_slug(ctx, field)
-	if err != nil {
-		return graphql.Null
-	}
-	ctx = graphql.WithFieldContext(ctx, fc)
-	defer func() {
-		if r := recover(); r != nil {
-			ec.Error(ctx, ec.Recover(ctx, r))
-			ret = graphql.Null
-		}
-	}()
-	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
-		ctx = rctx // use context from middleware stack in children
-		return ec.resolvers.Repository().Slug(rctx, obj)
-	})
-	if err != nil {
-		ec.Error(ctx, err)
-		return graphql.Null
-	}
-	if resTmp == nil {
-		if !graphql.HasFieldError(ctx, fc) {
-			ec.Errorf(ctx, "must not be null")
-		}
-		return graphql.Null
-	}
-	res := resTmp.(string)
-	fc.Result = res
-	return ec.marshalNString2string(ctx, field.Selections, res)
-}
-
-func (ec *executionContext) fieldContext_Repository_slug(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
-	fc = &graphql.FieldContext{
-		Object:     "Repository",
-		Field:      field,
-		IsMethod:   true,
-		IsResolver: true,
-		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
-			return nil, errors.New("field of type String does not have child fields")
-		},
-	}
-	return fc, nil
-}
-
 func (ec *executionContext) _Repository_allBugs(ctx context.Context, field graphql.CollectedField, obj *models.Repository) (ret graphql.Marshaler) {
 	fc, err := ec.fieldContext_Repository_allBugs(ctx, field)
 	if err != nil {
@@ -990,8 +945,6 @@ func (ec *executionContext) fieldContext_RepositoryConnection_nodes(_ context.Co
 			switch field.Name {
 			case "name":
 				return ec.fieldContext_Repository_name(ctx, field)
-			case "slug":
-				return ec.fieldContext_Repository_slug(ctx, field)
 			case "allBugs":
 				return ec.fieldContext_Repository_allBugs(ctx, field)
 			case "bug":
@@ -1194,8 +1147,6 @@ func (ec *executionContext) fieldContext_RepositoryEdge_node(_ context.Context,
 			switch field.Name {
 			case "name":
 				return ec.fieldContext_Repository_name(ctx, field)
-			case "slug":
-				return ec.fieldContext_Repository_slug(ctx, field)
 			case "allBugs":
 				return ec.fieldContext_Repository_allBugs(ctx, field)
 			case "bug":
@@ -1270,42 +1221,6 @@ func (ec *executionContext) _Repository(ctx context.Context, sel ast.SelectionSe
 				continue
 			}
 
-			out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
-		case "slug":
-			field := field
-
-			innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) {
-				defer func() {
-					if r := recover(); r != nil {
-						ec.Error(ctx, ec.Recover(ctx, r))
-					}
-				}()
-				res = ec._Repository_slug(ctx, field, obj)
-				if res == graphql.Null {
-					atomic.AddUint32(&fs.Invalids, 1)
-				}
-				return res
-			}
-
-			if field.Deferrable != nil {
-				dfs, ok := deferred[field.Deferrable.Label]
-				di := 0
-				if ok {
-					dfs.AddField(field)
-					di = len(dfs.Values) - 1
-				} else {
-					dfs = graphql.NewFieldSet([]graphql.CollectedField{field})
-					deferred[field.Deferrable.Label] = dfs
-				}
-				dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler {
-					return innerFunc(ctx, dfs)
-				})
-
-				// don't run the out.Concurrently() call below
-				out.Values[i] = graphql.Null
-				continue
-			}
-
 			out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
 		case "allBugs":
 			field := field

api/graphql/graph/root.generated.go 🔗

@@ -1112,8 +1112,6 @@ func (ec *executionContext) fieldContext_Query_repository(ctx context.Context, f
 			switch field.Name {
 			case "name":
 				return ec.fieldContext_Repository_name(ctx, field)
-			case "slug":
-				return ec.fieldContext_Repository_slug(ctx, field)
 			case "allBugs":
 				return ec.fieldContext_Repository_allBugs(ctx, field)
 			case "bug":

api/graphql/graph/root_.generated.go 🔗

@@ -386,7 +386,6 @@ type ComplexityRoot struct {
 		Bug           func(childComplexity int, prefix string) int
 		Identity      func(childComplexity int, prefix string) int
 		Name          func(childComplexity int) int
-		Slug          func(childComplexity int) int
 		UserIdentity  func(childComplexity int) int
 		ValidLabels   func(childComplexity int, after *string, before *string, first *int, last *int) int
 	}
@@ -1864,13 +1863,6 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
 
 		return e.complexity.Repository.Name(childComplexity), true
 
-	case "Repository.slug":
-		if e.complexity.Repository.Slug == nil {
-			break
-		}
-
-		return e.complexity.Repository.Slug(childComplexity), true
-
 	case "Repository.userIdentity":
 		if e.complexity.Repository.UserIdentity == nil {
 			break
@@ -2733,13 +2725,9 @@ type OperationEdge {
 }
 `, BuiltIn: false},
 	{Name: "../schema/repository.graphql", Input: `type Repository {
-    """The name of the repository"""
+    """The name of the repository. Null for the default (unnamed) repository."""
     name: String
 
-    """URL-friendly slug for this repository. Named repos use their name;
-    the default (unnamed) repo derives the slug from the directory basename."""
-    slug: String!
-
     """All the bugs"""
     allBugs(
         """Returns the elements in the list that come after the specified cursor."""

api/graphql/handler.go 🔗

@@ -20,12 +20,6 @@ import (
 	"github.com/git-bug/git-bug/cache"
 )
 
-// Handler is the root GraphQL http handler
-type Handler struct {
-	http.Handler
-	io.Closer
-}
-
 // ServerConfig carries server-level configuration that is passed down to
 // GraphQL resolvers. It is constructed once at startup and does not change.
 type ServerConfig struct {
@@ -35,7 +29,7 @@ type ServerConfig struct {
 	OAuthProviders []string
 }
 
-func NewHandler(mrc *cache.MultiRepoCache, cfg ServerConfig, errorOut io.Writer) Handler {
+func NewHandler(mrc *cache.MultiRepoCache, cfg ServerConfig, errorOut io.Writer) http.Handler {
 	rootResolver := resolvers.NewRootResolver(mrc, cfg.AuthMode, cfg.OAuthProviders)
 	config := graph.Config{Resolvers: rootResolver}
 
@@ -63,8 +57,5 @@ func NewHandler(mrc *cache.MultiRepoCache, cfg ServerConfig, errorOut io.Writer)
 		h.Use(&Tracer{Out: errorOut})
 	}
 
-	return Handler{
-		Handler: h,
-		Closer:  rootResolver,
-	}
+	return h
 }

api/graphql/resolvers/repo.go 🔗

@@ -18,15 +18,12 @@ type repoResolver struct{}
 
 func (repoResolver) Name(_ context.Context, obj *models.Repository) (*string, error) {
 	name := obj.Repo.Name()
+	if name == "" {
+		return nil, nil
+	}
 	return &name, nil
 }
 
-// Slug returns the URL-friendly identifier for the repo, used as the /:repo
-// path segment in the frontend.
-func (repoResolver) Slug(_ context.Context, obj *models.Repository) (string, error) {
-	return obj.Repo.Slug(), nil
-}
-
 func (repoResolver) AllBugs(_ context.Context, obj *models.Repository, after *string, before *string, first *int, last *int, queryStr *string) (*models.BugConnection, error) {
 	input := models.ConnectionInput{
 		Before: before,

api/graphql/schema/repository.graphql 🔗

@@ -1,11 +1,7 @@
 type Repository {
-    """The name of the repository"""
+    """The name of the repository. Null for the default (unnamed) repository."""
     name: String
 
-    """URL-friendly slug for this repository. Named repos use their name;
-    the default (unnamed) repo derives the slug from the directory basename."""
-    slug: String!
-
     """All the bugs"""
     allBugs(
         """Returns the elements in the list that come after the specified cursor."""

api/http/auth_handler.go 🔗

@@ -14,7 +14,7 @@
 // The flow for a first-time user:
 //
 //	browser → /auth/login → provider → /auth/callback
-//	       → store pending → /_/auth/select-identity
+//	       → store pending → /auth/select-identity
 //	       → POST /auth/adopt → set cookie → /
 package http
 
@@ -212,7 +212,7 @@ func (h *AuthHandler) HandleCallback(w http.ResponseWriter, r *http.Request) {
 		SameSite: http.SameSiteLaxMode,
 		Path:     "/",
 	})
-	http.Redirect(w, r, "/_/auth/select-identity", http.StatusFound)
+	http.Redirect(w, r, "/auth/select-identity", http.StatusFound)
 }
 
 // HandleUser returns the current authenticated user as JSON.
@@ -284,7 +284,7 @@ func (h *AuthHandler) HandleIdentities(w http.ResponseWriter, r *http.Request) {
 				continue
 			}
 			identities = append(identities, identityJSON{
-				RepoSlug:    repo.Slug(),
+				RepoSlug:    repo.Name(),
 				Id:          i.Id().String(),
 				HumanId:     i.Id().Human(),
 				DisplayName: i.DisplayName(),

cache/multi_repo_cache.go 🔗

@@ -62,17 +62,14 @@ func (c *MultiRepoCache) DefaultRepo() (*RepoCache, error) {
 
 // ResolveRepo retrieve a repository by name or slug
 func (c *MultiRepoCache) ResolveRepo(ref string) (*RepoCache, error) {
-	// Direct name lookup first
+	// "_" is the conventional placeholder for the default repository,
+	// consistent with the REST API path convention /api/repos/_/_/...
+	if ref == "_" {
+		return c.DefaultRepo()
+	}
 	if r, ok := c.repos[ref]; ok {
 		return r, nil
 	}
-	// Slug lookup fallback — allows using the human-readable slug (derived
-	// from the path basename) instead of the internal cache name.
-	for _, r := range c.repos {
-		if r.Slug() == ref {
-			return r, nil
-		}
-	}
 	return nil, fmt.Errorf("unknown repo %q", ref)
 }
 

cache/repo_cache_common.go 🔗

@@ -1,7 +1,6 @@
 package cache
 
 import (
-	"path/filepath"
 	"sync"
 
 	"github.com/pkg/errors"
@@ -12,7 +11,12 @@ import (
 	"github.com/git-bug/git-bug/util/multierr"
 )
 
+// Name returns the registered name of this repository, or empty string for
+// the default (unnamed) repository.
 func (c *RepoCache) Name() string {
+	if c.name == defaultRepoName {
+		return ""
+	}
 	return c.name
 }
 
@@ -21,21 +25,6 @@ func (c *RepoCache) GetPath() string {
 	return c.repo.GetPath()
 }
 
-// Slug returns a URL-friendly identifier for this repository.
-// Named repos use their registered name. The default ("__default") repo
-// derives its slug from the last component of the filesystem path so
-// URLs look like /git-bug/issues rather than /__default/issues.
-func (c *RepoCache) Slug() string {
-	if c.name != defaultRepoName {
-		return c.name
-	}
-	path := c.repo.GetPath()
-	if path == "" {
-		return c.name
-	}
-	return filepath.Base(path)
-}
-
 // LocalConfig give access to the repository scoped configuration
 func (c *RepoCache) LocalConfig() repository.Config {
 	return c.repo.LocalConfig()

commands/webui.go 🔗

@@ -29,7 +29,7 @@ import (
 	"github.com/git-bug/git-bug/commands/execenv"
 	"github.com/git-bug/git-bug/entities/identity"
 	"github.com/git-bug/git-bug/repository"
-	"github.com/git-bug/git-bug/webui"
+	"github.com/git-bug/git-bug/webui2"
 )
 
 const webUIOpenConfigKey = "git-bug.webui.open"
@@ -196,7 +196,7 @@ func runWebUI(env *execenv.Env, opts webUIOptions) error {
 	apiRepos.Path("/file/{hash}").Methods("GET").Handler(httpapi.NewGitFileHandler(mrc))
 	apiRepos.Path("/upload").Methods("POST").Handler(httpapi.NewGitUploadFileHandler(mrc))
 
-	router.PathPrefix("/").Handler(webui.NewHandler())
+	router.PathPrefix("/").Handler(webui2.NewHandler())
 
 	srv := &http.Server{
 		Addr:    addr,
@@ -222,11 +222,6 @@ func runWebUI(env *execenv.Env, opts webUIOptions) error {
 		}
 
 		// Teardown
-		err := graphqlHandler.Close()
-		if err != nil {
-			env.Out.Println(err)
-		}
-
 		err = mrc.Close()
 		if err != nil {
 			env.Out.Println(err)

go.mod 🔗

@@ -87,7 +87,6 @@ require (
 	github.com/golang/snappy v0.0.4 // indirect
 	github.com/google/go-querystring v1.1.0 // indirect
 	github.com/google/uuid v1.6.0 // indirect
-	github.com/gorilla/websocket v1.5.3
 	github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect
 	github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
 	github.com/hashicorp/go-retryablehttp v0.7.7 // indirect

webui2/handler.go 🔗

@@ -0,0 +1,34 @@
+package webui2
+
+import (
+	"embed"
+	"io/fs"
+	"net/http"
+)
+
+//go:embed all:dist
+var assets embed.FS
+
+// NewHandler returns an http.Handler that serves the webui2 SPA.
+// Unknown paths fall back to index.html so that client-side routing works.
+func NewHandler() http.Handler {
+	dist, err := fs.Sub(assets, "dist")
+	if err != nil {
+		panic(err)
+	}
+	return http.FileServer(&spaFS{http.FS(dist)})
+}
+
+// spaFS wraps an http.FileSystem to serve index.html for any path that does
+// not correspond to a real file, enabling client-side routing in the SPA.
+type spaFS struct {
+	http.FileSystem
+}
+
+func (s *spaFS) Open(name string) (http.File, error) {
+	f, err := s.FileSystem.Open(name)
+	if err != nil {
+		return s.FileSystem.Open("/index.html")
+	}
+	return f, nil
+}

webui2/package.json 🔗

@@ -12,15 +12,15 @@
   "dependencies": {
     "@apollo/client": "^3.13.0",
     "@radix-ui/react-avatar": "^1.1.3",
+    "@radix-ui/react-popover": "^1.1.6",
     "@radix-ui/react-separator": "^1.1.1",
     "@radix-ui/react-slot": "^1.1.2",
-    "@radix-ui/react-popover": "^1.1.6",
     "@radix-ui/react-tooltip": "^1.1.8",
     "class-variance-authority": "^0.7.1",
     "clsx": "^2.1.1",
     "date-fns": "^4.1.0",
-    "highlight.js": "^11.11.1",
     "graphql": "^16.9.0",
+    "highlight.js": "^11.11.1",
     "lucide-react": "^0.468.0",
     "react": "^19.1.0",
     "react-dom": "^19.1.0",

webui2/src/App.tsx 🔗

@@ -9,28 +9,21 @@ import { CodePage } from '@/pages/CodePage'
 import { UserProfilePage } from '@/pages/UserProfilePage'
 import { CommitPage } from '@/pages/CommitPage'
 import { IdentitySelectPage } from '@/pages/IdentitySelectPage'
+import { ErrorPage } from '@/pages/ErrorPage'
 
 // Route structure:
-//   /                          → repo picker
+//   /                          → repo picker (or redirect if single repo)
 //   /:repo                     → code browser (repo home)
 //   /:repo/issues              → issue list
-//   /_/auth/select-identity    → OAuth identity adoption (first-time login)
-//
-// The /_/auth/* prefix uses "_" as a reserved namespace so it never collides
-// with a real repo slug.
+//   /auth/select-identity      → OAuth identity adoption (first-time login)
 const router = createBrowserRouter([
   {
     path: '/',
     element: <Shell />,
+    errorElement: <ErrorPage />,
     children: [
       { index: true, element: <RepoPickerPage /> },
-      // Reserved namespace for app-level pages that are not repo-scoped.
-      {
-        path: '_',
-        children: [
-          { path: 'auth/select-identity', element: <IdentitySelectPage /> },
-        ],
-      },
+      { path: 'auth/select-identity', element: <IdentitySelectPage /> },
       {
         path: ':repo',
         element: <RepoShell />,

webui2/src/__generated__/graphql.ts 🔗

@@ -761,13 +761,8 @@ export type Repository = {
   allIdentities: IdentityConnection;
   bug?: Maybe<Bug>;
   identity?: Maybe<Identity>;
-  /** The name of the repository */
+  /** The name of the repository. Null for the default (unnamed) repository. */
   name?: Maybe<Scalars['String']['output']>;
-  /**
-   * URL-friendly slug for this repository. Named repos use their name;
-   * the default (unnamed) repo derives the slug from the directory basename.
-   */
-  slug: Scalars['String']['output'];
   /** The identity created or selected by the user as its own */
   userIdentity?: Maybe<Identity>;
   /** List of valid labels. */
@@ -960,7 +955,7 @@ export type BugSetTitleMutation = { __typename?: 'Mutation', bugSetTitle: { __ty
 export type RepositoriesQueryVariables = Exact<{ [key: string]: never; }>;
 
 
-export type RepositoriesQuery = { __typename?: 'Query', repositories: { __typename?: 'RepositoryConnection', totalCount: number, nodes: Array<{ __typename?: 'Repository', name?: string | null, slug: string }> } };
+export type RepositoriesQuery = { __typename?: 'Query', repositories: { __typename?: 'RepositoryConnection', totalCount: number, nodes: Array<{ __typename?: 'Repository', name?: string | null }> } };
 
 export type ServerConfigQueryVariables = Exact<{ [key: string]: never; }>;
 
@@ -1591,7 +1586,6 @@ export const RepositoriesDocument = gql`
   repositories {
     nodes {
       name
-      slug
     }
     totalCount
   }

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

@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
 import { Skeleton } from '@/components/ui/skeleton'
 import { getCommits } from '@/lib/gitApi'
 import type { GitCommit as GitCommitType } from '@/lib/gitApi'
+import { useRepo } from '@/lib/repo'
 
 interface CommitListProps {
   ref_: string
@@ -17,6 +18,7 @@ const PAGE_SIZE = 30
 // Paginated commit history grouped by calendar date. Each row links to the
 // commit detail page. Used in CodePage's "History" view.
 export function CommitList({ ref_, path }: CommitListProps) {
+  const repo = useRepo()
   const [commits, setCommits] = useState<GitCommitType[]>([])
   const [loading, setLoading] = useState(true)
   const [loadingMore, setLoadingMore] = useState(false)
@@ -72,7 +74,7 @@ export function CommitList({ ref_, path }: CommitListProps) {
           </h3>
           <div className="overflow-hidden rounded-md border border-border divide-y divide-border">
             {group.map((commit) => (
-              <CommitRow key={commit.hash} commit={commit} />
+              <CommitRow key={commit.hash} commit={commit} repo={repo} />
             ))}
           </div>
         </div>
@@ -89,14 +91,15 @@ export function CommitList({ ref_, path }: CommitListProps) {
   )
 }
 
-function CommitRow({ commit }: { commit: GitCommitType }) {
+function CommitRow({ commit, repo }: { commit: GitCommitType; repo: string | null }) {
+  const commitPath = repo ? `/${repo}/commit/${commit.hash}` : `/commit/${commit.hash}`
   return (
     <div className="flex items-center gap-3 bg-background px-4 py-3 hover:bg-muted/30">
       <GitCommit className="size-4 shrink-0 text-muted-foreground" />
 
       <div className="min-w-0 flex-1">
         <Link
-          to={`/commit/${commit.hash}`}
+          to={commitPath}
           className="block truncate font-medium text-foreground hover:text-primary hover:underline"
         >
           {commit.message}
@@ -108,7 +111,7 @@ function CommitRow({ commit }: { commit: GitCommitType }) {
       </div>
 
       <Link
-        to={`/commit/${commit.hash}`}
+        to={commitPath}
         className="shrink-0 font-mono text-xs text-muted-foreground hover:text-foreground hover:underline"
         title={commit.hash}
       >

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

@@ -2,6 +2,7 @@ import { Folder, File } from 'lucide-react'
 import { Link } from 'react-router-dom'
 import { formatDistanceToNow } from 'date-fns'
 import { Skeleton } from '@/components/ui/skeleton'
+import { useRepo } from '@/lib/repo'
 import type { GitTreeEntry } from '@/lib/gitApi'
 
 interface FileTreeProps {
@@ -57,6 +58,7 @@ function FileTreeRow({
   onNavigate: (entry: GitTreeEntry) => void
 }) {
   const isDir = entry.type === 'tree'
+  const repo = useRepo()
 
   return (
     <tr
@@ -78,7 +80,7 @@ function FileTreeRow({
       <td className="hidden max-w-xs truncate px-3 py-2 text-muted-foreground md:table-cell">
         {entry.lastCommit && (
           <Link
-            to={`/commit/${entry.lastCommit.hash}`}
+            to={repo ? `/${repo}/commit/${entry.lastCommit.hash}` : `/commit/${entry.lastCommit.hash}`}
             className="hover:text-foreground hover:underline"
             onClick={(e) => e.stopPropagation()}
           >

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

@@ -1,5 +1,4 @@
-import { useMemo } from 'react'
-import hljs from 'highlight.js'
+import { useState, useEffect } from 'react'
 import { Copy, Download } from 'lucide-react'
 import { Button } from '@/components/ui/button'
 import { Skeleton } from '@/components/ui/skeleton'
@@ -13,21 +12,33 @@ interface FileViewerProps {
 }
 
 // Syntax-highlighted file viewer with line numbers, copy, and download buttons.
-// Uses highlight.js for highlighting; binary files show a placeholder.
+// highlight.js is loaded lazily (dynamic import) so it doesn't bloat the initial bundle.
 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() ?? ''
-    const result = hljs.getLanguage(ext)
-      ? hljs.highlight(blob.content, { language: ext })
-      : hljs.highlightAuto(blob.content)
-    return {
-      html: result.value,
-      lineCount: blob.content.split('\n').length,
+  const [highlighted, setHighlighted] = useState<{ html: string; lineCount: number } | null>(null)
+
+  useEffect(() => {
+    if (blob.isBinary || !blob.content) {
+      setHighlighted({ html: '', lineCount: 0 })
+      return
     }
+    setHighlighted(null)
+    let cancelled = false
+    import('highlight.js').then(({ default: hljs }) => {
+      if (cancelled) return
+      const ext = blob.path.split('.').pop() ?? ''
+      const result = hljs.getLanguage(ext)
+        ? hljs.highlight(blob.content, { language: ext })
+        : hljs.highlightAuto(blob.content)
+      setHighlighted({
+        html: result.value,
+        lineCount: blob.content.split('\n').length,
+      })
+    })
+    return () => { cancelled = true }
   }, [blob])
 
-  if (loading) return <FileViewerSkeleton />
+  if (loading || highlighted === null) return <FileViewerSkeleton />
+  const { html, lineCount } = highlighted
 
   function copyToClipboard() {
     navigator.clipboard.writeText(blob.content)

webui2/src/components/layout/Header.tsx 🔗

@@ -39,8 +39,8 @@ export function Header() {
   const repoMatch = useMatch({ path: '/:repo/*', end: false })
   const repo = repoMatch?.params.repo ?? null
 
-  // Don't match /_/auth/* as a repo slug.
-  const effectiveRepo = repo === '_' ? null : repo
+  // Don't show repo nav on the /auth/* pages.
+  const effectiveRepo = repo === 'auth' ? null : repo
 
   return (
     <header className="sticky top-0 z-50 border-b border-border bg-background/95 backdrop-blur">

webui2/src/pages/ErrorPage.tsx 🔗

@@ -0,0 +1,36 @@
+// Global error boundary page. Rendered by React Router when a route throws
+// or when navigation results in a 404. Replaces the default "Unexpected
+// Application Error!" screen.
+
+import { useRouteError, isRouteErrorResponse, Link } from 'react-router-dom'
+import { AlertTriangle } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+
+export function ErrorPage() {
+  const error = useRouteError()
+
+  let status: number | undefined
+  let message: string
+
+  if (isRouteErrorResponse(error)) {
+    status = error.status
+    message = error.statusText || error.data
+  } else if (error instanceof Error) {
+    message = error.message
+  } else {
+    message = 'An unexpected error occurred.'
+  }
+
+  return (
+    <div className="flex min-h-screen flex-col items-center justify-center gap-4 text-center">
+      <AlertTriangle className="size-10 text-muted-foreground" />
+      {status && (
+        <p className="text-5xl font-bold tracking-tight">{status}</p>
+      )}
+      <p className="text-sm text-muted-foreground">{message}</p>
+      <Button variant="outline" size="sm" asChild>
+        <Link to="/">Go home</Link>
+      </Button>
+    </div>
+  )
+}

webui2/src/pages/IdentitySelectPage.tsx 🔗

@@ -1,4 +1,4 @@
-// Identity selection page (/_/auth/select-identity).
+// Identity selection page (/auth/select-identity).
 //
 // Reached after a successful OAuth login when no existing git-bug identity
 // could be matched automatically (via provider metadata set by the bridge).

webui2/src/pages/RepoPickerPage.tsx 🔗

@@ -1,14 +1,30 @@
-// Repository picker page (/). Shows all registered repos and lets the user
-// navigate into one. When there is only one repo, the list still renders
-// (no auto-redirect) so the user always knows which repo they're entering.
+// Repository picker page (/). Auto-redirects when there is exactly one repo.
+// Shows a list when multiple repos are registered.
 
-import { Link } from 'react-router-dom'
+import { useEffect } from 'react'
+import { Link, useNavigate } from 'react-router-dom'
 import { GitFork, FolderOpen, AlertCircle } from 'lucide-react'
 import { Skeleton } from '@/components/ui/skeleton'
 import { useRepositoriesQuery } from '@/__generated__/graphql'
 
+function repoSlug(name: string | null | undefined): string {
+  return name ?? '_'
+}
+
+function repoLabel(name: string | null | undefined): string {
+  return name ?? 'default'
+}
+
 export function RepoPickerPage() {
   const { data, loading, error } = useRepositoriesQuery()
+  const navigate = useNavigate()
+
+  // Auto-redirect when there is exactly one repo — no need to pick.
+  useEffect(() => {
+    if (data?.repositories.nodes.length === 1) {
+      navigate('/' + repoSlug(data.repositories.nodes[0].name), { replace: true })
+    }
+  }, [data, navigate])
 
   return (
     <div className="mx-auto max-w-lg py-12">
@@ -24,7 +40,7 @@ export function RepoPickerPage() {
         </div>
       )}
 
-      {loading && !data && (
+      {(loading && !data) && (
         <div className="space-y-2">
           {Array.from({ length: 3 }).map((_, i) => (
             <Skeleton key={i} className="h-16 w-full rounded-md" />
@@ -35,12 +51,12 @@ export function RepoPickerPage() {
       <div className="divide-y divide-border rounded-md border border-border">
         {data?.repositories.nodes.map((repo) => (
           <Link
-            key={repo.slug}
-            to={`/${repo.slug}`}
+            key={repoSlug(repo.name)}
+            to={`/${repoSlug(repo.name)}`}
             className="flex items-center gap-3 px-4 py-4 hover:bg-muted/50 transition-colors"
           >
             <FolderOpen className="size-5 shrink-0 text-muted-foreground" />
-            <p className="font-medium text-foreground">{repo.slug}</p>
+            <p className="font-medium text-foreground">{repoLabel(repo.name)}</p>
           </Link>
         ))}
 

webui2/vite.config.ts 🔗

@@ -12,6 +12,20 @@ export default defineConfig({
       '@': path.resolve(__dirname, './src'),
     },
   },
+  build: {
+    // highlight.js is inherently large (~1MB) but lazy-loaded; silence the warning.
+    chunkSizeWarningLimit: 1100,
+    rollupOptions: {
+      output: {
+        manualChunks: {
+          'vendor-react': ['react', 'react-dom', 'react-router-dom'],
+          'vendor-apollo': ['@apollo/client', 'graphql'],
+          'vendor-markdown': ['react-markdown', 'remark-gfm'],
+          'vendor-highlight': ['highlight.js'],
+        },
+      },
+    },
+  },
   server: {
     proxy: {
       '/graphql': { target: API_URL, changeOrigin: true },