Detailed changes
@@ -1,4 +1,5 @@
import { ApolloClient, InMemoryCache, HttpLink } from "@apollo/client";
+import { createQueryPreloader } from "@apollo/client/react";
const httpLink = new HttpLink({
uri: "/graphql",
@@ -18,3 +19,7 @@ export const client = new ApolloClient({
},
}),
});
+
+// Preloader for use in TanStack Router loaders. Returns a QueryRef
+// that components read with useReadQuery() for suspense-based rendering.
+export const preloadQuery = createQueryPreloader(client);
@@ -1,139 +0,0 @@
-import { useParams, Link } from "@tanstack/react-router";
-import { formatDistanceToNow } from "date-fns";
-import { ArrowLeft } from "lucide-react";
-
-import { useBugDetailQuery } from "@/__generated__/graphql";
-import { CommentBox } from "@/components/bugs/CommentBox";
-import { LabelEditor } from "@/components/bugs/LabelEditor";
-import { StatusBadge } from "@/components/bugs/StatusBadge";
-import { Timeline } from "@/components/bugs/Timeline";
-import { TitleEditor } from "@/components/bugs/TitleEditor";
-import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
-import { Separator } from "@/components/ui/separator";
-import { Skeleton } from "@/components/ui/skeleton";
-import { useRepo } from "@/lib/repo";
-
-// Issue detail page (/:repo/issues/:id). Shows title, status, timeline of
-// comments and events, and a sidebar with labels and participants.
-export function BugDetailPage() {
- const { id } = useParams({ strict: false });
- const repo = useRepo();
- const { data, loading, error } = useBugDetailQuery({
- variables: { ref: repo, prefix: id! },
- });
-
- if (error) {
- return (
- <div className="text-destructive py-16 text-center text-sm">
- Failed to load issue: {error.message}
- </div>
- );
- }
-
- if (loading && !data) {
- return <BugDetailSkeleton />;
- }
-
- const bug = data?.repository?.bug;
- if (!bug) {
- return <div className="text-muted-foreground py-16 text-center text-sm">Issue not found.</div>;
- }
-
- return (
- <div>
- <Link
- to="/$repo/issues"
- params={{ repo: repo! }}
- className="text-muted-foreground hover:text-foreground mb-4 flex items-center gap-1.5 text-sm"
- >
- <ArrowLeft className="size-3.5" />
- Back to issues
- </Link>
-
- {/* Title row β hover reveals edit button when logged in */}
- <div className="mb-3">
- <TitleEditor bugPrefix={bug.humanId} title={bug.title} humanId={bug.humanId} ref_={repo} />
- </div>
-
- <div className="text-muted-foreground mb-6 flex flex-wrap items-center gap-3 text-sm">
- <StatusBadge status={bug.status} />
- <span>
- <Link
- to="/$repo/user/$id"
- params={{ repo: repo!, id: bug.author.humanId }}
- className="text-foreground font-medium hover:underline"
- >
- {bug.author.displayName}
- </Link>{" "}
- opened this issue {formatDistanceToNow(new Date(bug.createdAt), { addSuffix: true })}
- </span>
- </div>
-
- <Separator className="mb-6" />
-
- <div className="flex gap-8">
- {/* Timeline + comment box */}
- <div className="min-w-0 flex-1 space-y-4">
- <Timeline bugPrefix={bug.humanId} items={bug.timeline.nodes} />
- <CommentBox bugPrefix={bug.humanId} bugStatus={bug.status} ref_={repo} />
- </div>
-
- {/* Sidebar */}
- <aside className="w-56 shrink-0 space-y-6">
- <LabelEditor bugPrefix={bug.humanId} currentLabels={bug.labels} ref_={repo} />
-
- <Separator />
-
- <div>
- <h3 className="text-muted-foreground mb-2 text-xs font-semibold tracking-wider uppercase">
- Participants
- </h3>
- <div className="flex flex-wrap gap-1.5">
- {bug.participants.nodes.map((p) => {
- return (
- <Link
- key={p.id}
- to="/$repo/user/$id"
- params={{ repo: repo!, id: p.humanId }}
- title={p.displayName}
- >
- <Avatar className="size-6">
- <AvatarImage src={p.avatarUrl ?? undefined} alt={p.displayName} />
- <AvatarFallback className="text-[10px]">
- {p.displayName.slice(0, 2).toUpperCase()}
- </AvatarFallback>
- </Avatar>
- </Link>
- );
- })}
- </div>
- </div>
- </aside>
- </div>
- </div>
- );
-}
-
-function BugDetailSkeleton() {
- return (
- <div className="space-y-4">
- <Skeleton className="h-8 w-2/3" />
- <Skeleton className="h-4 w-1/3" />
- <Separator />
- <div className="flex gap-8">
- <div className="flex-1 space-y-4">
- {Array.from({ length: 3 }).map((_, i) => (
- <div key={i} className="border-border rounded-md border p-4">
- <Skeleton className="mb-3 h-4 w-1/4" />
- <Skeleton className="h-16 w-full" />
- </div>
- ))}
- </div>
- <div className="w-56 space-y-3">
- <Skeleton className="h-4 w-full" />
- <Skeleton className="h-4 w-3/4" />
- </div>
- </div>
- </div>
- );
-}
@@ -1,388 +0,0 @@
-import { CircleDot, CircleCheck, ChevronLeft, ChevronRight } from "lucide-react";
-import { useState, useEffect } from "react";
-
-import { useBugListQuery } from "@/__generated__/graphql";
-import { BugRow } from "@/components/bugs/BugRow";
-import { IssueFilters } from "@/components/bugs/IssueFilters";
-import type { SortValue } from "@/components/bugs/IssueFilters";
-import { QueryInput } from "@/components/bugs/QueryInput";
-import { Button } from "@/components/ui/button";
-import { Skeleton } from "@/components/ui/skeleton";
-import { useRepo } from "@/lib/repo";
-import { cn } from "@/lib/utils";
-
-const PAGE_SIZE = 25;
-
-type StatusFilter = "open" | "closed";
-
-// Issue list page (/:repo/issues). Search bar with structured query, open/closed toggle,
-// label+author filter dropdowns, and paginated bug rows.
-export function BugListPage() {
- const repo = useRepo();
- const [statusFilter, setStatusFilter] = useState<StatusFilter>("open");
- const [selectedLabels, setSelectedLabels] = useState<string[]>([]);
- // humanId β uniquely identifies the selection for the dropdown UI
- const [selectedAuthorId, setSelectedAuthorId] = useState<string | null>(null);
- // query value (login/name) β what goes into author:... in the query string
- const [selectedAuthorQuery, setSelectedAuthorQuery] = useState<string | null>(null);
- const [freeText, setFreeText] = useState("");
- const [sort, setSort] = useState<SortValue>("creation-desc");
- const [draft, setDraft] = useState(() => buildQueryString("open", [], null, "", "creation-desc"));
-
- // Cursor-stack pagination: cursors[i] is the `after` value to fetch page i.
- // cursors[0] is always undefined (first page needs no cursor).
- const [cursors, setCursors] = useState<(string | undefined)[]>([undefined]);
- const page = cursors.length - 1; // 0-indexed current page
-
- // Build separate query strings: two for the always-visible counts (open/closed),
- // one for the paginated list. The count queries share all filters except status.
- const baseQuery = buildBaseQuery(selectedLabels, selectedAuthorQuery, freeText);
- const openQuery = `status:open ${baseQuery}`.trim();
- const closedQuery = `status:closed ${baseQuery}`.trim();
- const listQuery = buildQueryString(
- statusFilter,
- selectedLabels,
- selectedAuthorQuery,
- freeText,
- sort,
- );
-
- const { data, loading, error } = useBugListQuery({
- variables: {
- ref: repo,
- openQuery,
- closedQuery,
- listQuery,
- first: PAGE_SIZE,
- after: cursors[page],
- },
- });
-
- const openCount = data?.repository?.openCount.totalCount ?? 0;
- const closedCount = data?.repository?.closedCount.totalCount ?? 0;
- const bugs = data?.repository?.bugs;
- const totalCount = bugs?.totalCount ?? 0;
- const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
- const hasNext = bugs?.pageInfo.hasNextPage ?? false;
- const hasPrev = page > 0;
-
- // Reset to page 1 whenever the list query changes.
- useEffect(() => {
- setCursors([undefined]);
- }, [listQuery]);
-
- // Apply all filters at once, keeping draft in sync with the structured state.
- function applyFilters(
- status: StatusFilter,
- labels: string[],
- authorId: string | null,
- authorQuery: string | null,
- text: string,
- sortVal: SortValue = sort,
- ) {
- setStatusFilter(status);
- setSelectedLabels(labels);
- setSelectedAuthorId(authorId);
- setSelectedAuthorQuery(authorQuery);
- setFreeText(text);
- setSort(sortVal);
- setDraft(buildQueryString(status, labels, authorQuery, text, sortVal));
- }
-
- // Parse the draft text box on submit so manual edits update the dropdowns too.
- // When parsing we don't know the humanId β clear it so the dropdown resets.
- // Called both from the <form> onSubmit (with event) and from QueryInput's
- // Enter-key handler (without event), so e is optional.
- function handleSearch(e?: React.FormEvent) {
- e?.preventDefault();
- const p = parseQueryString(draft);
- applyFilters(p.status, p.labels, null, p.author, p.freeText, p.sort);
- }
-
- function goNext() {
- const endCursor = bugs?.pageInfo.endCursor;
- if (!endCursor) return;
- setCursors((prev) => [...prev, endCursor]);
- }
-
- function goPrev() {
- setCursors((prev) => prev.slice(0, -1));
- }
-
- return (
- <div>
- {/* Search bar */}
- <form onSubmit={handleSearch} className="mb-4 flex gap-2">
- <QueryInput
- value={draft}
- onChange={setDraft}
- onSubmit={handleSearch}
- placeholder="status:open author:β¦ label:β¦"
- />
- <Button type="submit">Search</Button>
- </form>
-
- {/* List container */}
- <div className="border-border rounded-md border">
- {/* Open / Closed toggle + filter dropdowns */}
- <div className="border-border flex items-center gap-2 overflow-x-auto border-b px-4 py-2">
- <div className="flex shrink-0 items-center gap-1">
- <button
- onClick={() =>
- applyFilters(
- "open",
- selectedLabels,
- selectedAuthorId,
- selectedAuthorQuery,
- freeText,
- )
- }
- className={cn(
- "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
- statusFilter === "open"
- ? "bg-accent text-accent-foreground"
- : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
- )}
- >
- <CircleDot
- className={cn(
- "size-4",
- statusFilter === "open" && "text-green-600 dark:text-green-400",
- )}
- />
- Open
- <span className="bg-muted ml-0.5 rounded-full px-1.5 py-0.5 text-xs leading-none tabular-nums">
- {openCount}
- </span>
- </button>
-
- <button
- onClick={() =>
- applyFilters(
- "closed",
- selectedLabels,
- selectedAuthorId,
- selectedAuthorQuery,
- freeText,
- )
- }
- className={cn(
- "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
- statusFilter === "closed"
- ? "bg-accent text-accent-foreground"
- : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
- )}
- >
- <CircleCheck
- className={cn(
- "size-4",
- statusFilter === "closed" && "text-purple-600 dark:text-purple-400",
- )}
- />
- Closed
- <span className="bg-muted ml-0.5 rounded-full px-1.5 py-0.5 text-xs leading-none tabular-nums">
- {closedCount}
- </span>
- </button>
- </div>
-
- <div className="ml-auto">
- <IssueFilters
- selectedLabels={selectedLabels}
- onLabelsChange={(labels) =>
- applyFilters(statusFilter, labels, selectedAuthorId, selectedAuthorQuery, freeText)
- }
- selectedAuthorId={selectedAuthorId}
- onAuthorChange={(id, qv) =>
- applyFilters(statusFilter, selectedLabels, id, qv, freeText)
- }
- recentAuthorIds={bugs?.nodes?.map((b) => b.author.humanId) ?? []}
- sort={sort}
- onSortChange={(s) =>
- applyFilters(
- statusFilter,
- selectedLabels,
- selectedAuthorId,
- selectedAuthorQuery,
- freeText,
- s,
- )
- }
- />
- </div>
- </div>
-
- {/* Bug rows */}
- {error && (
- <p className="text-destructive px-4 py-8 text-center text-sm">
- Failed to load issues: {error.message}
- </p>
- )}
-
- {loading && !data && <BugListSkeleton />}
-
- {bugs?.nodes.length === 0 && (
- <p className="text-muted-foreground px-4 py-8 text-center text-sm">
- No {statusFilter} issues found.
- </p>
- )}
-
- {bugs?.nodes.map((bug) => (
- <BugRow
- key={bug.id}
- id={bug.id}
- humanId={bug.humanId}
- status={bug.status}
- title={bug.title}
- labels={bug.labels}
- author={bug.author}
- createdAt={bug.createdAt}
- commentCount={bug.comments.totalCount}
- repo={repo}
- onLabelClick={(name) => {
- if (!selectedLabels.includes(name)) {
- applyFilters(
- statusFilter,
- [...selectedLabels, name],
- selectedAuthorId,
- selectedAuthorQuery,
- freeText,
- );
- }
- }}
- />
- ))}
-
- {totalPages > 1 && (
- <div className="border-border flex items-center justify-center gap-2 border-t px-4 py-2">
- <Button
- variant="ghost"
- size="sm"
- onClick={goPrev}
- disabled={!hasPrev || loading}
- className="text-muted-foreground gap-1"
- >
- <ChevronLeft className="size-4" />
- Previous
- </Button>
- <span className="text-muted-foreground text-sm">
- Page {page + 1} of {totalPages}
- </span>
- <Button
- variant="ghost"
- size="sm"
- onClick={goNext}
- disabled={!hasNext || loading}
- className="text-muted-foreground gap-1"
- >
- Next
- <ChevronRight className="size-4" />
- </Button>
- </div>
- )}
- </div>
- </div>
- );
-}
-
-// buildBaseQuery returns the filter parts (labels, author, freeText) without
-// the status prefix, so it can be combined with "status:open" / "status:closed".
-function buildBaseQuery(labels: string[], author: string | null, freeText: string): string {
- const parts: string[] = [];
- for (const label of labels) {
- parts.push(label.includes(" ") ? `label:"${label}"` : `label:${label}`);
- }
- if (author) {
- parts.push(author.includes(" ") ? `author:"${author}"` : `author:${author}`);
- }
- if (freeText.trim()) parts.push(freeText.trim());
- return parts.join(" ");
-}
-
-// Build the structured query string sent to the GraphQL allBugs(query:) argument.
-// Multi-word label/author values are wrapped in quotes so the backend parser
-// treats them as a single token (e.g. label:"my label" vs label:my label).
-function buildQueryString(
- status: StatusFilter,
- labels: string[],
- author: string | null,
- freeText: string,
- sort: SortValue = "creation-desc",
-): string {
- const parts = [`status:${status}`];
- const base = buildBaseQuery(labels, author, freeText);
- if (base) parts.push(base);
- if (sort !== "creation-desc") parts.push(`sort:${sort}`);
- return parts.join(" ");
-}
-
-// Tokenize a query string, keeping quoted spans (e.g. author:"RenΓ© Descartes")
-// as single tokens. Quotes are preserved in the output so callers can strip them
-// when extracting values.
-function tokenizeQuery(input: string): string[] {
- const tokens: string[] = [];
- let current = "";
- let inQuote = false;
- for (const ch of input.trim()) {
- if (ch === '"') {
- inQuote = !inQuote;
- current += ch;
- } else if (ch === " " && !inQuote) {
- if (current) {
- tokens.push(current);
- current = "";
- }
- } else current += ch;
- }
- if (current) tokens.push(current);
- return tokens;
-}
-
-// Parse a free-text query string back into structured filter state so that
-// manual edits to the search box are reflected in the dropdown UI on submit.
-// Strips surrounding quotes from values (they're an encoding detail, not part
-// of the value itself). Unknown tokens fall through to freeText.
-const VALID_SORTS = new Set<SortValue>(["creation-desc", "creation-asc", "edit-desc", "edit-asc"]);
-
-function parseQueryString(input: string): {
- status: StatusFilter;
- labels: string[];
- author: string | null;
- freeText: string;
- sort: SortValue;
-} {
- let status: StatusFilter = "open";
- const labels: string[] = [];
- let author: string | null = null;
- let sort: SortValue = "creation-desc";
- const free: string[] = [];
-
- for (const token of tokenizeQuery(input)) {
- if (token === "status:open") status = "open";
- else if (token === "status:closed") status = "closed";
- else if (token.startsWith("label:")) labels.push(token.slice(6));
- else if (token.startsWith("author:")) author = token.slice(7).replace(/^"|"$/g, "");
- else if (token.startsWith("sort:")) {
- const v = token.slice(5) as SortValue;
- if (VALID_SORTS.has(v)) sort = v;
- } else free.push(token);
- }
-
- return { status, labels, author, freeText: free.join(" "), sort };
-}
-
-function BugListSkeleton() {
- return (
- <div className="divide-border divide-y">
- {Array.from({ length: 8 }).map((_, i) => (
- <div key={i} className="flex items-start gap-3 px-4 py-3">
- <Skeleton className="mt-0.5 size-4 rounded-full" />
- <div className="flex-1 space-y-2">
- <Skeleton className="h-4 w-2/3" />
- <Skeleton className="h-3 w-1/3" />
- </div>
- </div>
- ))}
- </div>
- );
-}
@@ -1,275 +0,0 @@
-// Code browser page. Switches between tree view, file viewer, and commit
-// history via ?type= search param. Ref is selected via ?ref=.
-
-import { gql } from "@apollo/client";
-import { useQuery } from "@apollo/client/react";
-import { useNavigate, useSearch } from "@tanstack/react-router";
-import { AlertCircle, GitCommit } from "lucide-react";
-import { useEffect } from "react";
-
-import type { GitRef, GitTreeEntry, GitBlob, GitLastCommit } from "@/__generated__/graphql";
-import { CodeBreadcrumb } from "@/components/code/CodeBreadcrumb";
-import { CommitList } from "@/components/code/CommitList";
-import { FileTree } from "@/components/code/FileTree";
-import type { TreeEntryWithCommit } from "@/components/code/FileTree";
-import { FileViewer } from "@/components/code/FileViewer";
-import { RefSelector } from "@/components/code/RefSelector";
-import { Markdown } from "@/components/content/Markdown";
-import { ButtonLink } from "@/components/ui/button-link";
-import { Skeleton } from "@/components/ui/skeleton";
-import { useRepo } from "@/lib/repo";
-
-const REFS_QUERY = gql`
- query CodePageRefs($repo: String) {
- repository(ref: $repo) {
- name
- refs {
- nodes {
- name
- shortName
- type
- hash
- isDefault
- }
- }
- }
- }
-`;
-
-const TREE_QUERY = gql`
- query CodePageTree($repo: String, $ref: String!, $path: String) {
- repository(ref: $repo) {
- tree(ref: $ref, path: $path) {
- name
- type
- hash
- }
- }
- }
-`;
-
-const LAST_COMMITS_QUERY = gql`
- query CodePageLastCommits($repo: String, $ref: String!, $path: String, $names: [String!]!) {
- repository(ref: $repo) {
- lastCommits(ref: $ref, path: $path, names: $names) {
- name
- commit {
- hash
- shortHash
- message
- date
- }
- }
- }
- }
-`;
-
-const BLOB_QUERY = gql`
- query CodePageBlob($repo: String, $ref: String!, $path: String!) {
- repository(ref: $repo) {
- blob(ref: $ref, path: $path) {
- path
- hash
- text
- size
- isBinary
- isTruncated
- }
- }
- }
-`;
-
-interface RefsQueryData {
- repository: {
- name: string;
- refs: { nodes: GitRef[] } | null;
- } | null;
-}
-
-interface TreeQueryData {
- repository: {
- tree: GitTreeEntry[] | null;
- } | null;
-}
-
-interface LastCommitsQueryData {
- repository: {
- lastCommits: GitLastCommit[] | null;
- } | null;
-}
-
-interface BlobQueryData {
- repository: {
- blob: GitBlob | null;
- } | null;
-}
-
-import type { CodePageSearch } from "@/routes/$repo/index";
-
-type ViewMode = CodePageSearch["type"];
-
-export function CodePage() {
- const repo = useRepo();
- const navigate = useNavigate({ from: "/$repo/" });
- const { ref: currentRef, path: currentPath, type: viewMode } = useSearch({ from: "/$repo/" });
-
- const {
- data: refsData,
- loading: refsLoading,
- error: refsError,
- } = useQuery<RefsQueryData>(REFS_QUERY, {
- variables: { repo },
- });
- const refs: GitRef[] = refsData?.repository?.refs?.nodes ?? [];
-
- // Set default ref from query result once loaded
- useEffect(() => {
- if (refsLoading || refs.length === 0 || currentRef) return;
- const defaultRef = refs.find((r: GitRef) => r.isDefault) ?? refs[0];
- if (defaultRef) {
- void navigate({
- search: (prev) => ({ ...prev, ref: defaultRef.shortName }),
- replace: true,
- });
- }
- }, [refsLoading, refs.length]); // eslint-disable-line react-hooks/exhaustive-deps
-
- const inTreeMode = viewMode === "tree" && !!currentRef;
- const inBlobMode = viewMode === "blob" && !!currentRef && !!currentPath;
-
- const { data: treeData, loading: treeLoading } = useQuery<TreeQueryData>(TREE_QUERY, {
- variables: { repo, ref: currentRef, path: currentPath || null },
- skip: !inTreeMode,
- });
- const entries: GitTreeEntry[] = treeData?.repository?.tree ?? [];
-
- const entryNames = entries.map((e: GitTreeEntry) => e.name);
- const { data: lastCommitsData } = useQuery<LastCommitsQueryData>(LAST_COMMITS_QUERY, {
- variables: { repo, ref: currentRef, path: currentPath || null, names: entryNames },
- skip: !inTreeMode || entryNames.length === 0,
- });
- const lastCommitsByName = new Map<string, GitLastCommit>(
- (lastCommitsData?.repository?.lastCommits ?? []).map((lc: GitLastCommit) => [lc.name, lc]),
- );
- const entriesWithCommits: TreeEntryWithCommit[] = entries.map((e: GitTreeEntry) => ({
- ...e,
- lastCommit: lastCommitsByName.get(e.name)?.commit ?? undefined,
- }));
-
- const { data: blobData, loading: blobLoading } = useQuery<BlobQueryData>(BLOB_QUERY, {
- variables: { repo, ref: currentRef, path: currentPath },
- skip: !inBlobMode,
- });
- const blob: GitBlob | null = blobData?.repository?.blob ?? null;
-
- const readmeEntry = entries.find(
- (e: GitTreeEntry) => e.type === "BLOB" && /^readme(\.md|\.txt|\.rst)?$/i.test(e.name),
- );
- const readmePath = readmeEntry
- ? currentPath
- ? `${currentPath}/${readmeEntry.name}`
- : readmeEntry.name
- : null;
- const { data: readmeBlobData } = useQuery<BlobQueryData>(BLOB_QUERY, {
- variables: { repo, ref: currentRef, path: readmePath },
- skip: !inTreeMode || !readmePath,
- });
- const readme: string | null = readmeBlobData?.repository?.blob?.text ?? null;
-
- const repoName = refsData?.repository?.name ?? repo ?? "default-repo";
-
- function navigateTo(path: string, type: ViewMode = "tree") {
- void navigate({ search: (prev) => ({ ...prev, path, type }) });
- }
-
- function handleEntryClick(entry: TreeEntryWithCommit) {
- const newPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
- navigateTo(newPath, entry.type === "BLOB" ? "blob" : "tree");
- }
-
- function handleNavigateUp() {
- const parts = currentPath.split("/").filter(Boolean);
- parts.pop();
- navigateTo(parts.join("/"), "tree");
- }
-
- function handleRefSelect(ref: GitRef) {
- void navigate({ search: { ref: ref.shortName, path: "", type: "tree" } });
- }
-
- if (refsError) {
- return (
- <div className="flex flex-col items-center gap-3 py-16 text-center">
- <AlertCircle className="text-muted-foreground size-8" />
- <p className="text-sm font-medium">Code browser unavailable</p>
- <p className="text-muted-foreground max-w-sm text-xs">{refsError.message}</p>
- </div>
- );
- }
-
- return (
- <div className="space-y-4">
- <div className="flex flex-wrap items-center justify-between gap-3">
- {refsLoading ? (
- <Skeleton className="h-5 w-48" />
- ) : (
- <CodeBreadcrumb
- repoName={repoName}
- ref={currentRef}
- path={currentPath}
- onNavigate={(p) => navigateTo(p, "tree")}
- />
- )}
- <div className="flex items-center gap-2">
- {!refsLoading && (
- <ButtonLink
- to="/$repo"
- params={{ repo: repo! }}
- search={{
- ref: currentRef,
- path: currentPath,
- type: viewMode === "commits" ? "tree" : "commits",
- }}
- variant={viewMode === "commits" ? "secondary" : "outline"}
- size="sm"
- >
- <GitCommit className="size-3.5" />
- History
- </ButtonLink>
- )}
- {refsLoading ? (
- <Skeleton className="h-8 w-28" />
- ) : (
- <RefSelector refs={refs} currentRef={currentRef} onSelect={handleRefSelect} />
- )}
- </div>
- </div>
-
- {viewMode === "commits" ? (
- <CommitList ref_={currentRef} path={currentPath || undefined} />
- ) : viewMode === "tree" || !blob ? (
- <>
- <FileTree
- entries={entriesWithCommits}
- path={currentPath}
- loading={treeLoading}
- onNavigate={handleEntryClick}
- onNavigateUp={handleNavigateUp}
- />
- {readme && (
- <div className="rounded-md border">
- <div className="text-muted-foreground border-b px-4 py-2 text-xs font-medium">
- README
- </div>
- <div className="px-6 py-4">
- <Markdown content={readme} />
- </div>
- </div>
- )}
- </>
- ) : (
- <FileViewer blob={blob} loading={blobLoading} />
- )}
- </div>
- );
-}
@@ -1,174 +0,0 @@
-// Commit detail page (/:repo/commit/:hash). Shows commit metadata, full
-// message, parent links, and changed files with lazy diffs.
-
-import { gql } from "@apollo/client";
-import { useQuery } from "@apollo/client/react";
-import { Link, useParams } from "@tanstack/react-router";
-import { format } from "date-fns";
-import { ArrowLeft, GitCommit } from "lucide-react";
-
-import { FileDiffView } from "@/components/code/FileDiffView";
-import { Skeleton } from "@/components/ui/skeleton";
-import { useRepo } from "@/lib/repo";
-
-const COMMIT_QUERY = gql`
- query CommitPageDetail($repo: String, $hash: String!) {
- repository(ref: $repo) {
- commit(hash: $hash) {
- hash
- shortHash
- message
- fullMessage
- authorName
- authorEmail
- date
- parents
- files {
- nodes {
- path
- oldPath
- status
- }
- }
- }
- }
- }
-`;
-
-interface CommitQueryData {
- repository: {
- commit: {
- hash: string;
- shortHash: string;
- message: string;
- fullMessage: string;
- authorName: string;
- authorEmail: string | null;
- date: string;
- parents: string[];
- files: {
- nodes: { path: string; oldPath: string | null; status: string }[];
- } | null;
- } | null;
- } | null;
-}
-
-export function CommitPage() {
- const { hash } = useParams({ strict: false });
- const repo = useRepo();
-
- const { data, loading, error } = useQuery<CommitQueryData>(COMMIT_QUERY, {
- variables: { repo, hash },
- skip: !hash,
- });
-
- if (loading) return <CommitPageSkeleton />;
-
- if (error) {
- return (
- <div className="text-destructive py-16 text-center text-sm">
- Failed to load commit: {error.message}
- </div>
- );
- }
-
- const commit = data?.repository?.commit;
- if (!commit) return null;
-
- const date = new Date(commit.date);
- const files = commit.files?.nodes ?? [];
-
- return (
- <div>
- <button
- onClick={() => {
- window.history.back();
- }}
- className="text-muted-foreground hover:text-foreground mb-6 flex items-center gap-1.5 text-sm"
- >
- <ArrowLeft className="size-3.5" />
- Back
- </button>
-
- <div className="border-border mb-6 rounded-md border p-5">
- <div className="mb-1 flex items-start gap-3">
- <GitCommit className="text-muted-foreground mt-1 size-5 shrink-0" />
- <h1 className="text-lg leading-snug font-semibold">{commit.message}</h1>
- </div>
-
- {commit.fullMessage.includes("\n") && (
- <pre className="text-muted-foreground mt-3 mb-4 ml-8 font-sans text-sm whitespace-pre-wrap">
- {commit.fullMessage.split("\n").slice(1).join("\n").trim()}
- </pre>
- )}
-
- <div className="text-muted-foreground mt-3 ml-8 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
- <span>
- <span className="text-foreground font-medium">{commit.authorName}</span>
- {commit.authorEmail && <span> <{commit.authorEmail}></span>}
- </span>
- <span title={date.toISOString()}>{format(date, "PPP")}</span>
- </div>
-
- <div className="mt-3 ml-8 flex flex-wrap gap-3 text-xs">
- <span className="text-muted-foreground">
- commit <code className="text-foreground font-mono">{commit.hash}</code>
- </span>
- {commit.parents.map((p: string) => (
- <span key={p} className="text-muted-foreground">
- parent{" "}
- <Link
- to="/$repo/commit/$hash"
- params={{ repo: repo!, hash: p }}
- className="text-foreground font-mono hover:underline"
- >
- {p.slice(0, 7)}
- </Link>
- </span>
- ))}
- </div>
- </div>
-
- <div>
- <h2 className="text-muted-foreground mb-3 text-sm font-semibold">
- {files.length} file{files.length !== 1 ? "s" : ""} changed
- </h2>
- <div className="divide-border border-border divide-y overflow-hidden rounded-md border">
- {files.length === 0 && (
- <p className="text-muted-foreground px-4 py-4 text-sm">No file changes.</p>
- )}
- {files.map((file: { path: string; oldPath?: string | null; status: string }) => (
- <FileDiffView
- key={file.path}
- hash={commit.hash}
- path={file.path}
- oldPath={file.oldPath ?? undefined}
- status={file.status}
- />
- ))}
- </div>
- </div>
- </div>
- );
-}
-
-function CommitPageSkeleton() {
- return (
- <div className="space-y-6">
- <Skeleton className="h-4 w-24" />
- <div className="border-border space-y-3 rounded-md border p-5">
- <Skeleton className="h-6 w-3/4" />
- <Skeleton className="h-4 w-1/3" />
- <Skeleton className="h-3 w-1/2" />
- </div>
- <div className="divide-border border-border divide-y rounded-md border">
- {Array.from({ length: 5 }).map((_, i) => (
- <div key={i} className="flex items-center gap-3 px-4 py-2.5">
- <Skeleton className="size-4" />
- <Skeleton className="h-4 flex-1" />
- </div>
- ))}
- </div>
- </div>
- );
-}
@@ -1,34 +0,0 @@
-// Global error boundary page. Rendered by TanStack Router when a route throws.
-
-import { useRouter } from "@tanstack/react-router";
-import { AlertTriangle } from "lucide-react";
-
-import { Button } from "@/components/ui/button";
-import { ButtonLink } from "@/components/ui/button-link";
-
-export function ErrorPage({ error }: { error?: Error }) {
- const router = useRouter();
-
- const message = error?.message ?? "An unexpected error occurred.";
-
- return (
- <div className="flex min-h-screen flex-col items-center justify-center gap-4 text-center">
- <AlertTriangle className="text-muted-foreground size-10" />
- <p className="text-muted-foreground text-sm">{message}</p>
- <div className="flex gap-2">
- <Button
- variant="outline"
- size="sm"
- onClick={() => {
- void router.invalidate();
- }}
- >
- Try again
- </Button>
- <ButtonLink to="/" variant="outline" size="sm">
- Go home
- </ButtonLink>
- </div>
- </div>
- );
-}
@@ -1,131 +0,0 @@
-// 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).
-// The user can either adopt an existing identity β which links it to their
-// OAuth account for future logins β or create a fresh one from their OAuth
-// profile.
-
-import { UserCircle, Plus, AlertCircle } from "lucide-react";
-import { useEffect, useState } from "react";
-
-import { Button } from "@/components/ui/button";
-import { Skeleton } from "@/components/ui/skeleton";
-
-interface IdentityItem {
- repoSlug: string;
- id: string;
- humanId: string;
- displayName: string;
- login?: string;
- avatarUrl?: string;
-}
-
-export function IdentitySelectPage() {
- const [identities, setIdentities] = useState<IdentityItem[] | null>(null);
- const [error, setError] = useState<string | null>(null);
- const [working, setWorking] = useState(false);
-
- useEffect(() => {
- async function loadIdentities() {
- try {
- const res = await fetch("/auth/identities", { credentials: "include" });
- if (!res.ok) throw new Error(`unexpected status ${res.status}`);
- const data: IdentityItem[] = await res.json();
- setIdentities(data);
- } catch (e) {
- setError(String(e));
- }
- }
- void loadIdentities();
- }, []);
-
- async function adopt(identityId: string | null) {
- setWorking(true);
- try {
- const res = await fetch("/auth/adopt", {
- method: "POST",
- credentials: "include",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(identityId ? { identityId } : {}),
- });
- if (!res.ok) throw new Error(`adopt failed: ${res.status}`);
- // Full page reload to reset Apollo cache and auth state cleanly.
- window.location.assign("/");
- } catch (e) {
- setError(String(e));
- setWorking(false);
- }
- }
-
- return (
- <div className="mx-auto max-w-lg py-12">
- <div className="mb-2 flex items-center gap-3">
- <UserCircle className="text-muted-foreground size-6" />
- <h1 className="text-xl font-semibold">Choose your identity</h1>
- </div>
- <p className="text-muted-foreground mb-8 text-sm">
- No git-bug identity was found linked to your account. Select an existing identity to link
- it, or create a new one from your profile.
- </p>
-
- {error && (
- <div className="border-destructive/30 bg-destructive/10 text-destructive mb-4 flex items-center gap-2 rounded-md border px-4 py-3 text-sm">
- <AlertCircle className="size-4 shrink-0" />
- {error}
- </div>
- )}
-
- {!identities && !error && (
- <div className="space-y-2">
- {Array.from({ length: 3 }).map((_, i) => (
- <Skeleton key={i} className="h-14 w-full rounded-md" />
- ))}
- </div>
- )}
-
- <div className="divide-border border-border divide-y rounded-md border">
- {identities?.map((id) => (
- <div key={id.id} className="flex items-center gap-3 px-4 py-3">
- <div className="min-w-0 flex-1">
- <p className="font-medium">{id.displayName}</p>
- <p className="text-muted-foreground text-xs">
- {id.login ? `@${id.login} Β· ` : ""}
- {id.repoSlug} Β· {id.humanId}
- </p>
- </div>
- <Button
- size="sm"
- disabled={working}
- onClick={() => {
- void adopt(id.id);
- }}
- >
- Adopt
- </Button>
- </div>
- ))}
-
- {/* Always offer to create a new identity */}
- <div className="flex items-center gap-3 px-4 py-3">
- <div className="min-w-0 flex-1">
- <p className="font-medium">Create new identity</p>
- <p className="text-muted-foreground text-xs">
- A fresh git-bug identity will be created from your OAuth profile.
- </p>
- </div>
- <Button
- size="sm"
- disabled={working}
- onClick={() => {
- void adopt(null);
- }}
- >
- <Plus className="size-4" />
- Create
- </Button>
- </div>
- </div>
- </div>
- );
-}
@@ -1,118 +0,0 @@
-import { Link, useNavigate } from "@tanstack/react-router";
-import { ArrowLeft } from "lucide-react";
-import { useState } from "react";
-
-import { useBugCreateMutation } from "@/__generated__/graphql";
-import { Markdown } from "@/components/content/Markdown";
-import { Button } from "@/components/ui/button";
-import { ButtonLink } from "@/components/ui/button-link";
-import { Input } from "@/components/ui/input";
-import { Textarea } from "@/components/ui/textarea";
-import { useRepo } from "@/lib/repo";
-
-// New issue form (/:repo/issues/new). Title + body with write/preview tabs.
-export function NewBugPage() {
- const navigate = useNavigate();
- const repo = useRepo();
- const [title, setTitle] = useState("");
- const [message, setMessage] = useState("");
- const [preview, setPreview] = useState(false);
- const [createBug, { loading, error }] = useBugCreateMutation();
-
- async function handleSubmit(e: React.FormEvent) {
- e.preventDefault();
- const result = await createBug({
- variables: { input: { title: title.trim(), message: message.trim() } },
- });
- const humanId = result.data?.bugCreate.bug.humanId;
- if (humanId) {
- void navigate({
- to: "/$repo/issues/$id",
- params: { repo: repo!, id: humanId },
- });
- }
- }
-
- return (
- <div className="mx-auto max-w-3xl">
- <Link
- to="/$repo/issues"
- params={{ repo: repo! }}
- className="text-muted-foreground hover:text-foreground mb-6 flex items-center gap-1.5 text-sm"
- >
- <ArrowLeft className="size-3.5" />
- Back to issues
- </Link>
-
- <h1 className="mb-6 text-xl font-semibold">New issue</h1>
-
- <form
- onSubmit={(e) => {
- void handleSubmit(e);
- }}
- className="space-y-4"
- >
- <Input
- placeholder="Title"
- value={title}
- onChange={(e) => setTitle(e.target.value)}
- disabled={loading}
- autoFocus
- />
-
- <div>
- <div className="mb-2">
- <div className="flex gap-2">
- <button
- type="button"
- onClick={() => setPreview(false)}
- className={`rounded-sm px-2 py-0.5 transition-colors ${
- !preview ? "bg-muted font-medium" : "text-muted-foreground hover:text-foreground"
- }`}
- >
- Write
- </button>
- <button
- type="button"
- onClick={() => setPreview(true)}
- disabled={!message.trim()}
- className={`rounded-sm px-2 py-0.5 transition-colors disabled:opacity-40 ${
- preview ? "bg-muted font-medium" : "text-muted-foreground hover:text-foreground"
- }`}
- >
- Preview
- </button>
- </div>
- </div>
-
- {preview ? (
- <div className="border-input min-h-[200px] rounded-md border px-3 py-2">
- <Markdown content={message} />
- </div>
- ) : (
- <Textarea
- placeholder="Describe the issue in detailβ¦"
- className="min-h-[200px]"
- value={message}
- onChange={(e) => setMessage(e.target.value)}
- disabled={loading}
- />
- )}
- </div>
-
- {error && (
- <p className="text-destructive text-sm">Failed to create issue: {error.message}</p>
- )}
-
- <div className="flex justify-end gap-2">
- <ButtonLink to="/$repo/issues" params={{ repo: repo! }} variant="ghost">
- Cancel
- </ButtonLink>
- <Button type="submit" disabled={!title.trim() || loading}>
- {loading ? "Creatingβ¦" : "Submit new issue"}
- </Button>
- </div>
- </form>
- </div>
- );
-}
@@ -1,79 +0,0 @@
-// Repository picker page (/). Auto-redirects when there is exactly one repo.
-// Shows a list when multiple repos are registered.
-
-import { Link, useNavigate } from "@tanstack/react-router";
-import { GitFork, FolderOpen, AlertCircle } from "lucide-react";
-import { useEffect } from "react";
-
-import { useRepositoriesQuery } from "@/__generated__/graphql";
-import { Skeleton } from "@/components/ui/skeleton";
-
-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) {
- void navigate({
- to: "/$repo",
- params: { repo: repoSlug(data.repositories.nodes[0].name) },
- search: { ref: "", path: "", type: "tree" as const },
- replace: true,
- });
- }
- }, [data, navigate]);
-
- return (
- <div className="mx-auto max-w-lg py-12">
- <div className="mb-8 flex items-center gap-3">
- <GitFork className="text-muted-foreground size-6" />
- <h1 className="text-xl font-semibold">Repositories</h1>
- </div>
-
- {error && (
- <div className="border-destructive/30 bg-destructive/10 text-destructive flex items-center gap-2 rounded-md border px-4 py-3 text-sm">
- <AlertCircle className="size-4 shrink-0" />
- Failed to load repositories: {error.message}
- </div>
- )}
-
- {loading && !data && (
- <div className="space-y-2">
- {Array.from({ length: 3 }).map((_, i) => (
- <Skeleton key={i} className="h-16 w-full rounded-md" />
- ))}
- </div>
- )}
-
- <div className="divide-border border-border divide-y rounded-md border">
- {data?.repositories.nodes.map((repo) => (
- <Link
- key={repoSlug(repo.name)}
- to="/$repo"
- params={{ repo: repoSlug(repo.name) }}
- search={{ ref: "", path: "", type: "tree" as const }}
- className="hover:bg-muted/50 flex items-center gap-3 px-4 py-4 transition-colors"
- >
- <FolderOpen className="text-muted-foreground size-5 shrink-0" />
- <p className="text-foreground font-medium">{repoLabel(repo.name)}</p>
- </Link>
- ))}
-
- {data?.repositories.totalCount === 0 && (
- <p className="text-muted-foreground px-4 py-8 text-center text-sm">
- No repositories found.
- </p>
- )}
- </div>
- </div>
- );
-}
@@ -1,292 +0,0 @@
-// User profile page (/user/:id). Fetches an identity by prefix and shows:
-// - avatar, display name, login, email, humanId, protected badge
-// - open/closed issue toggle with BOTH counts always visible
-// - paginated list of that user's bugs (cursor-stack, same approach as BugListPage)
-//
-// The :id param is treated as a humanId prefix and passed directly to the
-// identity(prefix) and allBugs(query:"author:...") GraphQL arguments.
-
-import { useParams, Link } from "@tanstack/react-router";
-import { formatDistanceToNow } from "date-fns";
-import {
- ArrowLeft,
- MessageSquare,
- CircleDot,
- CircleCheck,
- ShieldCheck,
- ChevronLeft,
- ChevronRight,
-} from "lucide-react";
-import { useState } from "react";
-
-import { Status, useUserProfileQuery } from "@/__generated__/graphql";
-import { LabelBadge } from "@/components/bugs/LabelBadge";
-import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
-import { Button } from "@/components/ui/button";
-import { Skeleton } from "@/components/ui/skeleton";
-import { useRepo } from "@/lib/repo";
-import { cn } from "@/lib/utils";
-
-const PAGE_SIZE = 25;
-
-export function UserProfilePage() {
- const { id } = useParams({ strict: false });
- const repo = useRepo();
- const [statusFilter, setStatusFilter] = useState<"open" | "closed">("open");
-
- // Cursor-stack pagination: cursors[i] is the `after` value to fetch page i.
- // Resetting to [undefined] returns to page 1. Shared pattern with BugListPage.
- const [cursors, setCursors] = useState<(string | undefined)[]>([undefined]);
- const page = cursors.length - 1;
-
- // Three allBugs aliases in one round-trip:
- // openCount / closedCount β always fetched so both badge numbers are visible
- // bugs β paginated list for the selected tab
- const { data, loading, error } = useUserProfileQuery({
- variables: {
- ref: repo,
- prefix: id!,
- openQuery: `author:${id} status:open`,
- closedQuery: `author:${id} status:closed`,
- listQuery: `author:${id} status:${statusFilter}`,
- after: cursors[page],
- },
- });
-
- function switchStatus(next: "open" | "closed") {
- if (next === statusFilter) return;
- setStatusFilter(next);
- setCursors([undefined]); // reset to page 1 on tab change
- }
-
- if (error) {
- return (
- <div className="text-destructive py-16 text-center text-sm">
- Failed to load profile: {error.message}
- </div>
- );
- }
-
- if (loading && !data) return <ProfileSkeleton />;
-
- const identity = data?.repository?.identity;
- if (!identity) {
- return <div className="text-muted-foreground py-16 text-center text-sm">User not found.</div>;
- }
-
- const openCount = data?.repository?.openCount.totalCount ?? 0;
- const closedCount = data?.repository?.closedCount.totalCount ?? 0;
-
- const bugs = data?.repository?.bugs;
- const totalPages = Math.max(1, Math.ceil((bugs?.totalCount ?? 0) / PAGE_SIZE));
- const hasNext = bugs?.pageInfo.hasNextPage ?? false;
- const hasPrev = page > 0;
-
- function goNext() {
- const cursor = bugs?.pageInfo.endCursor;
- if (cursor) setCursors((prev) => [...prev, cursor]);
- }
-
- function goPrev() {
- setCursors((prev) => prev.slice(0, -1));
- }
-
- return (
- <div>
- <Link
- to="/$repo/issues"
- params={{ repo: repo! }}
- className="text-muted-foreground hover:text-foreground mb-6 flex items-center gap-1.5 text-sm"
- >
- <ArrowLeft className="size-3.5" />
- Back to issues
- </Link>
-
- {/* ββ Profile header βββββββββββββββββββββββββββββββββββββββββββββββ */}
- <div className="mb-8 flex items-start gap-5">
- <Avatar className="size-20">
- <AvatarImage src={identity.avatarUrl ?? undefined} alt={identity.displayName} />
- <AvatarFallback className="text-2xl">
- {identity.displayName.slice(0, 2).toUpperCase()}
- </AvatarFallback>
- </Avatar>
-
- <div className="pt-1">
- <div className="flex items-center gap-2">
- <h1 className="text-xl font-semibold">{identity.displayName}</h1>
- {/* isProtected means this identity has been cryptographically signed */}
- {identity.isProtected && (
- <span title="Protected identity">
- <ShieldCheck className="text-muted-foreground size-4" />
- </span>
- )}
- </div>
- <div className="text-muted-foreground mt-1 space-y-0.5 text-sm">
- {identity.login && <p>@{identity.login}</p>}
- {identity.email && <p>{identity.email}</p>}
- <p className="font-mono text-xs">#{identity.humanId}</p>
- </div>
-
- {/* Aggregate stats β always visible, independent of selected tab */}
- <div className="mt-3 flex items-center gap-4 text-sm">
- <span className="text-muted-foreground flex items-center gap-1">
- <CircleDot className="size-3.5 text-green-600 dark:text-green-400" />
- <span className="text-foreground font-medium">{openCount}</span> open
- </span>
- <span className="text-muted-foreground flex items-center gap-1">
- <CircleCheck className="size-3.5 text-purple-600 dark:text-purple-400" />
- <span className="text-foreground font-medium">{closedCount}</span> closed
- </span>
- </div>
- </div>
- </div>
-
- {/* ββ Issue list βββββββββββββββββββββββββββββββββββββββββββββββββββ */}
- <div className="border-border rounded-md border">
- {/* Open / Closed toggle β mirrors BugListPage style */}
- <div className="border-border flex items-center gap-1 border-b px-4 py-2">
- <button
- onClick={() => switchStatus("open")}
- className={cn(
- "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
- statusFilter === "open"
- ? "bg-accent text-accent-foreground"
- : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
- )}
- >
- <CircleDot
- className={cn(
- "size-4",
- statusFilter === "open" && "text-green-600 dark:text-green-400",
- )}
- />
- Open
- <span className="bg-muted ml-0.5 rounded-full px-1.5 py-0.5 text-xs leading-none">
- {openCount}
- </span>
- </button>
-
- <button
- onClick={() => switchStatus("closed")}
- className={cn(
- "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
- statusFilter === "closed"
- ? "bg-accent text-accent-foreground"
- : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
- )}
- >
- <CircleCheck
- className={cn(
- "size-4",
- statusFilter === "closed" && "text-purple-600 dark:text-purple-400",
- )}
- />
- Closed
- <span className="bg-muted ml-0.5 rounded-full px-1.5 py-0.5 text-xs leading-none">
- {closedCount}
- </span>
- </button>
- </div>
-
- {bugs?.nodes.length === 0 && (
- <p className="text-muted-foreground px-4 py-8 text-center text-sm">
- No {statusFilter} issues.
- </p>
- )}
-
- {bugs?.nodes.map((bug) => {
- const isOpen = bug.status === Status.Open;
- const StatusIcon = isOpen ? CircleDot : CircleCheck;
- return (
- <div
- key={bug.id}
- className="border-border flex items-start gap-3 border-b px-4 py-3 last:border-0"
- >
- <StatusIcon
- className={cn(
- "mt-0.5 size-4 shrink-0",
- isOpen
- ? "text-green-600 dark:text-green-400"
- : "text-purple-600 dark:text-purple-400",
- )}
- />
- <div className="min-w-0 flex-1">
- <div className="flex flex-wrap items-baseline gap-2">
- <Link
- to="/$repo/issues/$id"
- params={{ repo: repo!, id: bug.humanId }}
- className="text-foreground hover:text-primary font-medium hover:underline"
- >
- {bug.title}
- </Link>
- {bug.labels.map((label) => (
- <LabelBadge key={label.name} name={label.name} color={label.color} />
- ))}
- </div>
- <p className="text-muted-foreground mt-0.5 text-xs">
- #{bug.humanId} opened{" "}
- {formatDistanceToNow(new Date(bug.createdAt), { addSuffix: true })}
- </p>
- </div>
- {bug.comments.totalCount > 0 && (
- <div className="text-muted-foreground flex shrink-0 items-center gap-1 text-xs">
- <MessageSquare className="size-3.5" />
- {bug.comments.totalCount}
- </div>
- )}
- </div>
- );
- })}
-
- {/* Pagination footer β only shown when there is more than one page */}
- {totalPages > 1 && (
- <div className="border-border flex items-center justify-center gap-2 border-t px-4 py-2">
- <Button
- variant="ghost"
- size="sm"
- onClick={goPrev}
- disabled={!hasPrev || loading}
- className="text-muted-foreground gap-1"
- >
- <ChevronLeft className="size-4" />
- Previous
- </Button>
- <span className="text-muted-foreground text-sm">
- Page {page + 1} of {totalPages}
- </span>
- <Button
- variant="ghost"
- size="sm"
- onClick={goNext}
- disabled={!hasNext || loading}
- className="text-muted-foreground gap-1"
- >
- Next
- <ChevronRight className="size-4" />
- </Button>
- </div>
- )}
- </div>
- </div>
- );
-}
-
-function ProfileSkeleton() {
- return (
- <div className="space-y-6">
- <div className="flex items-start gap-5">
- <Skeleton className="size-20 rounded-full" />
- <div className="space-y-2 pt-1">
- <Skeleton className="h-6 w-40" />
- <Skeleton className="h-4 w-24" />
- <Skeleton className="h-4 w-32" />
- </div>
- </div>
- <div className="space-y-2">
- {Array.from({ length: 4 }).map((_, i) => (
- <Skeleton key={i} className="h-14 w-full" />
- ))}
- </div>
- </div>
- );
-}
@@ -1,7 +1,171 @@
-import { createFileRoute } from "@tanstack/react-router";
+// Commit detail page (/:repo/commit/:hash). Shows commit metadata, full
+// message, parent links, and changed files with lazy diffs.
-import { CommitPage } from "@/pages/CommitPage";
+import { gql } from "@apollo/client";
+import { useReadQuery } from "@apollo/client/react";
+import { createFileRoute, Link } from "@tanstack/react-router";
+import { format } from "date-fns";
+import { ArrowLeft, GitCommit } from "lucide-react";
+
+import { FileDiffView } from "@/components/code/FileDiffView";
+import { Skeleton } from "@/components/ui/skeleton";
+import { preloadQuery } from "@/lib/apollo";
+import { useRepo } from "@/lib/repo";
+
+const COMMIT_QUERY = gql`
+ query CommitPageDetail($repo: String, $hash: String!) {
+ repository(ref: $repo) {
+ commit(hash: $hash) {
+ hash
+ shortHash
+ message
+ fullMessage
+ authorName
+ authorEmail
+ date
+ parents
+ files {
+ nodes {
+ path
+ oldPath
+ status
+ }
+ }
+ }
+ }
+ }
+`;
+
+interface CommitQueryData {
+ repository: {
+ commit: {
+ hash: string;
+ shortHash: string;
+ message: string;
+ fullMessage: string;
+ authorName: string;
+ authorEmail: string | null;
+ date: string;
+ parents: string[];
+ files: {
+ nodes: { path: string; oldPath: string | null; status: string }[];
+ } | null;
+ } | null;
+ } | null;
+}
export const Route = createFileRoute("/$repo/commit/$hash")({
- component: CommitPage,
+ component: RouteComponent,
+ pendingComponent: CommitPageSkeleton,
+ loader: ({ params: { repo, hash } }) => ({
+ commitRef: preloadQuery<CommitQueryData>(COMMIT_QUERY, {
+ variables: { repo: repo === "_" ? null : repo, hash },
+ }),
+ }),
});
+
+function RouteComponent() {
+ const repo = useRepo();
+ const { commitRef } = Route.useLoaderData();
+ const { data } = useReadQuery(commitRef);
+
+ const commit = data?.repository?.commit;
+ if (!commit) return null;
+
+ const date = new Date(commit.date);
+ const files = commit.files?.nodes ?? [];
+
+ return (
+ <div>
+ <button
+ onClick={() => {
+ window.history.back();
+ }}
+ className="text-muted-foreground hover:text-foreground mb-6 flex items-center gap-1.5 text-sm"
+ >
+ <ArrowLeft className="size-3.5" />
+ Back
+ </button>
+
+ <div className="border-border mb-6 rounded-md border p-5">
+ <div className="mb-1 flex items-start gap-3">
+ <GitCommit className="text-muted-foreground mt-1 size-5 shrink-0" />
+ <h1 className="text-lg leading-snug font-semibold">{commit.message}</h1>
+ </div>
+
+ {commit.fullMessage.includes("\n") && (
+ <pre className="text-muted-foreground mt-3 mb-4 ml-8 font-sans text-sm whitespace-pre-wrap">
+ {commit.fullMessage.split("\n").slice(1).join("\n").trim()}
+ </pre>
+ )}
+
+ <div className="text-muted-foreground mt-3 ml-8 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
+ <span>
+ <span className="text-foreground font-medium">{commit.authorName}</span>
+ {commit.authorEmail && <span> <{commit.authorEmail}></span>}
+ </span>
+ <span title={date.toISOString()}>{format(date, "PPP")}</span>
+ </div>
+
+ <div className="mt-3 ml-8 flex flex-wrap gap-3 text-xs">
+ <span className="text-muted-foreground">
+ commit <code className="text-foreground font-mono">{commit.hash}</code>
+ </span>
+ {commit.parents.map((p: string) => (
+ <span key={p} className="text-muted-foreground">
+ parent{" "}
+ <Link
+ to="/$repo/commit/$hash"
+ params={{ repo: repo!, hash: p }}
+ className="text-foreground font-mono hover:underline"
+ >
+ {p.slice(0, 7)}
+ </Link>
+ </span>
+ ))}
+ </div>
+ </div>
+
+ <div>
+ <h2 className="text-muted-foreground mb-3 text-sm font-semibold">
+ {files.length} file{files.length !== 1 ? "s" : ""} changed
+ </h2>
+ <div className="divide-border border-border divide-y overflow-hidden rounded-md border">
+ {files.length === 0 && (
+ <p className="text-muted-foreground px-4 py-4 text-sm">No file changes.</p>
+ )}
+ {files.map((file: { path: string; oldPath?: string | null; status: string }) => (
+ <FileDiffView
+ key={file.path}
+ hash={commit.hash}
+ path={file.path}
+ oldPath={file.oldPath ?? undefined}
+ status={file.status}
+ />
+ ))}
+ </div>
+ </div>
+ </div>
+ );
+}
+
+function CommitPageSkeleton() {
+ return (
+ <div className="space-y-6">
+ <Skeleton className="h-4 w-24" />
+ <div className="border-border space-y-3 rounded-md border p-5">
+ <Skeleton className="h-6 w-3/4" />
+ <Skeleton className="h-4 w-1/3" />
+ <Skeleton className="h-3 w-1/2" />
+ </div>
+ <div className="divide-border border-border divide-y rounded-md border">
+ {Array.from({ length: 5 }).map((_, i) => (
+ <div key={i} className="flex items-center gap-3 px-4 py-2.5">
+ <Skeleton className="size-4" />
+ <Skeleton className="h-4 flex-1" />
+ </div>
+ ))}
+ </div>
+ </div>
+ );
+}
@@ -1,6 +1,109 @@
-import { createFileRoute } from "@tanstack/react-router";
+// Code browser page. Switches between tree view, file viewer, and commit
+// history via ?type= search param. Ref is selected via ?ref=.
-import { CodePage } from "@/pages/CodePage";
+import { gql } from "@apollo/client";
+import { useQuery, useReadQuery } from "@apollo/client/react";
+import { createFileRoute, useNavigate, useSearch } from "@tanstack/react-router";
+import { AlertCircle, GitCommit } from "lucide-react";
+import { useEffect } from "react";
+
+import type { GitRef, GitTreeEntry, GitBlob, GitLastCommit } from "@/__generated__/graphql";
+import { CodeBreadcrumb } from "@/components/code/CodeBreadcrumb";
+import { CommitList } from "@/components/code/CommitList";
+import { FileTree } from "@/components/code/FileTree";
+import type { TreeEntryWithCommit } from "@/components/code/FileTree";
+import { FileViewer } from "@/components/code/FileViewer";
+import { RefSelector } from "@/components/code/RefSelector";
+import { Markdown } from "@/components/content/Markdown";
+import { ButtonLink } from "@/components/ui/button-link";
+import { Skeleton } from "@/components/ui/skeleton";
+import { preloadQuery } from "@/lib/apollo";
+import { useRepo } from "@/lib/repo";
+
+const REFS_QUERY = gql`
+ query CodePageRefs($repo: String) {
+ repository(ref: $repo) {
+ name
+ refs {
+ nodes {
+ name
+ shortName
+ type
+ hash
+ isDefault
+ }
+ }
+ }
+ }
+`;
+
+const TREE_QUERY = gql`
+ query CodePageTree($repo: String, $ref: String!, $path: String) {
+ repository(ref: $repo) {
+ tree(ref: $ref, path: $path) {
+ name
+ type
+ hash
+ }
+ }
+ }
+`;
+
+const LAST_COMMITS_QUERY = gql`
+ query CodePageLastCommits($repo: String, $ref: String!, $path: String, $names: [String!]!) {
+ repository(ref: $repo) {
+ lastCommits(ref: $ref, path: $path, names: $names) {
+ name
+ commit {
+ hash
+ shortHash
+ message
+ date
+ }
+ }
+ }
+ }
+`;
+
+const BLOB_QUERY = gql`
+ query CodePageBlob($repo: String, $ref: String!, $path: String!) {
+ repository(ref: $repo) {
+ blob(ref: $ref, path: $path) {
+ path
+ hash
+ text
+ size
+ isBinary
+ isTruncated
+ }
+ }
+ }
+`;
+
+interface RefsQueryData {
+ repository: {
+ name: string;
+ refs: { nodes: GitRef[] } | null;
+ } | null;
+}
+
+interface TreeQueryData {
+ repository: {
+ tree: GitTreeEntry[] | null;
+ } | null;
+}
+
+interface LastCommitsQueryData {
+ repository: {
+ lastCommits: GitLastCommit[] | null;
+ } | null;
+}
+
+interface BlobQueryData {
+ repository: {
+ blob: GitBlob | null;
+ } | null;
+}
export type CodePageSearch = {
ref: string;
@@ -8,8 +111,11 @@ export type CodePageSearch = {
type: "tree" | "blob" | "commits";
};
+type ViewMode = CodePageSearch["type"];
+
export const Route = createFileRoute("/$repo/")({
- component: CodePage,
+ component: RouteComponent,
+ pendingComponent: CodePageSkeleton,
validateSearch: (search: Record<string, unknown>): CodePageSearch => ({
ref: (search.ref as string) ?? "",
path: (search.path as string) ?? "",
@@ -17,4 +123,179 @@ export const Route = createFileRoute("/$repo/")({
? (search.type as CodePageSearch["type"])
: "tree",
}),
+ loader: ({ params: { repo } }) => ({
+ refsRef: preloadQuery<RefsQueryData>(REFS_QUERY, {
+ variables: { repo: repo === "_" ? null : repo },
+ }),
+ }),
});
+
+function RouteComponent() {
+ const repo = useRepo();
+ const navigate = useNavigate({ from: "/$repo/" });
+ const { ref: currentRef, path: currentPath, type: viewMode } = useSearch({ from: "/$repo/" });
+
+ const { refsRef } = Route.useLoaderData();
+ const { data: refsData, error: refsError } = useReadQuery(refsRef);
+ const refs: GitRef[] = refsData?.repository?.refs?.nodes ?? [];
+
+ // Set default ref once loaded
+ useEffect(() => {
+ if (refs.length === 0 || currentRef) return;
+ const defaultRef = refs.find((r: GitRef) => r.isDefault) ?? refs[0];
+ if (defaultRef) {
+ void navigate({
+ search: (prev) => ({ ...prev, ref: defaultRef.shortName }),
+ replace: true,
+ });
+ }
+ }, [refs.length]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ const inTreeMode = viewMode === "tree" && !!currentRef;
+ const inBlobMode = viewMode === "blob" && !!currentRef && !!currentPath;
+
+ const { data: treeData, loading: treeLoading } = useQuery<TreeQueryData>(TREE_QUERY, {
+ variables: { repo, ref: currentRef, path: currentPath || null },
+ skip: !inTreeMode,
+ });
+ const entries: GitTreeEntry[] = treeData?.repository?.tree ?? [];
+
+ const entryNames = entries.map((e: GitTreeEntry) => e.name);
+ const { data: lastCommitsData } = useQuery<LastCommitsQueryData>(LAST_COMMITS_QUERY, {
+ variables: { repo, ref: currentRef, path: currentPath || null, names: entryNames },
+ skip: !inTreeMode || entryNames.length === 0,
+ });
+ const lastCommitsByName = new Map<string, GitLastCommit>(
+ (lastCommitsData?.repository?.lastCommits ?? []).map((lc: GitLastCommit) => [lc.name, lc]),
+ );
+ const entriesWithCommits: TreeEntryWithCommit[] = entries.map((e: GitTreeEntry) => ({
+ ...e,
+ lastCommit: lastCommitsByName.get(e.name)?.commit ?? undefined,
+ }));
+
+ const { data: blobData, loading: blobLoading } = useQuery<BlobQueryData>(BLOB_QUERY, {
+ variables: { repo, ref: currentRef, path: currentPath },
+ skip: !inBlobMode,
+ });
+ const blob: GitBlob | null = blobData?.repository?.blob ?? null;
+
+ const readmeEntry = entries.find(
+ (e: GitTreeEntry) => e.type === "BLOB" && /^readme(\.md|\.txt|\.rst)?$/i.test(e.name),
+ );
+ const readmePath = readmeEntry
+ ? currentPath
+ ? `${currentPath}/${readmeEntry.name}`
+ : readmeEntry.name
+ : null;
+ const { data: readmeBlobData } = useQuery<BlobQueryData>(BLOB_QUERY, {
+ variables: { repo, ref: currentRef, path: readmePath },
+ skip: !inTreeMode || !readmePath,
+ });
+ const readme: string | null = readmeBlobData?.repository?.blob?.text ?? null;
+
+ const repoName = refsData?.repository?.name ?? repo ?? "default-repo";
+
+ function navigateTo(path: string, type: ViewMode = "tree") {
+ void navigate({ search: (prev) => ({ ...prev, path, type }) });
+ }
+
+ function handleEntryClick(entry: TreeEntryWithCommit) {
+ const newPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
+ navigateTo(newPath, entry.type === "BLOB" ? "blob" : "tree");
+ }
+
+ function handleNavigateUp() {
+ const parts = currentPath.split("/").filter(Boolean);
+ parts.pop();
+ navigateTo(parts.join("/"), "tree");
+ }
+
+ function handleRefSelect(ref: GitRef) {
+ void navigate({ search: { ref: ref.shortName, path: "", type: "tree" } });
+ }
+
+ if (refsError) {
+ return (
+ <div className="flex flex-col items-center gap-3 py-16 text-center">
+ <AlertCircle className="text-muted-foreground size-8" />
+ <p className="text-sm font-medium">Code browser unavailable</p>
+ <p className="text-muted-foreground max-w-sm text-xs">{refsError.message}</p>
+ </div>
+ );
+ }
+
+ return (
+ <div className="space-y-4">
+ <div className="flex flex-wrap items-center justify-between gap-3">
+ <CodeBreadcrumb
+ repoName={repoName}
+ ref={currentRef}
+ path={currentPath}
+ onNavigate={(p) => navigateTo(p, "tree")}
+ />
+ <div className="flex items-center gap-2">
+ <ButtonLink
+ to="/$repo"
+ params={{ repo: repo! }}
+ search={{
+ ref: currentRef,
+ path: currentPath,
+ type: viewMode === "commits" ? "tree" : "commits",
+ }}
+ variant={viewMode === "commits" ? "secondary" : "outline"}
+ size="sm"
+ >
+ <GitCommit className="size-3.5" />
+ History
+ </ButtonLink>
+ <RefSelector refs={refs} currentRef={currentRef} onSelect={handleRefSelect} />
+ </div>
+ </div>
+
+ {viewMode === "commits" ? (
+ <CommitList ref_={currentRef} path={currentPath || undefined} />
+ ) : viewMode === "tree" || !blob ? (
+ <>
+ <FileTree
+ entries={entriesWithCommits}
+ path={currentPath}
+ loading={treeLoading}
+ onNavigate={handleEntryClick}
+ onNavigateUp={handleNavigateUp}
+ />
+ {readme && (
+ <div className="rounded-md border">
+ <div className="text-muted-foreground border-b px-4 py-2 text-xs font-medium">
+ README
+ </div>
+ <div className="px-6 py-4">
+ <Markdown content={readme} />
+ </div>
+ </div>
+ )}
+ </>
+ ) : (
+ <FileViewer blob={blob} loading={blobLoading} />
+ )}
+ </div>
+ );
+}
+
+function CodePageSkeleton() {
+ return (
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <Skeleton className="h-5 w-48" />
+ <Skeleton className="h-8 w-28" />
+ </div>
+ <div className="divide-border border-border divide-y rounded-md border">
+ {Array.from({ length: 8 }).map((_, i) => (
+ <div key={i} className="flex items-center gap-3 px-4 py-2">
+ <Skeleton className="size-4 rounded-sm" />
+ <Skeleton className="h-4 w-32" />
+ </div>
+ ))}
+ </div>
+ </div>
+ );
+}
@@ -1,7 +1,137 @@
-import { createFileRoute } from "@tanstack/react-router";
+import { useReadQuery } from "@apollo/client/react";
+import { createFileRoute, Link } from "@tanstack/react-router";
+import { formatDistanceToNow } from "date-fns";
+import { ArrowLeft } from "lucide-react";
-import { BugDetailPage } from "@/pages/BugDetailPage";
+import { type BugDetailQuery, BugDetailDocument } from "@/__generated__/graphql";
+import { CommentBox } from "@/components/bugs/CommentBox";
+import { LabelEditor } from "@/components/bugs/LabelEditor";
+import { StatusBadge } from "@/components/bugs/StatusBadge";
+import { Timeline } from "@/components/bugs/Timeline";
+import { TitleEditor } from "@/components/bugs/TitleEditor";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Separator } from "@/components/ui/separator";
+import { Skeleton } from "@/components/ui/skeleton";
+import { preloadQuery } from "@/lib/apollo";
+import { useRepo } from "@/lib/repo";
export const Route = createFileRoute("/$repo/issues/$id")({
- component: BugDetailPage,
+ component: RouteComponent,
+ pendingComponent: BugDetailSkeleton,
+ loader: ({ params: { repo, id } }) => ({
+ bugDetailRef: preloadQuery<BugDetailQuery>(BugDetailDocument, {
+ variables: { ref: repo === "_" ? null : repo, prefix: id },
+ }),
+ }),
});
+
+// Issue detail page (/:repo/issues/:id). Shows title, status, timeline of
+// comments and events, and a sidebar with labels and participants.
+function RouteComponent() {
+ const repo = useRepo();
+ const { bugDetailRef } = Route.useLoaderData();
+ const { data } = useReadQuery(bugDetailRef);
+
+ const bug = data?.repository?.bug;
+ if (!bug) {
+ return <div className="text-muted-foreground py-16 text-center text-sm">Issue not found.</div>;
+ }
+
+ return (
+ <div>
+ <Link
+ to="/$repo/issues"
+ params={{ repo: repo! }}
+ className="text-muted-foreground hover:text-foreground mb-4 flex items-center gap-1.5 text-sm"
+ >
+ <ArrowLeft className="size-3.5" />
+ Back to issues
+ </Link>
+
+ {/* Title row β hover reveals edit button when logged in */}
+ <div className="mb-3">
+ <TitleEditor bugPrefix={bug.humanId} title={bug.title} humanId={bug.humanId} ref_={repo} />
+ </div>
+
+ <div className="text-muted-foreground mb-6 flex flex-wrap items-center gap-3 text-sm">
+ <StatusBadge status={bug.status} />
+ <span>
+ <Link
+ to="/$repo/user/$id"
+ params={{ repo: repo!, id: bug.author.humanId }}
+ className="text-foreground font-medium hover:underline"
+ >
+ {bug.author.displayName}
+ </Link>{" "}
+ opened this issue {formatDistanceToNow(new Date(bug.createdAt), { addSuffix: true })}
+ </span>
+ </div>
+
+ <Separator className="mb-6" />
+
+ <div className="flex gap-8">
+ {/* Timeline + comment box */}
+ <div className="min-w-0 flex-1 space-y-4">
+ <Timeline bugPrefix={bug.humanId} items={bug.timeline.nodes} />
+ <CommentBox bugPrefix={bug.humanId} bugStatus={bug.status} ref_={repo} />
+ </div>
+
+ {/* Sidebar */}
+ <aside className="w-56 shrink-0 space-y-6">
+ <LabelEditor bugPrefix={bug.humanId} currentLabels={bug.labels} ref_={repo} />
+
+ <Separator />
+
+ <div>
+ <h3 className="text-muted-foreground mb-2 text-xs font-semibold tracking-wider uppercase">
+ Participants
+ </h3>
+ <div className="flex flex-wrap gap-1.5">
+ {bug.participants.nodes.map((p) => {
+ return (
+ <Link
+ key={p.id}
+ to="/$repo/user/$id"
+ params={{ repo: repo!, id: p.humanId }}
+ title={p.displayName}
+ >
+ <Avatar className="size-6">
+ <AvatarImage src={p.avatarUrl ?? undefined} alt={p.displayName} />
+ <AvatarFallback className="text-[10px]">
+ {p.displayName.slice(0, 2).toUpperCase()}
+ </AvatarFallback>
+ </Avatar>
+ </Link>
+ );
+ })}
+ </div>
+ </div>
+ </aside>
+ </div>
+ </div>
+ );
+}
+
+function BugDetailSkeleton() {
+ return (
+ <div className="space-y-4">
+ <Skeleton className="h-8 w-2/3" />
+ <Skeleton className="h-4 w-1/3" />
+ <Separator />
+ <div className="flex gap-8">
+ <div className="flex-1 space-y-4">
+ {Array.from({ length: 3 }).map((_, i) => (
+ <div key={i} className="border-border rounded-md border p-4">
+ <Skeleton className="mb-3 h-4 w-1/4" />
+ <Skeleton className="h-16 w-full" />
+ </div>
+ ))}
+ </div>
+ <div className="w-56 space-y-3">
+ <Skeleton className="h-4 w-full" />
+ <Skeleton className="h-4 w-3/4" />
+ </div>
+ </div>
+ </div>
+ );
+}
@@ -1,7 +1,393 @@
import { createFileRoute } from "@tanstack/react-router";
+import { CircleDot, CircleCheck, ChevronLeft, ChevronRight } from "lucide-react";
+import { useState, useEffect } from "react";
-import { BugListPage } from "@/pages/BugListPage";
+import { useBugListQuery } from "@/__generated__/graphql";
+import { BugRow } from "@/components/bugs/BugRow";
+import { IssueFilters } from "@/components/bugs/IssueFilters";
+import type { SortValue } from "@/components/bugs/IssueFilters";
+import { QueryInput } from "@/components/bugs/QueryInput";
+import { Button } from "@/components/ui/button";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useRepo } from "@/lib/repo";
+import { cn } from "@/lib/utils";
export const Route = createFileRoute("/$repo/issues/")({
- component: BugListPage,
+ component: RouteComponent,
});
+
+const PAGE_SIZE = 25;
+
+type StatusFilter = "open" | "closed";
+
+// Issue list page (/:repo/issues). Search bar with structured query, open/closed toggle,
+// label+author filter dropdowns, and paginated bug rows.
+function RouteComponent() {
+ const repo = useRepo();
+ const [statusFilter, setStatusFilter] = useState<StatusFilter>("open");
+ const [selectedLabels, setSelectedLabels] = useState<string[]>([]);
+ // humanId β uniquely identifies the selection for the dropdown UI
+ const [selectedAuthorId, setSelectedAuthorId] = useState<string | null>(null);
+ // query value (login/name) β what goes into author:... in the query string
+ const [selectedAuthorQuery, setSelectedAuthorQuery] = useState<string | null>(null);
+ const [freeText, setFreeText] = useState("");
+ const [sort, setSort] = useState<SortValue>("creation-desc");
+ const [draft, setDraft] = useState(() => buildQueryString("open", [], null, "", "creation-desc"));
+
+ // Cursor-stack pagination: cursors[i] is the `after` value to fetch page i.
+ // cursors[0] is always undefined (first page needs no cursor).
+ const [cursors, setCursors] = useState<(string | undefined)[]>([undefined]);
+ const page = cursors.length - 1; // 0-indexed current page
+
+ // Build separate query strings: two for the always-visible counts (open/closed),
+ // one for the paginated list. The count queries share all filters except status.
+ const baseQuery = buildBaseQuery(selectedLabels, selectedAuthorQuery, freeText);
+ const openQuery = `status:open ${baseQuery}`.trim();
+ const closedQuery = `status:closed ${baseQuery}`.trim();
+ const listQuery = buildQueryString(
+ statusFilter,
+ selectedLabels,
+ selectedAuthorQuery,
+ freeText,
+ sort,
+ );
+
+ const { data, loading, error } = useBugListQuery({
+ variables: {
+ ref: repo,
+ openQuery,
+ closedQuery,
+ listQuery,
+ first: PAGE_SIZE,
+ after: cursors[page],
+ },
+ });
+
+ const openCount = data?.repository?.openCount.totalCount ?? 0;
+ const closedCount = data?.repository?.closedCount.totalCount ?? 0;
+ const bugs = data?.repository?.bugs;
+ const totalCount = bugs?.totalCount ?? 0;
+ const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
+ const hasNext = bugs?.pageInfo.hasNextPage ?? false;
+ const hasPrev = page > 0;
+
+ // Reset to page 1 whenever the list query changes.
+ useEffect(() => {
+ setCursors([undefined]);
+ }, [listQuery]);
+
+ // Apply all filters at once, keeping draft in sync with the structured state.
+ function applyFilters(
+ status: StatusFilter,
+ labels: string[],
+ authorId: string | null,
+ authorQuery: string | null,
+ text: string,
+ sortVal: SortValue = sort,
+ ) {
+ setStatusFilter(status);
+ setSelectedLabels(labels);
+ setSelectedAuthorId(authorId);
+ setSelectedAuthorQuery(authorQuery);
+ setFreeText(text);
+ setSort(sortVal);
+ setDraft(buildQueryString(status, labels, authorQuery, text, sortVal));
+ }
+
+ // Parse the draft text box on submit so manual edits update the dropdowns too.
+ // When parsing we don't know the humanId β clear it so the dropdown resets.
+ // Called both from the <form> onSubmit (with event) and from QueryInput's
+ // Enter-key handler (without event), so e is optional.
+ function handleSearch(e?: React.FormEvent) {
+ e?.preventDefault();
+ const p = parseQueryString(draft);
+ applyFilters(p.status, p.labels, null, p.author, p.freeText, p.sort);
+ }
+
+ function goNext() {
+ const endCursor = bugs?.pageInfo.endCursor;
+ if (!endCursor) return;
+ setCursors((prev) => [...prev, endCursor]);
+ }
+
+ function goPrev() {
+ setCursors((prev) => prev.slice(0, -1));
+ }
+
+ return (
+ <div>
+ {/* Search bar */}
+ <form onSubmit={handleSearch} className="mb-4 flex gap-2">
+ <QueryInput
+ value={draft}
+ onChange={setDraft}
+ onSubmit={handleSearch}
+ placeholder="status:open author:β¦ label:β¦"
+ />
+ <Button type="submit">Search</Button>
+ </form>
+
+ {/* List container */}
+ <div className="border-border rounded-md border">
+ {/* Open / Closed toggle + filter dropdowns */}
+ <div className="border-border flex items-center gap-2 overflow-x-auto border-b px-4 py-2">
+ <div className="flex shrink-0 items-center gap-1">
+ <button
+ onClick={() =>
+ applyFilters(
+ "open",
+ selectedLabels,
+ selectedAuthorId,
+ selectedAuthorQuery,
+ freeText,
+ )
+ }
+ className={cn(
+ "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
+ statusFilter === "open"
+ ? "bg-accent text-accent-foreground"
+ : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
+ )}
+ >
+ <CircleDot
+ className={cn(
+ "size-4",
+ statusFilter === "open" && "text-green-600 dark:text-green-400",
+ )}
+ />
+ Open
+ <span className="bg-muted ml-0.5 rounded-full px-1.5 py-0.5 text-xs leading-none tabular-nums">
+ {openCount}
+ </span>
+ </button>
+
+ <button
+ onClick={() =>
+ applyFilters(
+ "closed",
+ selectedLabels,
+ selectedAuthorId,
+ selectedAuthorQuery,
+ freeText,
+ )
+ }
+ className={cn(
+ "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
+ statusFilter === "closed"
+ ? "bg-accent text-accent-foreground"
+ : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
+ )}
+ >
+ <CircleCheck
+ className={cn(
+ "size-4",
+ statusFilter === "closed" && "text-purple-600 dark:text-purple-400",
+ )}
+ />
+ Closed
+ <span className="bg-muted ml-0.5 rounded-full px-1.5 py-0.5 text-xs leading-none tabular-nums">
+ {closedCount}
+ </span>
+ </button>
+ </div>
+
+ <div className="ml-auto">
+ <IssueFilters
+ selectedLabels={selectedLabels}
+ onLabelsChange={(labels) =>
+ applyFilters(statusFilter, labels, selectedAuthorId, selectedAuthorQuery, freeText)
+ }
+ selectedAuthorId={selectedAuthorId}
+ onAuthorChange={(id, qv) =>
+ applyFilters(statusFilter, selectedLabels, id, qv, freeText)
+ }
+ recentAuthorIds={bugs?.nodes?.map((b) => b.author.humanId) ?? []}
+ sort={sort}
+ onSortChange={(s) =>
+ applyFilters(
+ statusFilter,
+ selectedLabels,
+ selectedAuthorId,
+ selectedAuthorQuery,
+ freeText,
+ s,
+ )
+ }
+ />
+ </div>
+ </div>
+
+ {/* Bug rows */}
+ {error && (
+ <p className="text-destructive px-4 py-8 text-center text-sm">
+ Failed to load issues: {error.message}
+ </p>
+ )}
+
+ {loading && !data && <BugListSkeleton />}
+
+ {bugs?.nodes.length === 0 && (
+ <p className="text-muted-foreground px-4 py-8 text-center text-sm">
+ No {statusFilter} issues found.
+ </p>
+ )}
+
+ {bugs?.nodes.map((bug) => (
+ <BugRow
+ key={bug.id}
+ id={bug.id}
+ humanId={bug.humanId}
+ status={bug.status}
+ title={bug.title}
+ labels={bug.labels}
+ author={bug.author}
+ createdAt={bug.createdAt}
+ commentCount={bug.comments.totalCount}
+ repo={repo}
+ onLabelClick={(name) => {
+ if (!selectedLabels.includes(name)) {
+ applyFilters(
+ statusFilter,
+ [...selectedLabels, name],
+ selectedAuthorId,
+ selectedAuthorQuery,
+ freeText,
+ );
+ }
+ }}
+ />
+ ))}
+
+ {totalPages > 1 && (
+ <div className="border-border flex items-center justify-center gap-2 border-t px-4 py-2">
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={goPrev}
+ disabled={!hasPrev || loading}
+ className="text-muted-foreground gap-1"
+ >
+ <ChevronLeft className="size-4" />
+ Previous
+ </Button>
+ <span className="text-muted-foreground text-sm">
+ Page {page + 1} of {totalPages}
+ </span>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={goNext}
+ disabled={!hasNext || loading}
+ className="text-muted-foreground gap-1"
+ >
+ Next
+ <ChevronRight className="size-4" />
+ </Button>
+ </div>
+ )}
+ </div>
+ </div>
+ );
+}
+
+// buildBaseQuery returns the filter parts (labels, author, freeText) without
+// the status prefix, so it can be combined with "status:open" / "status:closed".
+function buildBaseQuery(labels: string[], author: string | null, freeText: string): string {
+ const parts: string[] = [];
+ for (const label of labels) {
+ parts.push(label.includes(" ") ? `label:"${label}"` : `label:${label}`);
+ }
+ if (author) {
+ parts.push(author.includes(" ") ? `author:"${author}"` : `author:${author}`);
+ }
+ if (freeText.trim()) parts.push(freeText.trim());
+ return parts.join(" ");
+}
+
+// Build the structured query string sent to the GraphQL allBugs(query:) argument.
+// Multi-word label/author values are wrapped in quotes so the backend parser
+// treats them as a single token (e.g. label:"my label" vs label:my label).
+function buildQueryString(
+ status: StatusFilter,
+ labels: string[],
+ author: string | null,
+ freeText: string,
+ sort: SortValue = "creation-desc",
+): string {
+ const parts = [`status:${status}`];
+ const base = buildBaseQuery(labels, author, freeText);
+ if (base) parts.push(base);
+ if (sort !== "creation-desc") parts.push(`sort:${sort}`);
+ return parts.join(" ");
+}
+
+// Tokenize a query string, keeping quoted spans (e.g. author:"RenΓ© Descartes")
+// as single tokens. Quotes are preserved in the output so callers can strip them
+// when extracting values.
+function tokenizeQuery(input: string): string[] {
+ const tokens: string[] = [];
+ let current = "";
+ let inQuote = false;
+ for (const ch of input.trim()) {
+ if (ch === '"') {
+ inQuote = !inQuote;
+ current += ch;
+ } else if (ch === " " && !inQuote) {
+ if (current) {
+ tokens.push(current);
+ current = "";
+ }
+ } else current += ch;
+ }
+ if (current) tokens.push(current);
+ return tokens;
+}
+
+// Parse a free-text query string back into structured filter state so that
+// manual edits to the search box are reflected in the dropdown UI on submit.
+// Strips surrounding quotes from values (they're an encoding detail, not part
+// of the value itself). Unknown tokens fall through to freeText.
+const VALID_SORTS = new Set<SortValue>(["creation-desc", "creation-asc", "edit-desc", "edit-asc"]);
+
+function parseQueryString(input: string): {
+ status: StatusFilter;
+ labels: string[];
+ author: string | null;
+ freeText: string;
+ sort: SortValue;
+} {
+ let status: StatusFilter = "open";
+ const labels: string[] = [];
+ let author: string | null = null;
+ let sort: SortValue = "creation-desc";
+ const free: string[] = [];
+
+ for (const token of tokenizeQuery(input)) {
+ if (token === "status:open") status = "open";
+ else if (token === "status:closed") status = "closed";
+ else if (token.startsWith("label:")) labels.push(token.slice(6));
+ else if (token.startsWith("author:")) author = token.slice(7).replace(/^"|"$/g, "");
+ else if (token.startsWith("sort:")) {
+ const v = token.slice(5) as SortValue;
+ if (VALID_SORTS.has(v)) sort = v;
+ } else free.push(token);
+ }
+
+ return { status, labels, author, freeText: free.join(" "), sort };
+}
+
+function BugListSkeleton() {
+ return (
+ <div className="divide-border divide-y">
+ {Array.from({ length: 8 }).map((_, i) => (
+ <div key={i} className="flex items-start gap-3 px-4 py-3">
+ <Skeleton className="mt-0.5 size-4 rounded-full" />
+ <div className="flex-1 space-y-2">
+ <Skeleton className="h-4 w-2/3" />
+ <Skeleton className="h-3 w-1/3" />
+ </div>
+ </div>
+ ))}
+ </div>
+ );
+}
@@ -1,7 +1,122 @@
-import { createFileRoute } from "@tanstack/react-router";
+import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
+import { ArrowLeft } from "lucide-react";
+import { useState } from "react";
-import { NewBugPage } from "@/pages/NewBugPage";
+import { useBugCreateMutation } from "@/__generated__/graphql";
+import { Markdown } from "@/components/content/Markdown";
+import { Button } from "@/components/ui/button";
+import { ButtonLink } from "@/components/ui/button-link";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { useRepo } from "@/lib/repo";
export const Route = createFileRoute("/$repo/issues/new")({
- component: NewBugPage,
+ component: RouteComponent,
});
+
+// New issue form (/:repo/issues/new). Title + body with write/preview tabs.
+function RouteComponent() {
+ const navigate = useNavigate();
+ const repo = useRepo();
+ const [title, setTitle] = useState("");
+ const [message, setMessage] = useState("");
+ const [preview, setPreview] = useState(false);
+ const [createBug, { loading, error }] = useBugCreateMutation();
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ const result = await createBug({
+ variables: { input: { title: title.trim(), message: message.trim() } },
+ });
+ const humanId = result.data?.bugCreate.bug.humanId;
+ if (humanId) {
+ void navigate({
+ to: "/$repo/issues/$id",
+ params: { repo: repo!, id: humanId },
+ });
+ }
+ }
+
+ return (
+ <div className="mx-auto max-w-3xl">
+ <Link
+ to="/$repo/issues"
+ params={{ repo: repo! }}
+ className="text-muted-foreground hover:text-foreground mb-6 flex items-center gap-1.5 text-sm"
+ >
+ <ArrowLeft className="size-3.5" />
+ Back to issues
+ </Link>
+
+ <h1 className="mb-6 text-xl font-semibold">New issue</h1>
+
+ <form
+ onSubmit={(e) => {
+ void handleSubmit(e);
+ }}
+ className="space-y-4"
+ >
+ <Input
+ placeholder="Title"
+ value={title}
+ onChange={(e) => setTitle(e.target.value)}
+ disabled={loading}
+ autoFocus
+ />
+
+ <div>
+ <div className="mb-2">
+ <div className="flex gap-2">
+ <button
+ type="button"
+ onClick={() => setPreview(false)}
+ className={`rounded-sm px-2 py-0.5 transition-colors ${
+ !preview ? "bg-muted font-medium" : "text-muted-foreground hover:text-foreground"
+ }`}
+ >
+ Write
+ </button>
+ <button
+ type="button"
+ onClick={() => setPreview(true)}
+ disabled={!message.trim()}
+ className={`rounded-sm px-2 py-0.5 transition-colors disabled:opacity-40 ${
+ preview ? "bg-muted font-medium" : "text-muted-foreground hover:text-foreground"
+ }`}
+ >
+ Preview
+ </button>
+ </div>
+ </div>
+
+ {preview ? (
+ <div className="border-input min-h-[200px] rounded-md border px-3 py-2">
+ <Markdown content={message} />
+ </div>
+ ) : (
+ <Textarea
+ placeholder="Describe the issue in detailβ¦"
+ className="min-h-[200px]"
+ value={message}
+ onChange={(e) => setMessage(e.target.value)}
+ disabled={loading}
+ />
+ )}
+ </div>
+
+ {error && (
+ <p className="text-destructive text-sm">Failed to create issue: {error.message}</p>
+ )}
+
+ <div className="flex justify-end gap-2">
+ <ButtonLink to="/$repo/issues" params={{ repo: repo! }} variant="ghost">
+ Cancel
+ </ButtonLink>
+ <Button type="submit" disabled={!title.trim() || loading}>
+ {loading ? "Creatingβ¦" : "Submit new issue"}
+ </Button>
+ </div>
+ </form>
+ </div>
+ );
+}
@@ -1,7 +1,296 @@
-import { createFileRoute } from "@tanstack/react-router";
+// User profile page (/user/:id). Fetches an identity by prefix and shows:
+// - avatar, display name, login, email, humanId, protected badge
+// - open/closed issue toggle with BOTH counts always visible
+// - paginated list of that user's bugs (cursor-stack, same approach as BugListPage)
+//
+// The :id param is treated as a humanId prefix and passed directly to the
+// identity(prefix) and allBugs(query:"author:...") GraphQL arguments.
-import { UserProfilePage } from "@/pages/UserProfilePage";
+import { createFileRoute, useParams, Link } from "@tanstack/react-router";
+import { formatDistanceToNow } from "date-fns";
+import {
+ ArrowLeft,
+ MessageSquare,
+ CircleDot,
+ CircleCheck,
+ ShieldCheck,
+ ChevronLeft,
+ ChevronRight,
+} from "lucide-react";
+import { useState } from "react";
+
+import { Status, useUserProfileQuery } from "@/__generated__/graphql";
+import { LabelBadge } from "@/components/bugs/LabelBadge";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Button } from "@/components/ui/button";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useRepo } from "@/lib/repo";
+import { cn } from "@/lib/utils";
export const Route = createFileRoute("/$repo/user/$id")({
- component: UserProfilePage,
+ component: RouteComponent,
});
+
+const PAGE_SIZE = 25;
+
+function RouteComponent() {
+ const { id } = useParams({ strict: false });
+ const repo = useRepo();
+ const [statusFilter, setStatusFilter] = useState<"open" | "closed">("open");
+
+ // Cursor-stack pagination: cursors[i] is the `after` value to fetch page i.
+ // Resetting to [undefined] returns to page 1. Shared pattern with BugListPage.
+ const [cursors, setCursors] = useState<(string | undefined)[]>([undefined]);
+ const page = cursors.length - 1;
+
+ // Three allBugs aliases in one round-trip:
+ // openCount / closedCount β always fetched so both badge numbers are visible
+ // bugs β paginated list for the selected tab
+ const { data, loading, error } = useUserProfileQuery({
+ variables: {
+ ref: repo,
+ prefix: id!,
+ openQuery: `author:${id} status:open`,
+ closedQuery: `author:${id} status:closed`,
+ listQuery: `author:${id} status:${statusFilter}`,
+ after: cursors[page],
+ },
+ });
+
+ function switchStatus(next: "open" | "closed") {
+ if (next === statusFilter) return;
+ setStatusFilter(next);
+ setCursors([undefined]); // reset to page 1 on tab change
+ }
+
+ if (error) {
+ return (
+ <div className="text-destructive py-16 text-center text-sm">
+ Failed to load profile: {error.message}
+ </div>
+ );
+ }
+
+ if (loading && !data) return <ProfileSkeleton />;
+
+ const identity = data?.repository?.identity;
+ if (!identity) {
+ return <div className="text-muted-foreground py-16 text-center text-sm">User not found.</div>;
+ }
+
+ const openCount = data?.repository?.openCount.totalCount ?? 0;
+ const closedCount = data?.repository?.closedCount.totalCount ?? 0;
+
+ const bugs = data?.repository?.bugs;
+ const totalPages = Math.max(1, Math.ceil((bugs?.totalCount ?? 0) / PAGE_SIZE));
+ const hasNext = bugs?.pageInfo.hasNextPage ?? false;
+ const hasPrev = page > 0;
+
+ function goNext() {
+ const cursor = bugs?.pageInfo.endCursor;
+ if (cursor) setCursors((prev) => [...prev, cursor]);
+ }
+
+ function goPrev() {
+ setCursors((prev) => prev.slice(0, -1));
+ }
+
+ return (
+ <div>
+ <Link
+ to="/$repo/issues"
+ params={{ repo: repo! }}
+ className="text-muted-foreground hover:text-foreground mb-6 flex items-center gap-1.5 text-sm"
+ >
+ <ArrowLeft className="size-3.5" />
+ Back to issues
+ </Link>
+
+ {/* ββ Profile header βββββββββββββββββββββββββββββββββββββββββββββββ */}
+ <div className="mb-8 flex items-start gap-5">
+ <Avatar className="size-20">
+ <AvatarImage src={identity.avatarUrl ?? undefined} alt={identity.displayName} />
+ <AvatarFallback className="text-2xl">
+ {identity.displayName.slice(0, 2).toUpperCase()}
+ </AvatarFallback>
+ </Avatar>
+
+ <div className="pt-1">
+ <div className="flex items-center gap-2">
+ <h1 className="text-xl font-semibold">{identity.displayName}</h1>
+ {/* isProtected means this identity has been cryptographically signed */}
+ {identity.isProtected && (
+ <span title="Protected identity">
+ <ShieldCheck className="text-muted-foreground size-4" />
+ </span>
+ )}
+ </div>
+ <div className="text-muted-foreground mt-1 space-y-0.5 text-sm">
+ {identity.login && <p>@{identity.login}</p>}
+ {identity.email && <p>{identity.email}</p>}
+ <p className="font-mono text-xs">#{identity.humanId}</p>
+ </div>
+
+ {/* Aggregate stats β always visible, independent of selected tab */}
+ <div className="mt-3 flex items-center gap-4 text-sm">
+ <span className="text-muted-foreground flex items-center gap-1">
+ <CircleDot className="size-3.5 text-green-600 dark:text-green-400" />
+ <span className="text-foreground font-medium">{openCount}</span> open
+ </span>
+ <span className="text-muted-foreground flex items-center gap-1">
+ <CircleCheck className="size-3.5 text-purple-600 dark:text-purple-400" />
+ <span className="text-foreground font-medium">{closedCount}</span> closed
+ </span>
+ </div>
+ </div>
+ </div>
+
+ {/* ββ Issue list βββββββββββββββββββββββββββββββββββββββββββββββββββ */}
+ <div className="border-border rounded-md border">
+ {/* Open / Closed toggle β mirrors BugListPage style */}
+ <div className="border-border flex items-center gap-1 border-b px-4 py-2">
+ <button
+ onClick={() => switchStatus("open")}
+ className={cn(
+ "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
+ statusFilter === "open"
+ ? "bg-accent text-accent-foreground"
+ : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
+ )}
+ >
+ <CircleDot
+ className={cn(
+ "size-4",
+ statusFilter === "open" && "text-green-600 dark:text-green-400",
+ )}
+ />
+ Open
+ <span className="bg-muted ml-0.5 rounded-full px-1.5 py-0.5 text-xs leading-none">
+ {openCount}
+ </span>
+ </button>
+
+ <button
+ onClick={() => switchStatus("closed")}
+ className={cn(
+ "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
+ statusFilter === "closed"
+ ? "bg-accent text-accent-foreground"
+ : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
+ )}
+ >
+ <CircleCheck
+ className={cn(
+ "size-4",
+ statusFilter === "closed" && "text-purple-600 dark:text-purple-400",
+ )}
+ />
+ Closed
+ <span className="bg-muted ml-0.5 rounded-full px-1.5 py-0.5 text-xs leading-none">
+ {closedCount}
+ </span>
+ </button>
+ </div>
+
+ {bugs?.nodes.length === 0 && (
+ <p className="text-muted-foreground px-4 py-8 text-center text-sm">
+ No {statusFilter} issues.
+ </p>
+ )}
+
+ {bugs?.nodes.map((bug) => {
+ const isOpen = bug.status === Status.Open;
+ const StatusIcon = isOpen ? CircleDot : CircleCheck;
+ return (
+ <div
+ key={bug.id}
+ className="border-border flex items-start gap-3 border-b px-4 py-3 last:border-0"
+ >
+ <StatusIcon
+ className={cn(
+ "mt-0.5 size-4 shrink-0",
+ isOpen
+ ? "text-green-600 dark:text-green-400"
+ : "text-purple-600 dark:text-purple-400",
+ )}
+ />
+ <div className="min-w-0 flex-1">
+ <div className="flex flex-wrap items-baseline gap-2">
+ <Link
+ to="/$repo/issues/$id"
+ params={{ repo: repo!, id: bug.humanId }}
+ className="text-foreground hover:text-primary font-medium hover:underline"
+ >
+ {bug.title}
+ </Link>
+ {bug.labels.map((label) => (
+ <LabelBadge key={label.name} name={label.name} color={label.color} />
+ ))}
+ </div>
+ <p className="text-muted-foreground mt-0.5 text-xs">
+ #{bug.humanId} opened{" "}
+ {formatDistanceToNow(new Date(bug.createdAt), { addSuffix: true })}
+ </p>
+ </div>
+ {bug.comments.totalCount > 0 && (
+ <div className="text-muted-foreground flex shrink-0 items-center gap-1 text-xs">
+ <MessageSquare className="size-3.5" />
+ {bug.comments.totalCount}
+ </div>
+ )}
+ </div>
+ );
+ })}
+
+ {/* Pagination footer β only shown when there is more than one page */}
+ {totalPages > 1 && (
+ <div className="border-border flex items-center justify-center gap-2 border-t px-4 py-2">
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={goPrev}
+ disabled={!hasPrev || loading}
+ className="text-muted-foreground gap-1"
+ >
+ <ChevronLeft className="size-4" />
+ Previous
+ </Button>
+ <span className="text-muted-foreground text-sm">
+ Page {page + 1} of {totalPages}
+ </span>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={goNext}
+ disabled={!hasNext || loading}
+ className="text-muted-foreground gap-1"
+ >
+ Next
+ <ChevronRight className="size-4" />
+ </Button>
+ </div>
+ )}
+ </div>
+ </div>
+ );
+}
+
+function ProfileSkeleton() {
+ return (
+ <div className="space-y-6">
+ <div className="flex items-start gap-5">
+ <Skeleton className="size-20 rounded-full" />
+ <div className="space-y-2 pt-1">
+ <Skeleton className="h-6 w-40" />
+ <Skeleton className="h-4 w-24" />
+ <Skeleton className="h-4 w-32" />
+ </div>
+ </div>
+ <div className="space-y-2">
+ {Array.from({ length: 4 }).map((_, i) => (
+ <Skeleton key={i} className="h-14 w-full" />
+ ))}
+ </div>
+ </div>
+ );
+}
@@ -1,9 +1,38 @@
-import { createRootRoute } from "@tanstack/react-router";
+import { createRootRoute, useRouter } from "@tanstack/react-router";
+import { AlertTriangle } from "lucide-react";
import { Shell } from "@/components/layout/Shell";
-import { ErrorPage } from "@/pages/ErrorPage";
+import { Button } from "@/components/ui/button";
+import { ButtonLink } from "@/components/ui/button-link";
export const Route = createRootRoute({
component: Shell,
errorComponent: ErrorPage,
});
+
+function ErrorPage({ error }: { error?: Error }) {
+ const router = useRouter();
+
+ const message = error?.message ?? "An unexpected error occurred.";
+
+ return (
+ <div className="flex min-h-screen flex-col items-center justify-center gap-4 text-center">
+ <AlertTriangle className="text-muted-foreground size-10" />
+ <p className="text-muted-foreground text-sm">{message}</p>
+ <div className="flex gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ void router.invalidate();
+ }}
+ >
+ Try again
+ </Button>
+ <ButtonLink to="/" variant="outline" size="sm">
+ Go home
+ </ButtonLink>
+ </div>
+ </div>
+ );
+}
@@ -1,7 +1,136 @@
+// 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).
+// The user can either adopt an existing identity β which links it to their
+// OAuth account for future logins β or create a fresh one from their OAuth
+// profile.
+
import { createFileRoute } from "@tanstack/react-router";
+import { UserCircle, Plus, AlertCircle } from "lucide-react";
+import { useEffect, useState } from "react";
-import { IdentitySelectPage } from "@/pages/IdentitySelectPage";
+import { Button } from "@/components/ui/button";
+import { Skeleton } from "@/components/ui/skeleton";
export const Route = createFileRoute("/auth/select-identity")({
- component: IdentitySelectPage,
+ component: RouteComponent,
});
+
+interface IdentityItem {
+ repoSlug: string;
+ id: string;
+ humanId: string;
+ displayName: string;
+ login?: string;
+ avatarUrl?: string;
+}
+
+function RouteComponent() {
+ const [identities, setIdentities] = useState<IdentityItem[] | null>(null);
+ const [error, setError] = useState<string | null>(null);
+ const [working, setWorking] = useState(false);
+
+ useEffect(() => {
+ async function loadIdentities() {
+ try {
+ const res = await fetch("/auth/identities", { credentials: "include" });
+ if (!res.ok) throw new Error(`unexpected status ${res.status}`);
+ const data: IdentityItem[] = await res.json();
+ setIdentities(data);
+ } catch (e) {
+ setError(String(e));
+ }
+ }
+ void loadIdentities();
+ }, []);
+
+ async function adopt(identityId: string | null) {
+ setWorking(true);
+ try {
+ const res = await fetch("/auth/adopt", {
+ method: "POST",
+ credentials: "include",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(identityId ? { identityId } : {}),
+ });
+ if (!res.ok) throw new Error(`adopt failed: ${res.status}`);
+ // Full page reload to reset Apollo cache and auth state cleanly.
+ window.location.assign("/");
+ } catch (e) {
+ setError(String(e));
+ setWorking(false);
+ }
+ }
+
+ return (
+ <div className="mx-auto max-w-lg py-12">
+ <div className="mb-2 flex items-center gap-3">
+ <UserCircle className="text-muted-foreground size-6" />
+ <h1 className="text-xl font-semibold">Choose your identity</h1>
+ </div>
+ <p className="text-muted-foreground mb-8 text-sm">
+ No git-bug identity was found linked to your account. Select an existing identity to link
+ it, or create a new one from your profile.
+ </p>
+
+ {error && (
+ <div className="border-destructive/30 bg-destructive/10 text-destructive mb-4 flex items-center gap-2 rounded-md border px-4 py-3 text-sm">
+ <AlertCircle className="size-4 shrink-0" />
+ {error}
+ </div>
+ )}
+
+ {!identities && !error && (
+ <div className="space-y-2">
+ {Array.from({ length: 3 }).map((_, i) => (
+ <Skeleton key={i} className="h-14 w-full rounded-md" />
+ ))}
+ </div>
+ )}
+
+ <div className="divide-border border-border divide-y rounded-md border">
+ {identities?.map((id) => (
+ <div key={id.id} className="flex items-center gap-3 px-4 py-3">
+ <div className="min-w-0 flex-1">
+ <p className="font-medium">{id.displayName}</p>
+ <p className="text-muted-foreground text-xs">
+ {id.login ? `@${id.login} Β· ` : ""}
+ {id.repoSlug} Β· {id.humanId}
+ </p>
+ </div>
+ <Button
+ size="sm"
+ disabled={working}
+ onClick={() => {
+ void adopt(id.id);
+ }}
+ >
+ Adopt
+ </Button>
+ </div>
+ ))}
+
+ {/* Always offer to create a new identity */}
+ <div className="flex items-center gap-3 px-4 py-3">
+ <div className="min-w-0 flex-1">
+ <p className="font-medium">Create new identity</p>
+ <p className="text-muted-foreground text-xs">
+ A fresh git-bug identity will be created from your OAuth profile.
+ </p>
+ </div>
+ <Button
+ size="sm"
+ disabled={working}
+ onClick={() => {
+ void adopt(null);
+ }}
+ >
+ <Plus className="size-4" />
+ Create
+ </Button>
+ </div>
+ </div>
+ </div>
+ );
+}
@@ -1,7 +1,80 @@
-import { createFileRoute } from "@tanstack/react-router";
+// Repository picker page (/). Auto-redirects when there is exactly one repo.
+// Shows a list when multiple repos are registered.
-import { RepoPickerPage } from "@/pages/RepoPickerPage";
+import { useReadQuery } from "@apollo/client/react";
+import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
+import { GitFork, FolderOpen, AlertCircle } from "lucide-react";
+import { useEffect } from "react";
+
+import { type RepositoriesQuery, RepositoriesDocument } from "@/__generated__/graphql";
+import { preloadQuery } from "@/lib/apollo";
export const Route = createFileRoute("/")({
- component: RepoPickerPage,
+ component: RouteComponent,
+ loader: () => ({
+ repositoriesRef: preloadQuery<RepositoriesQuery>(RepositoriesDocument),
+ }),
});
+
+function repoSlug(name: string | null | undefined): string {
+ return name ?? "_";
+}
+
+function repoLabel(name: string | null | undefined): string {
+ return name ?? "default";
+}
+
+function RouteComponent() {
+ const { repositoriesRef } = Route.useLoaderData();
+ const { data, error } = useReadQuery(repositoriesRef);
+ const navigate = useNavigate();
+
+ // Auto-redirect when there is exactly one repo β no need to pick.
+ useEffect(() => {
+ if (data?.repositories.nodes.length === 1) {
+ void navigate({
+ to: "/$repo",
+ params: { repo: repoSlug(data.repositories.nodes[0].name) },
+ search: { ref: "", path: "", type: "tree" as const },
+ replace: true,
+ });
+ }
+ }, [data, navigate]);
+
+ return (
+ <div className="mx-auto max-w-lg py-12">
+ <div className="mb-8 flex items-center gap-3">
+ <GitFork className="text-muted-foreground size-6" />
+ <h1 className="text-xl font-semibold">Repositories</h1>
+ </div>
+
+ {error && (
+ <div className="border-destructive/30 bg-destructive/10 text-destructive flex items-center gap-2 rounded-md border px-4 py-3 text-sm">
+ <AlertCircle className="size-4 shrink-0" />
+ Failed to load repositories: {error.message}
+ </div>
+ )}
+
+ <div className="divide-border border-border divide-y rounded-md border">
+ {data?.repositories.nodes.map((repo) => (
+ <Link
+ key={repoSlug(repo.name)}
+ to="/$repo"
+ params={{ repo: repoSlug(repo.name) }}
+ search={{ ref: "", path: "", type: "tree" as const }}
+ className="hover:bg-muted/50 flex items-center gap-3 px-4 py-4 transition-colors"
+ >
+ <FolderOpen className="text-muted-foreground size-5 shrink-0" />
+ <p className="text-foreground font-medium">{repoLabel(repo.name)}</p>
+ </Link>
+ ))}
+
+ {data?.repositories.totalCount === 0 && (
+ <p className="text-muted-foreground px-4 py-8 text-center text-sm">
+ No repositories found.
+ </p>
+ )}
+ </div>
+ </div>
+ );
+}