diff --git a/.gitignore b/.gitignore index b625a693f232aeb1469e102014c2c245f56c38b2..d6eae0b3200d3d6c4ce1c648724d07da53309b05 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,3 @@ coverage.txt # nix output directory from `nix build` commands /result -webui2 diff --git a/Makefile b/Makefile index e613dea639f7e9c01de297261000ac4096e34487..c19a8899740c93c53ddabf0a086b8fbc9928135c 100644 --- a/Makefile +++ b/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 diff --git a/api/graphql/graph/repository.generated.go b/api/graphql/graph/repository.generated.go index d22f533d7ac1423d5dda91b81b7ce83670a88cc7..81e39832ce30d106fd54672bbf7e2a362f121fa8 100644 --- a/api/graphql/graph/repository.generated.go +++ b/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 diff --git a/api/graphql/graph/root.generated.go b/api/graphql/graph/root.generated.go index ad26a054bd3e03fb0b1f55526c78c59f72eefc9b..9d51fb9ed357fd9f0aa5ec2dfb5fbea78ead61b5 100644 --- a/api/graphql/graph/root.generated.go +++ b/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": diff --git a/api/graphql/graph/root_.generated.go b/api/graphql/graph/root_.generated.go index 63783e6cfc1f44e5e6710394c786c4348c344f48..3c17263d6936e87b22914d6c5a118ed9655a3795 100644 --- a/api/graphql/graph/root_.generated.go +++ b/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.""" diff --git a/api/graphql/handler.go b/api/graphql/handler.go index 8c217b46bb38907d263057b7c0c455b58cfe2f45..f3e62bac9c4260054efdd2ae447f05dc2ba2c9ac 100644 --- a/api/graphql/handler.go +++ b/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 } diff --git a/api/graphql/resolvers/repo.go b/api/graphql/resolvers/repo.go index f18b7aff4b4b68afdb722ce17c32b5ad70ebca9f..6be7e2bacc0e70c48c38d9077284cf5f2e6951dd 100644 --- a/api/graphql/resolvers/repo.go +++ b/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, diff --git a/api/graphql/schema/repository.graphql b/api/graphql/schema/repository.graphql index 7f39d698953d63f11d8e529ffdbac74030c689cc..f72ea69fd28c4ab3485f083c78dddc53a6e0c9e4 100644 --- a/api/graphql/schema/repository.graphql +++ b/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.""" diff --git a/api/http/auth_handler.go b/api/http/auth_handler.go index a5439e63caf0c42a14a4472b0a14e548f6ee770d..842606e4fd71cd91b07e79c90ce1f36b85be5579 100644 --- a/api/http/auth_handler.go +++ b/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(), diff --git a/cache/multi_repo_cache.go b/cache/multi_repo_cache.go index 81f6825c0c1fe7c7d84fc0d2d9630ab1fd71a010..8dc7fb02b92afd390750dc6f6545472b94660acc 100644 --- a/cache/multi_repo_cache.go +++ b/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) } diff --git a/cache/repo_cache_common.go b/cache/repo_cache_common.go index d4147fa73e94eff6e9c6c151248eb9cb3dce317d..5cb0bdfca6f93aee2dfc93fbd35c645d1a4a3a12 100644 --- a/cache/repo_cache_common.go +++ b/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() diff --git a/commands/webui.go b/commands/webui.go index fbb01a14993dbf123b91e399d089f555f42dc61b..9c19bb2863df0d076b51e3120b6e5bb7ed10a649 100644 --- a/commands/webui.go +++ b/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) diff --git a/go.mod b/go.mod index 3d464cdafe111139513a88bd084baa31b8da60ed..0c4c9c103460306cf3ba1e288681441e22a4c9da 100644 --- a/go.mod +++ b/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 diff --git a/webui2/handler.go b/webui2/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..98f355e0437f9bfc0c75f64a21a6e3916c1844ee --- /dev/null +++ b/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 +} diff --git a/webui2/package.json b/webui2/package.json index 84590e35c77cfaa9cda4eb9c6467c562ee2cd7ea..bd18a3d03f4453205a0bdb6df744524b009fade2 100644 --- a/webui2/package.json +++ b/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", diff --git a/webui2/src/App.tsx b/webui2/src/App.tsx index f54076596826f1a1fcde9ac73fa3e28cb8561811..8a904ed47b9dd9fb25bb49aed112506ac370f74d 100644 --- a/webui2/src/App.tsx +++ b/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: , + errorElement: , children: [ { index: true, element: }, - // Reserved namespace for app-level pages that are not repo-scoped. - { - path: '_', - children: [ - { path: 'auth/select-identity', element: }, - ], - }, + { path: 'auth/select-identity', element: }, { path: ':repo', element: , diff --git a/webui2/src/__generated__/graphql.ts b/webui2/src/__generated__/graphql.ts index a0daece0782c87618d10f81a6d4524b05225e6c9..0df92e0640ae2231469c2a05ab7a122a1bfde9ac 100644 --- a/webui2/src/__generated__/graphql.ts +++ b/webui2/src/__generated__/graphql.ts @@ -761,13 +761,8 @@ export type Repository = { allIdentities: IdentityConnection; bug?: Maybe; identity?: Maybe; - /** The name of the repository */ + /** The name of the repository. Null for the default (unnamed) repository. */ name?: Maybe; - /** - * 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; /** 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 } diff --git a/webui2/src/components/code/CommitList.tsx b/webui2/src/components/code/CommitList.tsx index 5ba3fd622ee4bd4faea221ebad9a9e65e774b25d..186a22c4d7ef732de8af59af72c6b662aede8f46 100644 --- a/webui2/src/components/code/CommitList.tsx +++ b/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([]) const [loading, setLoading] = useState(true) const [loadingMore, setLoadingMore] = useState(false) @@ -72,7 +74,7 @@ export function CommitList({ ref_, path }: CommitListProps) {
{group.map((commit) => ( - + ))}
@@ -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 (
{commit.message} @@ -108,7 +111,7 @@ function CommitRow({ commit }: { commit: GitCommitType }) {
diff --git a/webui2/src/components/code/FileTree.tsx b/webui2/src/components/code/FileTree.tsx index 5008f4605d4665ed9763827719c71e897e90b24b..22aaf1099166c06a31d61261920bf52422f9c636 100644 --- a/webui2/src/components/code/FileTree.tsx +++ b/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 ( {entry.lastCommit && ( e.stopPropagation()} > diff --git a/webui2/src/components/code/FileViewer.tsx b/webui2/src/components/code/FileViewer.tsx index 6d50e627b72a1f825756eda6e4babe16f0a35344..94216c95b3ff04623821e2c8c8c9f98dde7eacfb 100644 --- a/webui2/src/components/code/FileViewer.tsx +++ b/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 + if (loading || highlighted === null) return + const { html, lineCount } = highlighted function copyToClipboard() { navigator.clipboard.writeText(blob.content) diff --git a/webui2/src/components/layout/Header.tsx b/webui2/src/components/layout/Header.tsx index 1be525060812d995b79d63bdb925d703393641ca..b7b87a7bd87e65e3d6f08a9006627e9c53bf2953 100644 --- a/webui2/src/components/layout/Header.tsx +++ b/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 (
diff --git a/webui2/src/graphql/Repositories.graphql b/webui2/src/graphql/Repositories.graphql index b4972f56ecdcae5a592c6c06bdddd8c63b6785ee..6ede5c94e7dab7b75c3e2daccde9a22fd15015bb 100644 --- a/webui2/src/graphql/Repositories.graphql +++ b/webui2/src/graphql/Repositories.graphql @@ -3,7 +3,6 @@ query Repositories { repositories { nodes { name - slug } totalCount } diff --git a/webui2/src/pages/ErrorPage.tsx b/webui2/src/pages/ErrorPage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..da05b969d4fa1a90d79cdf9030a37d863df74493 --- /dev/null +++ b/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 ( +
+ + {status && ( +

{status}

+ )} +

{message}

+ +
+ ) +} diff --git a/webui2/src/pages/IdentitySelectPage.tsx b/webui2/src/pages/IdentitySelectPage.tsx index 784fbba9636cbc03c93b1ef25e86b396d43c4f5a..691a1dc440a99ac89af6fd37d925e59b8c12cb00 100644 --- a/webui2/src/pages/IdentitySelectPage.tsx +++ b/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). diff --git a/webui2/src/pages/RepoPickerPage.tsx b/webui2/src/pages/RepoPickerPage.tsx index 8b399bd88a5e63dd856f4604fe67815bead5ae08..666f374ba833f792c2b73d56dc04248bf22d6203 100644 --- a/webui2/src/pages/RepoPickerPage.tsx +++ b/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 (
@@ -24,7 +40,7 @@ export function RepoPickerPage() {
)} - {loading && !data && ( + {(loading && !data) && (
{Array.from({ length: 3 }).map((_, i) => ( @@ -35,12 +51,12 @@ export function RepoPickerPage() {
{data?.repositories.nodes.map((repo) => ( -

{repo.slug}

+

{repoLabel(repo.name)}

))} diff --git a/webui2/tsconfig.app.tsbuildinfo b/webui2/tsconfig.app.tsbuildinfo index 558bb8db05ef372347a84ad50d24dc3f09b19422..befd0014b84ed102377127747bbe2c1a358891d2 100644 --- a/webui2/tsconfig.app.tsbuildinfo +++ b/webui2/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/main.tsx","./src/__generated__/graphql.ts","./src/components/bugs/BugRow.tsx","./src/components/bugs/CommentBox.tsx","./src/components/bugs/IssueFilters.tsx","./src/components/bugs/LabelBadge.tsx","./src/components/bugs/LabelEditor.tsx","./src/components/bugs/QueryInput.tsx","./src/components/bugs/StatusBadge.tsx","./src/components/bugs/Timeline.tsx","./src/components/bugs/TitleEditor.tsx","./src/components/code/CodeBreadcrumb.tsx","./src/components/code/CommitList.tsx","./src/components/code/FileTree.tsx","./src/components/code/FileViewer.tsx","./src/components/code/RefSelector.tsx","./src/components/content/Markdown.tsx","./src/components/layout/Header.tsx","./src/components/layout/Shell.tsx","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/popover.tsx","./src/components/ui/separator.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/textarea.tsx","./src/lib/apollo.ts","./src/lib/auth.tsx","./src/lib/gitApi.ts","./src/lib/repo.tsx","./src/lib/theme.tsx","./src/lib/utils.ts","./src/pages/BugDetailPage.tsx","./src/pages/BugListPage.tsx","./src/pages/CodePage.tsx","./src/pages/CommitPage.tsx","./src/pages/IdentitySelectPage.tsx","./src/pages/NewBugPage.tsx","./src/pages/RepoPickerPage.tsx","./src/pages/UserProfilePage.tsx"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/main.tsx","./src/__generated__/graphql.ts","./src/components/bugs/BugRow.tsx","./src/components/bugs/CommentBox.tsx","./src/components/bugs/IssueFilters.tsx","./src/components/bugs/LabelBadge.tsx","./src/components/bugs/LabelEditor.tsx","./src/components/bugs/QueryInput.tsx","./src/components/bugs/StatusBadge.tsx","./src/components/bugs/Timeline.tsx","./src/components/bugs/TitleEditor.tsx","./src/components/code/CodeBreadcrumb.tsx","./src/components/code/CommitList.tsx","./src/components/code/FileTree.tsx","./src/components/code/FileViewer.tsx","./src/components/code/RefSelector.tsx","./src/components/content/Markdown.tsx","./src/components/layout/Header.tsx","./src/components/layout/Shell.tsx","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/popover.tsx","./src/components/ui/separator.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/textarea.tsx","./src/lib/apollo.ts","./src/lib/auth.tsx","./src/lib/gitApi.ts","./src/lib/repo.tsx","./src/lib/theme.tsx","./src/lib/utils.ts","./src/pages/BugDetailPage.tsx","./src/pages/BugListPage.tsx","./src/pages/CodePage.tsx","./src/pages/CommitPage.tsx","./src/pages/ErrorPage.tsx","./src/pages/IdentitySelectPage.tsx","./src/pages/NewBugPage.tsx","./src/pages/RepoPickerPage.tsx","./src/pages/UserProfilePage.tsx"],"version":"5.9.3"} \ No newline at end of file diff --git a/webui2/vite.config.ts b/webui2/vite.config.ts index 0df825c6a2864a9aa880179b59a223651422cd32..2a8e01f071cbb5d2a22f74ac121292045a7e2e49 100644 --- a/webui2/vite.config.ts +++ b/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 },