.gitignore 🔗
@@ -17,4 +17,3 @@ coverage.txt
# nix output directory from `nix build` commands
/result
-webui2
Michael Muré created
.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(-)
@@ -17,4 +17,3 @@ coverage.txt
# nix output directory from `nix build` commands
/result
-webui2
@@ -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
@@ -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
@@ -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":
@@ -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."""
@@ -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
}
@@ -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,
@@ -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."""
@@ -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(),
@@ -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)
}
@@ -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()
@@ -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)
@@ -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
@@ -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
+}
@@ -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",
@@ -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 />,
@@ -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
}
@@ -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}
>
@@ -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()}
>
@@ -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)
@@ -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">
@@ -3,7 +3,6 @@ query Repositories {
repositories {
nodes {
name
- slug
}
totalCount
}
@@ -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>
+ )
+}
@@ -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).
@@ -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>
))}
@@ -1 +1 @@
@@ -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 },