Detailed changes
@@ -22,7 +22,7 @@ Node 22 is required. If you use asdf, `.tool-versions` pins the right version au
## Routes
| Path | Page |
-|-------------------------|----------------------------------------------------------|
+| ----------------------- | -------------------------------------------------------- |
| `/` | Repo picker β auto-redirects when there is only one repo |
| `/_` | Default repo (issues + code browser) |
| `/_/issues` | Issue list with search and label filtering |
@@ -62,7 +62,7 @@ pnpm codegen
**Code browser** uses REST endpoints at `/api/repos/{owner}/{repo}/git/*` implemented in `api/http/git_browse_handler.go`. `_` is used for both owner and repo (local single-user setup). The TypeScript client is `src/lib/gitApi.ts`.
| Endpoint | Description |
-|---------------------------------------|-----------------------------------------|
+| ------------------------------------- | --------------------------------------- |
| `GET /git/refs` | List branches and tags |
| `GET /git/trees/{ref}?path=` | Directory listing with last-commit info |
| `GET /git/blobs/{ref}?path=` | File content |
@@ -1,28 +1,24 @@
-import type { CodegenConfig } from '@graphql-codegen/cli'
+import type { CodegenConfig } from "@graphql-codegen/cli";
const config: CodegenConfig = {
- schema: '../api/graphql/schema/*.graphql',
- documents: 'src/graphql/**/*.graphql',
+ schema: "../api/graphql/schema/*.graphql",
+ documents: "src/graphql/**/*.graphql",
generates: {
- 'src/__generated__/graphql.ts': {
- plugins: [
- 'typescript',
- 'typescript-operations',
- 'typescript-react-apollo',
- ],
+ "src/__generated__/graphql.ts": {
+ plugins: ["typescript", "typescript-operations", "typescript-react-apollo"],
config: {
withHooks: true,
withComponent: false,
withHOC: false,
scalars: {
- Time: 'string',
- Hash: 'string',
- CombinedId: 'string',
- Color: '{ R: number; G: number; B: number }',
+ Time: "string",
+ Hash: "string",
+ CombinedId: "string",
+ Color: "{ R: number; G: number; B: number }",
},
},
},
},
-}
+};
-export default config
+export default config;
@@ -3,4 +3,4 @@ export default {
tailwindcss: {},
autoprefixer: {},
},
-}
+};
@@ -1,15 +1,16 @@
-import { createBrowserRouter, RouterProvider } from 'react-router-dom'
-import { Shell } from '@/components/layout/Shell'
-import { RepoShell } from '@/lib/repo'
-import { RepoPickerPage } from '@/pages/RepoPickerPage'
-import { BugListPage } from '@/pages/BugListPage'
-import { BugDetailPage } from '@/pages/BugDetailPage'
-import { NewBugPage } from '@/pages/NewBugPage'
-import { CodePage } from '@/pages/CodePage'
-import { UserProfilePage } from '@/pages/UserProfilePage'
-import { CommitPage } from '@/pages/CommitPage'
-import { IdentitySelectPage } from '@/pages/IdentitySelectPage'
-import { ErrorPage } from '@/pages/ErrorPage'
+import { createBrowserRouter, RouterProvider } from "react-router-dom";
+
+import { Shell } from "@/components/layout/Shell";
+import { RepoShell } from "@/lib/repo";
+import { BugDetailPage } from "@/pages/BugDetailPage";
+import { BugListPage } from "@/pages/BugListPage";
+import { CodePage } from "@/pages/CodePage";
+import { CommitPage } from "@/pages/CommitPage";
+import { ErrorPage } from "@/pages/ErrorPage";
+import { IdentitySelectPage } from "@/pages/IdentitySelectPage";
+import { NewBugPage } from "@/pages/NewBugPage";
+import { RepoPickerPage } from "@/pages/RepoPickerPage";
+import { UserProfilePage } from "@/pages/UserProfilePage";
// Route structure:
// / β repo picker (or redirect if single repo)
@@ -18,28 +19,28 @@ import { ErrorPage } from '@/pages/ErrorPage'
// /auth/select-identity β OAuth identity adoption (first-time login)
const router = createBrowserRouter([
{
- path: '/',
+ path: "/",
element: <Shell />,
errorElement: <ErrorPage />,
children: [
{ index: true, element: <RepoPickerPage /> },
- { path: 'auth/select-identity', element: <IdentitySelectPage /> },
+ { path: "auth/select-identity", element: <IdentitySelectPage /> },
{
- path: ':repo',
+ path: ":repo",
element: <RepoShell />,
children: [
{ index: true, element: <CodePage /> },
- { path: 'issues', element: <BugListPage /> },
- { path: 'issues/new', element: <NewBugPage /> },
- { path: 'issues/:id', element: <BugDetailPage /> },
- { path: 'user/:id', element: <UserProfilePage /> },
- { path: 'commit/:hash', element: <CommitPage /> },
+ { path: "issues", element: <BugListPage /> },
+ { path: "issues/new", element: <NewBugPage /> },
+ { path: "issues/:id", element: <BugDetailPage /> },
+ { path: "user/:id", element: <UserProfilePage /> },
+ { path: "commit/:hash", element: <CommitPage /> },
],
},
],
},
-])
+]);
export function App() {
- return <RouterProvider router={router} />
+ return <RouterProvider router={router} />;
}
@@ -1,21 +1,23 @@
-import { Link } from 'react-router-dom'
-import { MessageSquare, CircleDot, CircleCheck } from 'lucide-react'
-import { formatDistanceToNow } from 'date-fns'
-import { LabelBadge } from './LabelBadge'
-import { Status } from '@/__generated__/graphql'
+import { formatDistanceToNow } from "date-fns";
+import { MessageSquare, CircleDot, CircleCheck } from "lucide-react";
+import { Link } from "react-router-dom";
+
+import { Status } from "@/__generated__/graphql";
+
+import { LabelBadge } from "./LabelBadge";
interface BugRowProps {
- id: string
- humanId: string
- status: Status
- title: string
- labels: Array<{ name: string; color: { R: number; G: number; B: number } }>
- author: { humanId: string; displayName: string; avatarUrl?: string | null }
- createdAt: string
- commentCount: number
+ id: string;
+ humanId: string;
+ status: Status;
+ title: string;
+ labels: Array<{ name: string; color: { R: number; G: number; B: number } }>;
+ author: { humanId: string; displayName: string; avatarUrl?: string | null };
+ createdAt: string;
+ commentCount: number;
/** Current repo slug, used to build /:repo/issues/:id and /:repo/user/:id links. */
- repo: string | null
- onLabelClick?: (name: string) => void
+ repo: string | null;
+ onLabelClick?: (name: string) => void;
}
// Single row in the issue list. Shows status icon, title, labels, author and
@@ -31,19 +33,19 @@ export function BugRow({
repo,
onLabelClick,
}: BugRowProps) {
- const isOpen = status === Status.Open
- const StatusIcon = isOpen ? CircleDot : CircleCheck
+ const isOpen = status === Status.Open;
+ const StatusIcon = isOpen ? CircleDot : CircleCheck;
- const issueHref = repo ? `/${repo}/issues/${humanId}` : `/issues/${humanId}`
- const authorHref = repo ? `/${repo}/user/${author.humanId}` : `/user/${author.humanId}`
+ const issueHref = repo ? `/${repo}/issues/${humanId}` : `/issues/${humanId}`;
+ const authorHref = repo ? `/${repo}/user/${author.humanId}` : `/user/${author.humanId}`;
return (
<div className="flex items-start gap-3 border-b border-border px-4 py-3 last:border-0 hover:bg-muted/30">
<StatusIcon
className={
isOpen
- ? 'mt-0.5 size-4 shrink-0 text-green-600 dark:text-green-400'
- : 'mt-0.5 size-4 shrink-0 text-purple-600 dark:text-purple-400'
+ ? "mt-0.5 size-4 shrink-0 text-green-600 dark:text-green-400"
+ : "mt-0.5 size-4 shrink-0 text-purple-600 dark:text-purple-400"
}
/>
@@ -56,11 +58,16 @@ export function BugRow({
{title}
</Link>
{labels.map((label) => (
- <LabelBadge key={label.name} name={label.name} color={label.color} onClick={onLabelClick} />
+ <LabelBadge
+ key={label.name}
+ name={label.name}
+ color={label.color}
+ onClick={onLabelClick}
+ />
))}
</div>
<p className="mt-0.5 text-xs text-muted-foreground">
- #{humanId} opened {formatDistanceToNow(new Date(createdAt), { addSuffix: true })} by{' '}
+ #{humanId} opened {formatDistanceToNow(new Date(createdAt), { addSuffix: true })} by{" "}
<Link to={authorHref} className="hover:underline">
{author.displayName}
</Link>
@@ -74,5 +81,5 @@ export function BugRow({
</div>
)}
</div>
- )
+ );
}
@@ -1,10 +1,6 @@
-import { useState } from 'react'
-import { Button } from '@/components/ui/button'
-import { Textarea } from '@/components/ui/textarea'
-import { Markdown } from '@/components/content/Markdown'
-import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
-import { useAuth } from '@/lib/auth'
-import { Status } from '@/__generated__/graphql'
+import { useState } from "react";
+
+import { Status } from "@/__generated__/graphql";
import {
useBugAddCommentMutation,
useBugAddCommentAndCloseMutation,
@@ -12,60 +8,68 @@ import {
useBugStatusCloseMutation,
useBugStatusOpenMutation,
BugDetailDocument,
-} from '@/__generated__/graphql'
+} from "@/__generated__/graphql";
+import { Markdown } from "@/components/content/Markdown";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import { useAuth } from "@/lib/auth";
interface CommentBoxProps {
- bugPrefix: string
- bugStatus: Status
+ bugPrefix: string;
+ bugStatus: Status;
/** Current repo slug, passed as `ref` in refetch query variables. */
- ref_?: string | null
+ ref_?: string | null;
}
// Write/preview comment form at the bottom of BugDetailPage. Also contains the
// Close / Reopen button. Hidden entirely in read-only mode (no logged-in user).
export function CommentBox({ bugPrefix, bugStatus, ref_ }: CommentBoxProps) {
- const { user } = useAuth()
- const [message, setMessage] = useState('')
- const [preview, setPreview] = useState(false)
+ const { user } = useAuth();
+ const [message, setMessage] = useState("");
+ const [preview, setPreview] = useState(false);
- const refetchVars = { variables: { ref: ref_, prefix: bugPrefix } }
- const refetch = { refetchQueries: [{ query: BugDetailDocument, ...refetchVars }] }
+ const refetchVars = { variables: { ref: ref_, prefix: bugPrefix } };
+ const refetch = { refetchQueries: [{ query: BugDetailDocument, ...refetchVars }] };
- const [addComment, { loading: addingComment }] = useBugAddCommentMutation(refetch)
- const [addAndClose, { loading: addingAndClosing }] = useBugAddCommentAndCloseMutation(refetch)
- const [addAndReopen, { loading: addingAndReopening }] = useBugAddCommentAndReopenMutation(refetch)
- const [statusClose, { loading: closing }] = useBugStatusCloseMutation(refetch)
- const [statusOpen, { loading: reopening }] = useBugStatusOpenMutation(refetch)
+ const [addComment, { loading: addingComment }] = useBugAddCommentMutation(refetch);
+ const [addAndClose, { loading: addingAndClosing }] = useBugAddCommentAndCloseMutation(refetch);
+ const [addAndReopen, { loading: addingAndReopening }] =
+ useBugAddCommentAndReopenMutation(refetch);
+ const [statusClose, { loading: closing }] = useBugStatusCloseMutation(refetch);
+ const [statusOpen, { loading: reopening }] = useBugStatusOpenMutation(refetch);
- const isOpen = bugStatus === Status.Open
- const busy = addingComment || addingAndClosing || addingAndReopening || closing || reopening
- const hasMessage = message.trim().length > 0
+ const isOpen = bugStatus === Status.Open;
+ const busy = addingComment || addingAndClosing || addingAndReopening || closing || reopening;
+ const hasMessage = message.trim().length > 0;
async function handleComment() {
- await addComment({ variables: { input: { prefix: bugPrefix, message: message.trim() } } })
- setMessage('')
- setPreview(false)
+ await addComment({ variables: { input: { prefix: bugPrefix, message: message.trim() } } });
+ setMessage("");
+ setPreview(false);
}
async function handleToggleStatus() {
if (isOpen) {
if (hasMessage) {
- await addAndClose({ variables: { input: { prefix: bugPrefix, message: message.trim() } } })
+ await addAndClose({ variables: { input: { prefix: bugPrefix, message: message.trim() } } });
} else {
- await statusClose({ variables: { input: { prefix: bugPrefix } } })
+ await statusClose({ variables: { input: { prefix: bugPrefix } } });
}
} else {
if (hasMessage) {
- await addAndReopen({ variables: { input: { prefix: bugPrefix, message: message.trim() } } })
+ await addAndReopen({
+ variables: { input: { prefix: bugPrefix, message: message.trim() } },
+ });
} else {
- await statusOpen({ variables: { input: { prefix: bugPrefix } } })
+ await statusOpen({ variables: { input: { prefix: bugPrefix } } });
}
}
- setMessage('')
- setPreview(false)
+ setMessage("");
+ setPreview(false);
}
- if (!user) return null
+ if (!user) return null;
return (
<div className="flex gap-3">
@@ -83,8 +87,8 @@ export function CommentBox({ bugPrefix, bugStatus, ref_ }: CommentBoxProps) {
onClick={() => setPreview(false)}
className={`px-4 py-2 text-sm font-medium transition-colors ${
!preview
- ? 'border-b-2 border-primary text-foreground'
- : 'text-muted-foreground hover:text-foreground'
+ ? "border-b-2 border-primary text-foreground"
+ : "text-muted-foreground hover:text-foreground"
}`}
>
Write
@@ -94,8 +98,8 @@ export function CommentBox({ bugPrefix, bugStatus, ref_ }: CommentBoxProps) {
disabled={!hasMessage}
className={`px-4 py-2 text-sm font-medium transition-colors disabled:opacity-40 ${
preview
- ? 'border-b-2 border-primary text-foreground'
- : 'text-muted-foreground hover:text-foreground'
+ ? "border-b-2 border-primary text-foreground"
+ : "text-muted-foreground hover:text-foreground"
}`}
>
Preview
@@ -124,17 +128,13 @@ export function CommentBox({ bugPrefix, bugStatus, ref_ }: CommentBoxProps) {
disabled={busy}
className="min-w-[7.5rem]"
>
- {isOpen ? 'Close issue' : 'Reopen issue'}
+ {isOpen ? "Close issue" : "Reopen issue"}
</Button>
- <Button
- size="sm"
- onClick={handleComment}
- disabled={!hasMessage || busy}
- >
+ <Button size="sm" onClick={handleComment} disabled={!hasMessage || busy}>
Comment
</Button>
</div>
</div>
</div>
- )
+ );
}
@@ -1,17 +1,19 @@
-import { useState } from 'react'
-import { ArrowUpDown, ChevronDown, Tag, User, X, Search, Check } from 'lucide-react'
-import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
-import { LabelBadge } from './LabelBadge'
-import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
-import { useValidLabelsQuery, useAllIdentitiesQuery } from '@/__generated__/graphql'
-import { useAuth } from '@/lib/auth'
-import { cn } from '@/lib/utils'
-import { useRepo } from '@/lib/repo'
+import { ArrowUpDown, ChevronDown, Tag, User, X, Search, Check } from "lucide-react";
+import { useState } from "react";
+
+import { useValidLabelsQuery, useAllIdentitiesQuery } from "@/__generated__/graphql";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { useAuth } from "@/lib/auth";
+import { useRepo } from "@/lib/repo";
+import { cn } from "@/lib/utils";
+
+import { LabelBadge } from "./LabelBadge";
// Max authors shown in the non-searching state. We intentionally cap this to
// avoid a giant list β the current-user + recently-seen pattern covers the
// common case; typing to search handles the rest.
-const INITIAL_AUTHOR_LIMIT = 8
+const INITIAL_AUTHOR_LIMIT = 8;
// Returns the value passed to author:... in the query string.
// Preference order: login (never has spaces, safest) > name > humanId.
@@ -22,28 +24,32 @@ const INITIAL_AUTHOR_LIMIT = 8
// option. git-bug identities can have login="" (empty, not null) when the
// login field was never set; ?? would return "" and the filter would silently
// produce author:"" which buildQueryString then drops, making the filter a no-op.
-function authorQueryValue(i: { login?: string | null; name?: string | null; humanId: string }): string {
- return i.login || i.name || i.humanId
+function authorQueryValue(i: {
+ login?: string | null;
+ name?: string | null;
+ humanId: string;
+}): string {
+ return i.login || i.name || i.humanId;
}
-export type SortValue = 'creation-desc' | 'creation-asc' | 'edit-desc' | 'edit-asc'
+export type SortValue = "creation-desc" | "creation-asc" | "edit-desc" | "edit-asc";
const SORT_OPTIONS: { value: SortValue; label: string }[] = [
- { value: 'creation-desc', label: 'Newest' },
- { value: 'creation-asc', label: 'Oldest' },
- { value: 'edit-desc', label: 'Recently updated' },
- { value: 'edit-asc', label: 'Least recently updated' },
-]
+ { value: "creation-desc", label: "Newest" },
+ { value: "creation-asc", label: "Oldest" },
+ { value: "edit-desc", label: "Recently updated" },
+ { value: "edit-asc", label: "Least recently updated" },
+];
interface IssueFiltersProps {
- selectedLabels: string[]
- onLabelsChange: (labels: string[]) => void
- selectedAuthorId: string | null
- onAuthorChange: (humanId: string | null, queryValue: string | null) => void
+ selectedLabels: string[];
+ onLabelsChange: (labels: string[]) => void;
+ selectedAuthorId: string | null;
+ onAuthorChange: (humanId: string | null, queryValue: string | null) => void;
/** humanIds of authors appearing in the current bug list, used to rank the initial suggestions */
- recentAuthorIds?: string[]
- sort: SortValue
- onSortChange: (sort: SortValue) => void
+ recentAuthorIds?: string[];
+ sort: SortValue;
+ onSortChange: (sort: SortValue) => void;
}
// Label and author filter dropdowns shown in the issue list header bar.
@@ -66,98 +72,111 @@ export function IssueFilters({
sort,
onSortChange,
}: IssueFiltersProps) {
- const { user } = useAuth()
- const repo = useRepo()
- const { data: labelsData } = useValidLabelsQuery({ variables: { ref: repo } })
- const { data: authorsData } = useAllIdentitiesQuery({ variables: { ref: repo } })
- const [labelSearch, setLabelSearch] = useState('')
- const [authorSearch, setAuthorSearch] = useState('')
+ const { user } = useAuth();
+ const repo = useRepo();
+ const { data: labelsData } = useValidLabelsQuery({ variables: { ref: repo } });
+ const { data: authorsData } = useAllIdentitiesQuery({ variables: { ref: repo } });
+ const [labelSearch, setLabelSearch] = useState("");
+ const [authorSearch, setAuthorSearch] = useState("");
const validLabels = [...(labelsData?.repository?.validLabels.nodes ?? [])].sort((a, b) =>
a.name.localeCompare(b.name),
- )
+ );
const allIdentities = [...(authorsData?.repository?.allIdentities.nodes ?? [])].sort((a, b) =>
a.displayName.localeCompare(b.displayName),
- )
+ );
const filteredLabels = labelSearch.trim()
? validLabels.filter((l) => l.name.toLowerCase().includes(labelSearch.toLowerCase()))
- : validLabels
+ : validLabels;
// Selected labels float to top, then alphabetical
const sortedLabels = [
...filteredLabels.filter((l) => selectedLabels.includes(l.name)),
...filteredLabels.filter((l) => !selectedLabels.includes(l.name)),
- ]
+ ];
// Build the displayed identity list:
// - When searching: filter full list reactively as-you-type
// - When not searching: show current user first, then recently-seen authors,
// then others up to INITIAL_AUTHOR_LIMIT
- const isSearching = authorSearch.trim() !== ''
+ const isSearching = authorSearch.trim() !== "";
const matchesSearch = (i: (typeof allIdentities)[number]) => {
- const q = authorSearch.toLowerCase()
+ const q = authorSearch.toLowerCase();
return (
i.displayName.toLowerCase().includes(q) ||
- (i.name ?? '').toLowerCase().includes(q) ||
- (i.login ?? '').toLowerCase().includes(q) ||
- (i.email ?? '').toLowerCase().includes(q)
- )
- }
+ (i.name ?? "").toLowerCase().includes(q) ||
+ (i.login ?? "").toLowerCase().includes(q) ||
+ (i.email ?? "").toLowerCase().includes(q)
+ );
+ };
- let visibleIdentities: typeof allIdentities
+ let visibleIdentities: typeof allIdentities;
if (isSearching) {
- visibleIdentities = allIdentities.filter(matchesSearch)
+ visibleIdentities = allIdentities.filter(matchesSearch);
} else {
- const pinned = new Set<string>()
- const result: typeof allIdentities = []
+ const pinned = new Set<string>();
+ const result: typeof allIdentities = [];
// 1. Current user
if (user) {
- const me = allIdentities.find((i) => i.id === user.id)
- if (me) { result.push(me); pinned.add(me.id) }
+ const me = allIdentities.find((i) => i.id === user.id);
+ if (me) {
+ result.push(me);
+ pinned.add(me.id);
+ }
}
// 2. Selected author (if not already added)
if (selectedAuthorId) {
- const sel = allIdentities.find((i) => i.humanId === selectedAuthorId)
- if (sel && !pinned.has(sel.id)) { result.push(sel); pinned.add(sel.id) }
+ const sel = allIdentities.find((i) => i.humanId === selectedAuthorId);
+ if (sel && !pinned.has(sel.id)) {
+ result.push(sel);
+ pinned.add(sel.id);
+ }
}
// 3. Recently seen authors (recentAuthorIds are humanIds from bug rows)
for (const humanId of recentAuthorIds) {
- const match = allIdentities.find((i) => i.humanId === humanId)
- if (match && !pinned.has(match.id)) { result.push(match); pinned.add(match.id) }
+ const match = allIdentities.find((i) => i.humanId === humanId);
+ if (match && !pinned.has(match.id)) {
+ result.push(match);
+ pinned.add(match.id);
+ }
}
// 4. Fill up to limit with remaining alphabetical
for (const i of allIdentities) {
- if (result.length >= INITIAL_AUTHOR_LIMIT) break
- if (!pinned.has(i.id)) result.push(i)
+ if (result.length >= INITIAL_AUTHOR_LIMIT) break;
+ if (!pinned.has(i.id)) result.push(i);
}
- visibleIdentities = result
+ visibleIdentities = result;
}
function toggleLabel(name: string) {
if (selectedLabels.includes(name)) {
- onLabelsChange(selectedLabels.filter((l) => l !== name))
+ onLabelsChange(selectedLabels.filter((l) => l !== name));
} else {
- onLabelsChange([...selectedLabels, name])
+ onLabelsChange([...selectedLabels, name]);
}
}
- const selectedAuthorIdentity = allIdentities.find((i) => i.humanId === selectedAuthorId)
+ const selectedAuthorIdentity = allIdentities.find((i) => i.humanId === selectedAuthorId);
return (
<div className="flex shrink-0 items-center gap-1">
{/* Label filter */}
- <Popover onOpenChange={(open) => { if (!open) setLabelSearch('') }}>
+ <Popover
+ onOpenChange={(open) => {
+ if (!open) setLabelSearch("");
+ }}
+ >
<PopoverTrigger asChild>
<button
className={cn(
- 'flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
+ "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
selectedLabels.length > 0
- ? 'bg-accent text-accent-foreground'
- : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground',
+ ? "bg-accent text-accent-foreground"
+ : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
>
<Tag className="size-3.5" />
@@ -170,7 +189,7 @@ export function IssueFilters({
<ChevronDown className="size-3" />
</button>
</PopoverTrigger>
- <PopoverContent align="end" className="w-56 p-0 bg-popover shadow-lg">
+ <PopoverContent align="end" className="w-56 bg-popover p-0 shadow-lg">
{/* Search */}
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<Search className="size-3.5 shrink-0 text-muted-foreground" />
@@ -187,7 +206,7 @@ export function IssueFilters({
<p className="px-2 py-3 text-center text-xs text-muted-foreground">No labels found</p>
)}
{sortedLabels.map((label) => {
- const active = selectedLabels.includes(label.name)
+ const active = selectedLabels.includes(label.name);
return (
<button
key={label.name}
@@ -204,7 +223,7 @@ export function IssueFilters({
<LabelBadge name={label.name} color={label.color} />
{active && <Check className="ml-auto size-3.5 shrink-0 text-foreground" />}
</button>
- )
+ );
})}
</div>
{selectedLabels.length > 0 && (
@@ -222,12 +241,18 @@ export function IssueFilters({
</Popover>
{/* Author filter */}
- <Popover onOpenChange={(open) => { if (!open) setAuthorSearch('') }}>
+ <Popover
+ onOpenChange={(open) => {
+ if (!open) setAuthorSearch("");
+ }}
+ >
<PopoverTrigger asChild>
<button
className={cn(
- 'flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
- selectedAuthorId ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground',
+ "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
+ selectedAuthorId
+ ? "bg-accent text-accent-foreground"
+ : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
>
{selectedAuthorIdentity ? (
@@ -252,7 +277,7 @@ export function IssueFilters({
<ChevronDown className="size-3" />
</button>
</PopoverTrigger>
- <PopoverContent align="end" className="w-56 p-0 bg-popover shadow-lg">
+ <PopoverContent align="end" className="w-56 bg-popover p-0 shadow-lg">
{/* Search */}
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<Search className="size-3.5 shrink-0 text-muted-foreground" />
@@ -266,14 +291,21 @@ export function IssueFilters({
</div>
<div className="max-h-64 overflow-y-auto p-1">
{visibleIdentities.length === 0 && (
- <p className="px-2 py-3 text-center text-xs text-muted-foreground">No authors found</p>
+ <p className="px-2 py-3 text-center text-xs text-muted-foreground">
+ No authors found
+ </p>
)}
{visibleIdentities.map((identity) => {
- const active = selectedAuthorId === identity.humanId
+ const active = selectedAuthorId === identity.humanId;
return (
<button
key={identity.id}
- onClick={() => onAuthorChange(active ? null : identity.humanId, active ? null : authorQueryValue(identity))}
+ onClick={() =>
+ onAuthorChange(
+ active ? null : identity.humanId,
+ active ? null : authorQueryValue(identity),
+ )
+ }
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted"
>
<Avatar className="size-5 shrink-0">
@@ -285,12 +317,14 @@ export function IssueFilters({
<div className="min-w-0 flex-1 text-left">
<div className="truncate">{identity.displayName}</div>
{identity.login && identity.login !== identity.displayName && (
- <div className="truncate text-xs text-muted-foreground">@{identity.login}</div>
+ <div className="truncate text-xs text-muted-foreground">
+ @{identity.login}
+ </div>
)}
</div>
{active && <Check className="size-3.5 shrink-0 text-foreground" />}
</button>
- )
+ );
})}
{!isSearching && allIdentities.length > INITIAL_AUTHOR_LIMIT && (
<p className="px-2 py-1.5 text-center text-xs text-muted-foreground">
@@ -317,18 +351,18 @@ export function IssueFilters({
<PopoverTrigger asChild>
<button
className={cn(
- 'flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors whitespace-nowrap',
- sort !== 'creation-desc'
- ? 'bg-accent text-accent-foreground'
- : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground',
+ "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors whitespace-nowrap",
+ sort !== "creation-desc"
+ ? "bg-accent text-accent-foreground"
+ : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
>
<ArrowUpDown className="size-3.5" />
- {SORT_OPTIONS.find((o) => o.value === sort)?.label ?? 'Sort'}
+ {SORT_OPTIONS.find((o) => o.value === sort)?.label ?? "Sort"}
<ChevronDown className="size-3" />
</button>
</PopoverTrigger>
- <PopoverContent align="end" className="w-56 p-1 bg-popover shadow-lg">
+ <PopoverContent align="end" className="w-56 bg-popover p-1 shadow-lg">
{SORT_OPTIONS.map((opt) => (
<button
key={opt.value}
@@ -336,11 +370,13 @@ export function IssueFilters({
className="flex w-full items-center gap-2 whitespace-nowrap rounded px-2 py-1.5 text-sm hover:bg-muted"
>
{opt.label}
- {sort === opt.value && <Check className="ml-auto size-3.5 shrink-0 text-foreground" />}
+ {sort === opt.value && (
+ <Check className="ml-auto size-3.5 shrink-0 text-foreground" />
+ )}
</button>
))}
</PopoverContent>
</Popover>
</div>
- )
+ );
}
@@ -1,20 +1,20 @@
interface LabelBadgeProps {
- name: string
- color: { R: number; G: number; B: number }
- onClick?: (name: string) => void
+ name: string;
+ color: { R: number; G: number; B: number };
+ onClick?: (name: string) => void;
}
function contrastColor(r: number, g: number, b: number): string {
// Perceived luminance β pick black or white text for readability
- const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
- return luminance > 0.55 ? 'rgba(0,0,0,0.75)' : 'rgba(255,255,255,0.9)'
+ const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
+ return luminance > 0.55 ? "rgba(0,0,0,0.75)" : "rgba(255,255,255,0.9)";
}
// Coloured label pill. Renders as a <button> when onClick is provided,
// used in BugRow and UserProfilePage to filter issues by label.
export function LabelBadge({ name, color, onClick }: LabelBadgeProps) {
- const bg = `rgb(${color.R},${color.G},${color.B})`
- const text = contrastColor(color.R, color.G, color.B)
+ const bg = `rgb(${color.R},${color.G},${color.B})`;
+ const text = contrastColor(color.R, color.G, color.B);
if (onClick) {
return (
@@ -22,11 +22,15 @@ export function LabelBadge({ name, color, onClick }: LabelBadgeProps) {
type="button"
className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium hover:opacity-80"
style={{ backgroundColor: bg, color: text }}
- onClick={(e) => { e.preventDefault(); e.stopPropagation(); onClick(name) }}
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ onClick(name);
+ }}
>
{name}
</button>
- )
+ );
}
return (
@@ -36,5 +40,5 @@ export function LabelBadge({ name, color, onClick }: LabelBadgeProps) {
>
{name}
</span>
- )
+ );
}
@@ -1,35 +1,37 @@
-import { Settings2 } from 'lucide-react'
-import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
-import { LabelBadge } from './LabelBadge'
-import { useAuth } from '@/lib/auth'
+import { Settings2 } from "lucide-react";
+
import {
useValidLabelsQuery,
useBugChangeLabelsMutation,
BugDetailDocument,
-} from '@/__generated__/graphql'
+} from "@/__generated__/graphql";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { useAuth } from "@/lib/auth";
+
+import { LabelBadge } from "./LabelBadge";
interface LabelEditorProps {
- bugPrefix: string
- currentLabels: Array<{ name: string; color: { R: number; G: number; B: number } }>
+ bugPrefix: string;
+ currentLabels: Array<{ name: string; color: { R: number; G: number; B: number } }>;
/** Current repo slug, passed as `ref` in refetch query variables. */
- ref_?: string | null
+ ref_?: string | null;
}
// Gear-icon popover in the BugDetailPage sidebar for adding/removing labels.
// Loads all valid labels from the repo and toggles them via bugChangeLabels.
// Hidden in read-only mode.
export function LabelEditor({ bugPrefix, currentLabels, ref_ }: LabelEditorProps) {
- const { user } = useAuth()
- const { data } = useValidLabelsQuery({ skip: !user, variables: { ref: ref_ } })
+ const { user } = useAuth();
+ const { data } = useValidLabelsQuery({ skip: !user, variables: { ref: ref_ } });
const [changeLabels] = useBugChangeLabelsMutation({
refetchQueries: [{ query: BugDetailDocument, variables: { ref: ref_, prefix: bugPrefix } }],
- })
+ });
- const validLabels = data?.repository?.validLabels.nodes ?? []
- const currentNames = new Set(currentLabels.map((l) => l.name))
+ const validLabels = data?.repository?.validLabels.nodes ?? [];
+ const currentNames = new Set(currentLabels.map((l) => l.name));
async function toggleLabel(name: string) {
- const isSet = currentNames.has(name)
+ const isSet = currentNames.has(name);
await changeLabels({
variables: {
input: {
@@ -38,7 +40,7 @@ export function LabelEditor({ bugPrefix, currentLabels, ref_ }: LabelEditorProps
Removed: isSet ? [name] : [],
},
},
- })
+ });
}
return (
@@ -55,12 +57,10 @@ export function LabelEditor({ bugPrefix, currentLabels, ref_ }: LabelEditorProps
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-56 p-2">
- <p className="mb-2 px-2 text-xs font-medium text-muted-foreground">
- Apply labels
- </p>
+ <p className="mb-2 px-2 text-xs font-medium text-muted-foreground">Apply labels</p>
<div className="space-y-1">
{validLabels.map((label) => {
- const active = currentNames.has(label.name)
+ const active = currentNames.has(label.name);
return (
<button
key={label.name}
@@ -69,13 +69,21 @@ export function LabelEditor({ bugPrefix, currentLabels, ref_ }: LabelEditorProps
>
<span
className={`size-2 rounded-full border-2 transition-colors ${
- active ? 'border-transparent' : 'border-muted-foreground/40 bg-transparent'
+ active
+ ? "border-transparent"
+ : "border-muted-foreground/40 bg-transparent"
}`}
- style={active ? { backgroundColor: `rgb(${label.color.R},${label.color.G},${label.color.B})` } : {}}
+ style={
+ active
+ ? {
+ backgroundColor: `rgb(${label.color.R},${label.color.G},${label.color.B})`,
+ }
+ : {}
+ }
/>
<LabelBadge name={label.name} color={label.color} />
</button>
- )
+ );
})}
</div>
</PopoverContent>
@@ -93,5 +101,5 @@ export function LabelEditor({ bugPrefix, currentLabels, ref_ }: LabelEditorProps
</div>
)}
</div>
- )
+ );
}
@@ -9,93 +9,101 @@
// dropdown appears with filtered suggestions fetched from GraphQL. Clicking or
// keyboard-selecting a suggestion replaces the current token in the input.
-import { useState, useRef, useMemo, type ChangeEvent } from 'react'
-import { Search } from 'lucide-react'
-import { cn } from '@/lib/utils'
-import { useValidLabelsQuery, useAllIdentitiesQuery } from '@/__generated__/graphql'
-import { useRepo } from '@/lib/repo'
+import { Search } from "lucide-react";
+import { useState, useRef, useMemo, type ChangeEvent } from "react";
+
+import { useValidLabelsQuery, useAllIdentitiesQuery } from "@/__generated__/graphql";
+import { useRepo } from "@/lib/repo";
+import { cn } from "@/lib/utils";
// ββ Segment parsing (for the syntax-highlight backdrop) βββββββββββββββββββββββ
-type SegmentType = 'status-open' | 'status-closed' | 'label' | 'author' | 'text' | 'space'
+type SegmentType = "status-open" | "status-closed" | "label" | "author" | "text" | "space";
interface Segment {
- text: string
- type: SegmentType
+ text: string;
+ type: SegmentType;
}
// Parse the query string into typed segments, preserving all whitespace.
// Walks char-by-char so that quoted values (e.g. label:"my label") are kept as
// a single token and spaces inside quotes don't split the segment.
function parseSegments(input: string): Segment[] {
- const segments: Segment[] = []
- let i = 0
+ const segments: Segment[] = [];
+ let i = 0;
while (i < input.length) {
// Whitespace runs β preserved as a separate 'space' segment so the backdrop
// can use whitespace-pre and match the input exactly.
- if (input[i] === ' ') {
- let j = i
- while (j < input.length && input[j] === ' ') j++
- segments.push({ text: input.slice(i, j), type: 'space' })
- i = j
- continue
+ if (input[i] === " ") {
+ let j = i;
+ while (j < input.length && input[j] === " ") j++;
+ segments.push({ text: input.slice(i, j), type: "space" });
+ i = j;
+ continue;
}
// Token β consume until an unquoted space
- let j = i
- let inQuote = false
+ let j = i;
+ let inQuote = false;
while (j < input.length) {
- if (input[j] === '"') { inQuote = !inQuote; j++; continue }
- if (!inQuote && input[j] === ' ') break
- j++
+ if (input[j] === '"') {
+ inQuote = !inQuote;
+ j++;
+ continue;
+ }
+ if (!inQuote && input[j] === " ") break;
+ j++;
}
- const token = input.slice(i, j)
- let type: SegmentType = 'text'
- if (token === 'status:open') type = 'status-open'
- else if (token === 'status:closed') type = 'status-closed'
- else if (token.startsWith('label:')) type = 'label'
- else if (token.startsWith('author:')) type = 'author'
+ const token = input.slice(i, j);
+ let type: SegmentType = "text";
+ if (token === "status:open") type = "status-open";
+ else if (token === "status:closed") type = "status-closed";
+ else if (token.startsWith("label:")) type = "label";
+ else if (token.startsWith("author:")) type = "author";
- segments.push({ text: token, type })
- i = j
+ segments.push({ text: token, type });
+ i = j;
}
- return segments
+ return segments;
}
// Only the key portion (e.g. "label:") is colored; the value stays in foreground.
function renderSegment(seg: Segment, i: number): React.ReactNode {
- if (seg.type === 'space' || seg.type === 'text') {
- return <span key={i}>{seg.text}</span>
+ if (seg.type === "space" || seg.type === "text") {
+ return <span key={i}>{seg.text}</span>;
}
- const colon = seg.text.indexOf(':')
- const key = seg.text.slice(0, colon + 1)
- const val = seg.text.slice(colon + 1)
+ const colon = seg.text.indexOf(":");
+ const key = seg.text.slice(0, colon + 1);
+ const val = seg.text.slice(colon + 1);
const keyClass =
- seg.type === 'status-open' ? 'text-green-600 dark:text-green-400' :
- seg.type === 'status-closed' ? 'text-purple-600 dark:text-purple-400' :
- seg.type === 'label' ? 'text-yellow-600 dark:text-yellow-500' :
- /* author */ 'text-blue-600 dark:text-blue-400'
+ seg.type === "status-open"
+ ? "text-green-600 dark:text-green-400"
+ : seg.type === "status-closed"
+ ? "text-purple-600 dark:text-purple-400"
+ : seg.type === "label"
+ ? "text-yellow-600 dark:text-yellow-500"
+ : /* author */ "text-blue-600 dark:text-blue-400";
return (
<span key={i}>
<span className={keyClass}>{key}</span>
<span>{val}</span>
</span>
- )
+ );
}
// ββ Autocomplete logic ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
interface CompletionInfo {
- type: 'label' | 'author'
+ type: "label" | "author";
/** Text typed after the prefix (e.g. "bu" for "label:bu"). Quotes stripped. */
- query: string
+ query: string;
/** Byte position in `value` where the current token starts. */
- tokenStart: number
+ tokenStart: number;
}
// Inspects the text to the left of `cursor` to determine if the user is in the
@@ -103,181 +111,187 @@ interface CompletionInfo {
// Returns null when not in an autocomplete-eligible position.
function getCompletionInfo(value: string, cursor: number): CompletionInfo | null {
// Walk backward to find the start of the current token
- let tokenStart = 0
+ let tokenStart = 0;
for (let i = cursor - 1; i >= 0; i--) {
- if (value[i] === ' ') { tokenStart = i + 1; break }
+ if (value[i] === " ") {
+ tokenStart = i + 1;
+ break;
+ }
}
- const partial = value.slice(tokenStart, cursor)
- if (partial.startsWith('label:')) {
- return { type: 'label', query: partial.slice(6), tokenStart }
+ const partial = value.slice(tokenStart, cursor);
+ if (partial.startsWith("label:")) {
+ return { type: "label", query: partial.slice(6), tokenStart };
}
- if (partial.startsWith('author:')) {
+ if (partial.startsWith("author:")) {
// Strip a leading quote that the user may have typed
- return { type: 'author', query: partial.slice(7).replace(/^"/, ''), tokenStart }
+ return { type: "author", query: partial.slice(7).replace(/^"/, ""), tokenStart };
}
- return null
+ return null;
}
// Find where the current token ends (next unquoted space, or end of string).
// Used when replacing a token on suggestion selection so we don't leave stale text.
function getTokenEnd(value: string, tokenStart: number): number {
- let inQuote = false
+ let inQuote = false;
for (let i = tokenStart; i < value.length; i++) {
- if (value[i] === '"') { inQuote = !inQuote; continue }
- if (!inQuote && value[i] === ' ') return i
+ if (value[i] === '"') {
+ inQuote = !inQuote;
+ continue;
+ }
+ if (!inQuote && value[i] === " ") return i;
}
- return value.length
+ return value.length;
}
// ββ Component βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
interface QueryInputProps {
- value: string
- onChange: (value: string) => void
- onSubmit: () => void
- placeholder?: string
- className?: string
+ value: string;
+ onChange: (value: string) => void;
+ onSubmit: () => void;
+ placeholder?: string;
+ className?: string;
}
export function QueryInput({ value, onChange, onSubmit, placeholder, className }: QueryInputProps) {
- const inputRef = useRef<HTMLInputElement>(null)
- const repo = useRepo()
+ const inputRef = useRef<HTMLInputElement>(null);
+ const repo = useRepo();
// Autocomplete state: null when the dropdown is hidden.
- const [completion, setCompletion] = useState<CompletionInfo | null>(null)
+ const [completion, setCompletion] = useState<CompletionInfo | null>(null);
// Keyboard-highlighted index within the visible suggestions list.
- const [acIndex, setAcIndex] = useState(0)
+ const [acIndex, setAcIndex] = useState(0);
// Fetch all labels and identities for autocomplete suggestions.
// These queries are cheap (cached by Apollo) and already used by IssueFilters,
// so there is no extra network cost.
- const { data: labelsData } = useValidLabelsQuery({ variables: { ref: repo } })
- const { data: authorsData } = useAllIdentitiesQuery({ variables: { ref: repo } })
+ const { data: labelsData } = useValidLabelsQuery({ variables: { ref: repo } });
+ const { data: authorsData } = useAllIdentitiesQuery({ variables: { ref: repo } });
- const allLabels = labelsData?.repository?.validLabels.nodes ?? []
- const allAuthors = authorsData?.repository?.allIdentities.nodes ?? []
+ const allLabels = labelsData?.repository?.validLabels.nodes ?? [];
+ const allAuthors = authorsData?.repository?.allIdentities.nodes ?? [];
// Compute the filtered suggestion list whenever completion info changes.
const suggestions = useMemo(() => {
- if (!completion) return []
+ if (!completion) return [];
- if (completion.type === 'label') {
- const q = completion.query.toLowerCase()
+ if (completion.type === "label") {
+ const q = completion.query.toLowerCase();
return allLabels
- .filter((l) => q === '' || l.name.toLowerCase().includes(q))
+ .filter((l) => q === "" || l.name.toLowerCase().includes(q))
.slice(0, 8)
.map((l) => ({
display: l.name,
// Quote the token value if the label name contains a space
- completedToken: `label:${l.name.includes(' ') ? `"${l.name}"` : l.name}`,
+ completedToken: `label:${l.name.includes(" ") ? `"${l.name}"` : l.name}`,
color: l.color,
- }))
+ }));
}
// author suggestions β match against displayName, login, and name
- const q = completion.query.toLowerCase()
+ const q = completion.query.toLowerCase();
return allAuthors
.filter(
(a) =>
- q === '' ||
+ q === "" ||
a.displayName.toLowerCase().includes(q) ||
- (a.login ?? '').toLowerCase().includes(q) ||
- (a.name ?? '').toLowerCase().includes(q),
+ (a.login ?? "").toLowerCase().includes(q) ||
+ (a.name ?? "").toLowerCase().includes(q),
)
.slice(0, 8)
.map((a) => {
// Prefer login (no spaces, stable) β name β humanId as the query value.
// Same preference used by IssueFilters.authorQueryValue.
- const qv = a.login ?? a.name ?? a.humanId
+ const qv = a.login ?? a.name ?? a.humanId;
return {
display: a.displayName,
- completedToken: `author:${qv.includes(' ') ? `"${qv}"` : qv}`,
+ completedToken: `author:${qv.includes(" ") ? `"${qv}"` : qv}`,
color: null,
- }
- })
- }, [completion, allLabels, allAuthors])
+ };
+ });
+ }, [completion, allLabels, allAuthors]);
// ββ Recompute completion state after every input change or cursor move ββββββ
function updateCompletion(newValue: string, cursor: number) {
- const info = getCompletionInfo(newValue, cursor)
- setCompletion(info)
- setAcIndex(0)
+ const info = getCompletionInfo(newValue, cursor);
+ setCompletion(info);
+ setAcIndex(0);
}
function handleChange(e: ChangeEvent<HTMLInputElement>) {
- const newValue = e.target.value
- const cursor = e.target.selectionStart ?? newValue.length
- onChange(newValue)
- updateCompletion(newValue, cursor)
+ const newValue = e.target.value;
+ const cursor = e.target.selectionStart ?? newValue.length;
+ onChange(newValue);
+ updateCompletion(newValue, cursor);
}
// onSelect fires on cursor movement (arrow keys, click-to-reposition), which
// lets us show/hide the dropdown correctly when the cursor moves into or out
// of an autocomplete-eligible token without changing the text.
function handleSelect(e: React.SyntheticEvent<HTMLInputElement>) {
- updateCompletion(value, e.currentTarget.selectionStart ?? value.length)
+ updateCompletion(value, e.currentTarget.selectionStart ?? value.length);
}
// ββ Keyboard navigation βββββββββββββββββββββββββββββββββββββββββββββββββββ
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
- if (e.key === 'Enter' && !completion) {
- e.preventDefault()
- onSubmit()
- return
+ if (e.key === "Enter" && !completion) {
+ e.preventDefault();
+ onSubmit();
+ return;
}
- if (!completion || suggestions.length === 0) return
-
- if (e.key === 'ArrowDown') {
- e.preventDefault()
- setAcIndex((i) => (i + 1) % suggestions.length)
- } else if (e.key === 'ArrowUp') {
- e.preventDefault()
- setAcIndex((i) => (i - 1 + suggestions.length) % suggestions.length)
- } else if (e.key === 'Enter' || e.key === 'Tab') {
- e.preventDefault()
- applySuggestion(suggestions[acIndex])
- } else if (e.key === 'Escape') {
- setCompletion(null)
+ if (!completion || suggestions.length === 0) return;
+
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ setAcIndex((i) => (i + 1) % suggestions.length);
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault();
+ setAcIndex((i) => (i - 1 + suggestions.length) % suggestions.length);
+ } else if (e.key === "Enter" || e.key === "Tab") {
+ e.preventDefault();
+ applySuggestion(suggestions[acIndex]);
+ } else if (e.key === "Escape") {
+ setCompletion(null);
}
}
// ββ Apply a selected suggestion ββββββββββββββββββββββββββββββββββββββββββ
function applySuggestion(s: { completedToken: string }) {
- if (!completion) return
- const tokenEnd = getTokenEnd(value, completion.tokenStart)
+ if (!completion) return;
+ const tokenEnd = getTokenEnd(value, completion.tokenStart);
// Replace the current token (from tokenStart to tokenEnd) with the completed
// token, then add a space so the user can type the next filter immediately.
const newValue =
value.slice(0, completion.tokenStart) +
s.completedToken +
- ' ' +
- value.slice(tokenEnd).trimStart()
- onChange(newValue)
- setCompletion(null)
+ " " +
+ value.slice(tokenEnd).trimStart();
+ onChange(newValue);
+ setCompletion(null);
// Restore focus and position cursor after the inserted token + space
- const newCursor = completion.tokenStart + s.completedToken.length + 1
+ const newCursor = completion.tokenStart + s.completedToken.length + 1;
requestAnimationFrame(() => {
- inputRef.current?.focus()
- inputRef.current?.setSelectionRange(newCursor, newCursor)
- })
+ inputRef.current?.focus();
+ inputRef.current?.setSelectionRange(newCursor, newCursor);
+ });
}
// ββ Render ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- const segments = parseSegments(value)
- const showDropdown = completion !== null && suggestions.length > 0
+ const segments = parseSegments(value);
+ const showDropdown = completion !== null && suggestions.length > 0;
return (
<div
className={cn(
- 'relative flex-1 flex items-center rounded-md border border-input bg-background',
- 'ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2',
+ "relative flex-1 flex items-center rounded-md border border-input bg-background",
+ "ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2",
className,
)}
onClick={() => inputRef.current?.focus()}
@@ -288,9 +302,9 @@ export function QueryInput({ value, onChange, onSubmit, placeholder, className }
screen readers only see the real input, not the duplicate text. */}
<div
aria-hidden
- className="pointer-events-none absolute inset-0 flex items-center overflow-hidden pl-9 pr-3 font-mono text-sm text-foreground whitespace-pre"
+ className="pointer-events-none absolute inset-0 flex items-center overflow-hidden whitespace-pre pl-9 pr-3 font-mono text-sm text-foreground"
>
- {value === '' ? null : segments.map((seg, i) => renderSegment(seg, i))}
+ {value === "" ? null : segments.map((seg, i) => renderSegment(seg, i))}
</div>
{/* Actual input β transparent bg and text so the backdrop shows through.
@@ -316,10 +330,13 @@ export function QueryInput({ value, onChange, onSubmit, placeholder, className }
{suggestions.map((s, i) => (
<button
key={s.completedToken}
- onMouseDown={(e) => { e.preventDefault(); applySuggestion(s) }}
+ onMouseDown={(e) => {
+ e.preventDefault();
+ applySuggestion(s);
+ }}
className={cn(
- 'flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm',
- i === acIndex ? 'bg-accent text-accent-foreground' : 'hover:bg-muted',
+ "flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm",
+ i === acIndex ? "bg-accent text-accent-foreground" : "hover:bg-muted",
)}
>
{s.color && (
@@ -329,7 +346,7 @@ export function QueryInput({ value, onChange, onSubmit, placeholder, className }
/>
)}
<span className="font-mono">{s.completedToken}</span>
- {s.display !== s.completedToken.split(':')[1]?.replace(/"/g, '') && (
+ {s.display !== s.completedToken.split(":")[1]?.replace(/"/g, "") && (
<span className="ml-auto text-xs text-muted-foreground">{s.display}</span>
)}
</button>
@@ -337,5 +354,5 @@ export function QueryInput({ value, onChange, onSubmit, placeholder, className }
</div>
)}
</div>
- )
+ );
}
@@ -1,28 +1,29 @@
-import { CircleDot, CircleCheck } from 'lucide-react'
-import { cn } from '@/lib/utils'
-import { Status } from '@/__generated__/graphql'
+import { CircleDot, CircleCheck } from "lucide-react";
+
+import { Status } from "@/__generated__/graphql";
+import { cn } from "@/lib/utils";
interface StatusBadgeProps {
- status: Status
- className?: string
+ status: Status;
+ className?: string;
}
// Open / Closed status badge with icon. Used in BugDetailPage header.
export function StatusBadge({ status, className }: StatusBadgeProps) {
- const isOpen = status === Status.Open
+ const isOpen = status === Status.Open;
return (
<span
className={cn(
- 'inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium',
+ "inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium",
isOpen
- ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
- : 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400',
+ ? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
+ : "bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400",
className,
)}
>
{isOpen ? <CircleDot className="size-3" /> : <CircleCheck className="size-3" />}
- {isOpen ? 'Open' : 'Closed'}
+ {isOpen ? "Open" : "Closed"}
</span>
- )
+ );
}
@@ -1,28 +1,30 @@
-import { useState } from 'react'
-import { formatDistanceToNow } from 'date-fns'
-import { Link } from 'react-router-dom'
-import { Tag, GitPullRequestClosed, Pencil, CircleDot } from 'lucide-react'
-import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
-import { Markdown } from '@/components/content/Markdown'
-import { LabelBadge } from './LabelBadge'
-import { Button } from '@/components/ui/button'
-import { Textarea } from '@/components/ui/textarea'
+import { formatDistanceToNow } from "date-fns";
+import { Tag, GitPullRequestClosed, Pencil, CircleDot } from "lucide-react";
+import { useState } from "react";
+import { Link } from "react-router-dom";
+
import {
Status,
type BugDetailQuery,
useBugEditCommentMutation,
BugDetailDocument,
-} from '@/__generated__/graphql'
-import { useAuth } from '@/lib/auth'
-import { useRepo } from '@/lib/repo'
+} from "@/__generated__/graphql";
+import { Markdown } from "@/components/content/Markdown";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import { useAuth } from "@/lib/auth";
+import { useRepo } from "@/lib/repo";
+
+import { LabelBadge } from "./LabelBadge";
type TimelineNode = NonNullable<
- NonNullable<NonNullable<BugDetailQuery['repository']>['bug']>['timeline']['nodes'][number]
->
+ NonNullable<NonNullable<BugDetailQuery["repository"]>["bug"]>["timeline"]["nodes"][number]
+>;
interface TimelineProps {
- bugPrefix: string
- items: TimelineNode[]
+ bugPrefix: string;
+ items: TimelineNode[];
}
// Ordered sequence of events on a bug: comments (create and add-comment) and
@@ -33,56 +35,56 @@ export function Timeline({ bugPrefix, items }: TimelineProps) {
<div className="space-y-4">
{items.map((item) => {
switch (item.__typename) {
- case 'BugCreateTimelineItem':
- case 'BugAddCommentTimelineItem':
- return <CommentItem key={item.id} item={item} bugPrefix={bugPrefix} />
- case 'BugLabelChangeTimelineItem':
- return <LabelChangeItem key={item.id} item={item} />
- case 'BugSetStatusTimelineItem':
- return <StatusChangeItem key={item.id} item={item} />
- case 'BugSetTitleTimelineItem':
- return <TitleChangeItem key={item.id} item={item} />
+ case "BugCreateTimelineItem":
+ case "BugAddCommentTimelineItem":
+ return <CommentItem key={item.id} item={item} bugPrefix={bugPrefix} />;
+ case "BugLabelChangeTimelineItem":
+ return <LabelChangeItem key={item.id} item={item} />;
+ case "BugSetStatusTimelineItem":
+ return <StatusChangeItem key={item.id} item={item} />;
+ case "BugSetTitleTimelineItem":
+ return <TitleChangeItem key={item.id} item={item} />;
default:
- return null
+ return null;
}
})}
</div>
- )
+ );
}
// ββ Comment (create or add-comment) ββββββββββββββββββββββββββββββββββββββββββ
type CommentItem = Extract<
TimelineNode,
- { __typename: 'BugCreateTimelineItem' | 'BugAddCommentTimelineItem' }
->
+ { __typename: "BugCreateTimelineItem" | "BugAddCommentTimelineItem" }
+>;
function CommentItem({ item, bugPrefix }: { item: CommentItem; bugPrefix: string }) {
- const { user } = useAuth()
- const repo = useRepo()
- const [editing, setEditing] = useState(false)
- const [editValue, setEditValue] = useState(item.message ?? '')
+ const { user } = useAuth();
+ const repo = useRepo();
+ const [editing, setEditing] = useState(false);
+ const [editValue, setEditValue] = useState(item.message ?? "");
const [editComment, { loading }] = useBugEditCommentMutation({
refetchQueries: [{ query: BugDetailDocument, variables: { prefix: bugPrefix } }],
- })
+ });
function handleSave() {
- if (editValue.trim() === (item.message ?? '').trim()) {
- setEditing(false)
- return
+ if (editValue.trim() === (item.message ?? "").trim()) {
+ setEditing(false);
+ return;
}
editComment({
variables: { input: { targetPrefix: item.id, message: editValue } },
- }).then(() => setEditing(false))
+ }).then(() => setEditing(false));
}
function handleCancel() {
- setEditValue(item.message ?? '')
- setEditing(false)
+ setEditValue(item.message ?? "");
+ setEditing(false);
}
- const canEdit = user !== null && user.id === item.author.id
+ const canEdit = user !== null && user.id === item.author.id;
return (
<div className="flex gap-3">
@@ -95,15 +97,16 @@ function CommentItem({ item, bugPrefix }: { item: CommentItem; bugPrefix: string
<div className="min-w-0 flex-1 rounded-md border border-border">
<div className="flex items-center gap-2 border-b border-border bg-muted/40 px-4 py-2 text-sm">
- <Link to={repo ? `/${repo}/user/${item.author.humanId}` : `/user/${item.author.humanId}`} className="font-medium text-foreground hover:underline">
+ <Link
+ to={repo ? `/${repo}/user/${item.author.humanId}` : `/user/${item.author.humanId}`}
+ className="font-medium text-foreground hover:underline"
+ >
{item.author.displayName}
</Link>
<span className="text-muted-foreground">
{formatDistanceToNow(new Date(item.createdAt), { addSuffix: true })}
</span>
- {item.edited && !editing && (
- <span className="text-xs text-muted-foreground">edited</span>
- )}
+ {item.edited && !editing && <span className="text-xs text-muted-foreground">edited</span>}
{canEdit && !editing && (
<button
onClick={() => setEditing(true)}
@@ -123,13 +126,16 @@ function CommentItem({ item, bugPrefix }: { item: CommentItem; bugPrefix: string
className="min-h-24 font-mono text-sm"
autoFocus
onKeyDown={(e) => {
- if (e.key === 'Escape') handleCancel()
- if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); handleSave() }
+ if (e.key === "Escape") handleCancel();
+ if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
+ e.preventDefault();
+ handleSave();
+ }
}}
/>
<div className="flex gap-2">
<Button size="sm" onClick={handleSave} disabled={loading}>
- {loading ? 'Savingβ¦' : 'Save'}
+ {loading ? "Savingβ¦" : "Save"}
</Button>
<Button size="sm" variant="ghost" onClick={handleCancel} disabled={loading}>
Cancel
@@ -147,14 +153,14 @@ function CommentItem({ item, bugPrefix }: { item: CommentItem; bugPrefix: string
)}
</div>
</div>
- )
+ );
}
// ββ Inline events βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-type LabelChangeItem = Extract<TimelineNode, { __typename: 'BugLabelChangeTimelineItem' }>
-type StatusChangeItem = Extract<TimelineNode, { __typename: 'BugSetStatusTimelineItem' }>
-type TitleChangeItem = Extract<TimelineNode, { __typename: 'BugSetTitleTimelineItem' }>
+type LabelChangeItem = Extract<TimelineNode, { __typename: "BugLabelChangeTimelineItem" }>;
+type StatusChangeItem = Extract<TimelineNode, { __typename: "BugSetStatusTimelineItem" }>;
+type TitleChangeItem = Extract<TimelineNode, { __typename: "BugSetTitleTimelineItem" }>;
function EventRow({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
return (
@@ -162,40 +168,45 @@ function EventRow({ icon, children }: { icon: React.ReactNode; children: React.R
<span className="flex size-8 shrink-0 items-center justify-center">{icon}</span>
{children}
</div>
- )
+ );
}
function LabelChangeItem({ item }: { item: LabelChangeItem }) {
- const repo = useRepo()
+ const repo = useRepo();
return (
<EventRow icon={<Tag className="size-4" />}>
<span>
- <Link to={repo ? `/${repo}/user/${item.author.humanId}` : `/user/${item.author.humanId}`} className="font-medium text-foreground hover:underline">{item.author.displayName}</Link>{' '}
+ <Link
+ to={repo ? `/${repo}/user/${item.author.humanId}` : `/user/${item.author.humanId}`}
+ className="font-medium text-foreground hover:underline"
+ >
+ {item.author.displayName}
+ </Link>{" "}
{item.added.length > 0 && (
<>
- added{' '}
+ added{" "}
{item.added.map((l) => (
<LabelBadge key={l.name} name={l.name} color={l.color} />
- ))}{' '}
+ ))}{" "}
</>
)}
{item.removed.length > 0 && (
<>
- removed{' '}
+ removed{" "}
{item.removed.map((l) => (
<LabelBadge key={l.name} name={l.name} color={l.color} />
- ))}{' '}
+ ))}{" "}
</>
)}
{formatDistanceToNow(new Date(item.date), { addSuffix: true })}
</span>
</EventRow>
- )
+ );
}
function StatusChangeItem({ item }: { item: StatusChangeItem }) {
- const repo = useRepo()
- const isOpen = item.status === Status.Open
+ const repo = useRepo();
+ const isOpen = item.status === Status.Open;
return (
<EventRow
icon={
@@ -207,24 +218,34 @@ function StatusChangeItem({ item }: { item: StatusChangeItem }) {
}
>
<span>
- <Link to={repo ? `/${repo}/user/${item.author.humanId}` : `/user/${item.author.humanId}`} className="font-medium text-foreground hover:underline">{item.author.displayName}</Link>{' '}
- {isOpen ? 'reopened' : 'closed'} this{' '}
+ <Link
+ to={repo ? `/${repo}/user/${item.author.humanId}` : `/user/${item.author.humanId}`}
+ className="font-medium text-foreground hover:underline"
+ >
+ {item.author.displayName}
+ </Link>{" "}
+ {isOpen ? "reopened" : "closed"} this{" "}
{formatDistanceToNow(new Date(item.date), { addSuffix: true })}
</span>
</EventRow>
- )
+ );
}
function TitleChangeItem({ item }: { item: TitleChangeItem }) {
- const repo = useRepo()
+ const repo = useRepo();
return (
<EventRow icon={<Pencil className="size-4" />}>
<span>
- <Link to={repo ? `/${repo}/user/${item.author.humanId}` : `/user/${item.author.humanId}`} className="font-medium text-foreground hover:underline">{item.author.displayName}</Link> changed the
- title from <span className="line-through">{item.was}</span> to{' '}
- <span className="font-medium text-foreground">{item.title}</span>{' '}
+ <Link
+ to={repo ? `/${repo}/user/${item.author.humanId}` : `/user/${item.author.humanId}`}
+ className="font-medium text-foreground hover:underline"
+ >
+ {item.author.displayName}
+ </Link>{" "}
+ changed the title from <span className="line-through">{item.was}</span> to{" "}
+ <span className="font-medium text-foreground">{item.title}</span>{" "}
{formatDistanceToNow(new Date(item.date), { addSuffix: true })}
</span>
</EventRow>
- )
+ );
}
@@ -1,52 +1,53 @@
-import { useState, useRef, useEffect } from 'react'
-import { Pencil } from 'lucide-react'
-import { Button } from '@/components/ui/button'
-import { Input } from '@/components/ui/input'
-import { useAuth } from '@/lib/auth'
-import { useBugSetTitleMutation, BugDetailDocument } from '@/__generated__/graphql'
+import { Pencil } from "lucide-react";
+import { useState, useRef, useEffect } from "react";
+
+import { useBugSetTitleMutation, BugDetailDocument } from "@/__generated__/graphql";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { useAuth } from "@/lib/auth";
interface TitleEditorProps {
- bugPrefix: string
- title: string
- humanId: string
+ bugPrefix: string;
+ title: string;
+ humanId: string;
/** Current repo slug, passed as `ref` in refetch query variables. */
- ref_?: string | null
+ ref_?: string | null;
}
// Inline title editor in BugDetailPage. Shows the title as plain text with a
// pencil icon on hover (auth-gated). Enter saves, Escape cancels.
export function TitleEditor({ bugPrefix, title, humanId, ref_ }: TitleEditorProps) {
- const { user } = useAuth()
- const [editing, setEditing] = useState(false)
- const [value, setValue] = useState(title)
- const inputRef = useRef<HTMLInputElement>(null)
+ const { user } = useAuth();
+ const [editing, setEditing] = useState(false);
+ const [value, setValue] = useState(title);
+ const inputRef = useRef<HTMLInputElement>(null);
const [setTitle, { loading }] = useBugSetTitleMutation({
refetchQueries: [{ query: BugDetailDocument, variables: { ref: ref_, prefix: bugPrefix } }],
- })
+ });
useEffect(() => {
- if (editing) inputRef.current?.focus()
- }, [editing])
+ if (editing) inputRef.current?.focus();
+ }, [editing]);
// Keep local value in sync if title prop changes (e.g. after refetch)
useEffect(() => {
- if (!editing) setValue(title)
- }, [title, editing])
+ if (!editing) setValue(title);
+ }, [title, editing]);
async function handleSave() {
- const trimmed = value.trim()
+ const trimmed = value.trim();
if (trimmed && trimmed !== title) {
- await setTitle({ variables: { input: { prefix: bugPrefix, title: trimmed } } })
+ await setTitle({ variables: { input: { prefix: bugPrefix, title: trimmed } } });
}
- setEditing(false)
+ setEditing(false);
}
function handleKeyDown(e: React.KeyboardEvent) {
- if (e.key === 'Enter') handleSave()
- if (e.key === 'Escape') {
- setValue(title)
- setEditing(false)
+ if (e.key === "Enter") handleSave();
+ if (e.key === "Escape") {
+ setValue(title);
+ setEditing(false);
}
}
@@ -68,14 +69,14 @@ export function TitleEditor({ bugPrefix, title, humanId, ref_ }: TitleEditorProp
size="sm"
variant="ghost"
onClick={() => {
- setValue(title)
- setEditing(false)
+ setValue(title);
+ setEditing(false);
}}
>
Cancel
</Button>
</div>
- )
+ );
}
return (
@@ -94,5 +95,5 @@ export function TitleEditor({ bugPrefix, title, humanId, ref_ }: TitleEditorProp
</button>
)}
</div>
- )
+ );
}
@@ -1,30 +1,30 @@
-import { ChevronRight } from 'lucide-react'
+import { ChevronRight } from "lucide-react";
interface CodeBreadcrumbProps {
- repoName: string
- ref: string
- path: string
+ repoName: string;
+ ref: string;
+ path: string;
// called when user clicks a breadcrumb segment β returns new path
- onNavigate: (path: string) => void
+ onNavigate: (path: string) => void;
}
// Path breadcrumb for the code browser: repo name / ref / path segments.
// Each segment is clickable to navigate up the tree.
export function CodeBreadcrumb({ repoName, ref, path, onNavigate }: CodeBreadcrumbProps) {
- const parts = path ? path.split('/').filter(Boolean) : []
+ const parts = path ? path.split("/").filter(Boolean) : [];
return (
<div className="flex flex-wrap items-center gap-1 font-mono text-sm">
<button
- onClick={() => onNavigate('')}
+ onClick={() => onNavigate("")}
className="font-medium text-foreground hover:underline"
>
{repoName}
</button>
{parts.map((part, i) => {
- const partPath = parts.slice(0, i + 1).join('/')
- const isLast = i === parts.length - 1
+ const partPath = parts.slice(0, i + 1).join("/");
+ const isLast = i === parts.length - 1;
return (
<span key={partPath} className="flex items-center gap-1">
<ChevronRight className="size-3.5 text-muted-foreground" />
@@ -39,10 +39,10 @@ export function CodeBreadcrumb({ repoName, ref, path, onNavigate }: CodeBreadcru
</button>
)}
</span>
- )
+ );
})}
<span className="ml-2 text-xs text-muted-foreground">@ {ref}</span>
</div>
- )
+ );
}
@@ -1,14 +1,15 @@
// Paginated commit history grouped by calendar date. Each row links to the
// commit detail page. Used in CodePage's "History" view.
-import { useState } from 'react'
-import { Link } from 'react-router-dom'
-import { formatDistanceToNow } from 'date-fns'
-import { GitCommit } from 'lucide-react'
-import { gql, useQuery } from '@apollo/client'
-import { Button } from '@/components/ui/button'
-import { Skeleton } from '@/components/ui/skeleton'
-import { useRepo } from '@/lib/repo'
+import { gql, useQuery } from "@apollo/client";
+import { formatDistanceToNow } from "date-fns";
+import { GitCommit } from "lucide-react";
+import { useState } from "react";
+import { Link } from "react-router-dom";
+
+import { Button } from "@/components/ui/button";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useRepo } from "@/lib/repo";
const COMMITS_QUERY = gql`
query CommitList($repo: String, $ref: String!, $path: String, $after: String, $first: Int) {
@@ -28,64 +29,66 @@ const COMMITS_QUERY = gql`
}
}
}
-`
+`;
-const PAGE_SIZE = 30
+const PAGE_SIZE = 30;
interface CommitListProps {
- ref_: string
- path?: string
+ ref_: string;
+ path?: string;
}
type CommitNode = {
- hash: string
- shortHash: string
- message: string
- authorName: string
- date: string
-}
+ hash: string;
+ shortHash: string;
+ message: string;
+ authorName: string;
+ date: string;
+};
export function CommitList({ ref_, path }: CommitListProps) {
- const repo = useRepo()
- const [cursor, setCursor] = useState<string | null>(null)
- const [allCommits, setAllCommits] = useState<CommitNode[]>([])
+ const repo = useRepo();
+ const [cursor, setCursor] = useState<string | null>(null);
+ const [allCommits, setAllCommits] = useState<CommitNode[]>([]);
const { loading, error, fetchMore } = useQuery(COMMITS_QUERY, {
variables: { repo, ref: ref_, path: path ?? null, after: null, first: PAGE_SIZE },
skip: !ref_,
onCompleted(data) {
- const nodes = data?.repository?.commits?.nodes ?? []
- setAllCommits(nodes)
- setCursor(data?.repository?.commits?.pageInfo?.endCursor ?? null)
+ const nodes = data?.repository?.commits?.nodes ?? [];
+ setAllCommits(nodes);
+ setCursor(data?.repository?.commits?.pageInfo?.endCursor ?? null);
},
- })
+ });
- const hasMore = !!cursor && allCommits.length > 0 && allCommits.length % PAGE_SIZE === 0
- const [loadingMore, setLoadingMore] = useState(false)
+ const hasMore = !!cursor && allCommits.length > 0 && allCommits.length % PAGE_SIZE === 0;
+ const [loadingMore, setLoadingMore] = useState(false);
function loadMore() {
- if (!cursor) return
- setLoadingMore(true)
+ if (!cursor) return;
+ setLoadingMore(true);
fetchMore({
variables: { after: cursor },
- }).then((result) => {
- const newNodes = result.data?.repository?.commits?.nodes ?? []
- setAllCommits((prev) => [...prev, ...newNodes])
- setCursor(result.data?.repository?.commits?.pageInfo?.endCursor ?? null)
- }).finally(() => setLoadingMore(false))
+ })
+ .then((result) => {
+ const newNodes = result.data?.repository?.commits?.nodes ?? [];
+ setAllCommits((prev) => [...prev, ...newNodes]);
+ setCursor(result.data?.repository?.commits?.pageInfo?.endCursor ?? null);
+ })
+ .finally(() => setLoadingMore(false));
}
- if (loading) return <CommitListSkeleton />
+ if (loading) return <CommitListSkeleton />;
if (error) {
return (
<div className="rounded-md border border-border px-4 py-8 text-center text-sm text-destructive">
{error.message}
</div>
- )
+ );
}
- const groups = groupByDate(allCommits)
+ const groups = groupByDate(allCommits);
return (
<div className="space-y-6">
@@ -94,7 +97,7 @@ export function CommitList({ ref_, path }: CommitListProps) {
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Commits on {date}
</h3>
- <div className="overflow-hidden rounded-md border border-border divide-y divide-border">
+ <div className="divide-y divide-border overflow-hidden rounded-md border border-border">
{group.map((commit) => (
<CommitRow key={commit.hash} commit={commit} repo={repo} />
))}
@@ -105,16 +108,16 @@ export function CommitList({ ref_, path }: CommitListProps) {
{hasMore && (
<div className="text-center">
<Button variant="outline" size="sm" onClick={loadMore} disabled={loadingMore}>
- {loadingMore ? 'Loadingβ¦' : 'Load more commits'}
+ {loadingMore ? "Loadingβ¦" : "Load more commits"}
</Button>
</div>
)}
</div>
- )
+ );
}
function CommitRow({ commit, repo }: { commit: CommitNode; repo: string | null }) {
- const commitPath = repo ? `/${repo}/commit/${commit.hash}` : `/commit/${commit.hash}`
+ const commitPath = repo ? `/${repo}/commit/${commit.hash}` : `/commit/${commit.hash}`;
return (
<div className="flex items-center gap-3 bg-background px-4 py-3 hover:bg-muted/30">
<GitCommit className="size-4 shrink-0 text-muted-foreground" />
@@ -126,7 +129,7 @@ function CommitRow({ commit, repo }: { commit: CommitNode; repo: string | null }
{commit.message}
</Link>
<p className="mt-0.5 text-xs text-muted-foreground">
- {commit.authorName} ·{' '}
+ {commit.authorName} ·{" "}
{formatDistanceToNow(new Date(commit.date), { addSuffix: true })}
</p>
</div>
@@ -138,22 +141,22 @@ function CommitRow({ commit, repo }: { commit: CommitNode; repo: string | null }
{commit.shortHash}
</Link>
</div>
- )
+ );
}
function groupByDate(commits: CommitNode[]): [string, CommitNode[]][] {
- const map = new Map<string, CommitNode[]>()
+ const map = new Map<string, CommitNode[]>();
for (const c of commits) {
- const date = new Date(c.date).toLocaleDateString('en-US', {
- year: 'numeric',
- month: 'long',
- day: 'numeric',
- })
- const group = map.get(date) ?? []
- group.push(c)
- map.set(date, group)
+ const date = new Date(c.date).toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ });
+ const group = map.get(date) ?? [];
+ group.push(c);
+ map.set(date, group);
}
- return Array.from(map.entries())
+ return Array.from(map.entries());
}
function CommitListSkeleton() {
@@ -162,7 +165,7 @@ function CommitListSkeleton() {
{Array.from({ length: 2 }).map((_, g) => (
<div key={g}>
<Skeleton className="mb-2 h-3 w-32" />
- <div className="overflow-hidden rounded-md border border-border divide-y divide-border">
+ <div className="divide-y divide-border overflow-hidden rounded-md border border-border">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 px-4 py-3">
<Skeleton className="size-4 rounded" />
@@ -177,5 +180,5 @@ function CommitListSkeleton() {
</div>
))}
</div>
- )
+ );
}
@@ -1,11 +1,12 @@
// Collapsible diff view for a single file in a commit.
// Diff is fetched lazily on first expand via GraphQL.
-import { useState } from 'react'
-import { ChevronRight, FilePlus, FileMinus, FileEdit } from 'lucide-react'
-import { gql, useLazyQuery } from '@apollo/client'
-import { cn } from '@/lib/utils'
-import { useRepo } from '@/lib/repo'
+import { gql, useLazyQuery } from "@apollo/client";
+import { ChevronRight, FilePlus, FileMinus, FileEdit } from "lucide-react";
+import { useState } from "react";
+
+import { useRepo } from "@/lib/repo";
+import { cn } from "@/lib/utils";
const DIFF_QUERY = gql`
query FileDiff($repo: String, $hash: String!, $path: String!) {
@@ -33,128 +34,144 @@ const DIFF_QUERY = gql`
}
}
}
-`
+`;
interface FileDiffViewProps {
- hash: string
- path: string
- oldPath?: string
- status: string
+ hash: string;
+ path: string;
+ oldPath?: string;
+ status: string;
}
const statusIcon: Record<string, React.ReactNode> = {
- ADDED: <FilePlus className="size-3.5 text-green-600 dark:text-green-400" />,
- DELETED: <FileMinus className="size-3.5 text-red-500 dark:text-red-400" />,
- MODIFIED: <FileEdit className="size-3.5 text-yellow-500 dark:text-yellow-400" />,
- RENAMED: <FileEdit className="size-3.5 text-blue-500 dark:text-blue-400" />,
-}
-const statusBadge: Record<string, string> = { ADDED: 'A', DELETED: 'D', MODIFIED: 'M', RENAMED: 'R' }
+ ADDED: <FilePlus className="size-3.5 text-green-600 dark:text-green-400" />,
+ DELETED: <FileMinus className="size-3.5 text-red-500 dark:text-red-400" />,
+ MODIFIED: <FileEdit className="size-3.5 text-yellow-500 dark:text-yellow-400" />,
+ RENAMED: <FileEdit className="size-3.5 text-blue-500 dark:text-blue-400" />,
+};
+const statusBadge: Record<string, string> = {
+ ADDED: "A",
+ DELETED: "D",
+ MODIFIED: "M",
+ RENAMED: "R",
+};
export function FileDiffView({ hash, path, oldPath, status }: FileDiffViewProps) {
- const repo = useRepo()
- const [open, setOpen] = useState(false)
- const [fetchDiff, { data, loading, error }] = useLazyQuery(DIFF_QUERY)
+ const repo = useRepo();
+ const [open, setOpen] = useState(false);
+ const [fetchDiff, { data, loading, error }] = useLazyQuery(DIFF_QUERY);
function toggle() {
if (!open && !data && !loading) {
- fetchDiff({ variables: { repo, hash, path } })
+ fetchDiff({ variables: { repo, hash, path } });
}
- setOpen((v) => !v)
+ setOpen((v) => !v);
}
- const diff = data?.repository?.commit?.diff
+ const diff = data?.repository?.commit?.diff;
return (
<div className="divide-y divide-border">
<button
onClick={toggle}
- className="flex w-full items-center gap-3 px-4 py-2.5 text-left hover:bg-muted/50 transition-colors"
+ className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<ChevronRight
className={cn(
- 'size-3.5 shrink-0 text-muted-foreground transition-transform duration-150',
- open && 'rotate-90',
+ "size-3.5 shrink-0 text-muted-foreground transition-transform duration-150",
+ open && "rotate-90",
)}
/>
{statusIcon[status] ?? <FileEdit className="size-3.5 text-muted-foreground" />}
<span className="min-w-0 flex-1 font-mono text-sm">
- {status === 'RENAMED' ? (
+ {status === "RENAMED" ? (
<>
<span className="text-muted-foreground line-through">{oldPath}</span>
- {' β '}
+ {" β "}
<span>{path}</span>
</>
- ) : path}
+ ) : (
+ path
+ )}
</span>
<span className="shrink-0 rounded border border-border px-1.5 py-0.5 font-mono text-xs text-muted-foreground">
- {statusBadge[status] ?? '?'}
+ {statusBadge[status] ?? "?"}
</span>
</button>
{open && (
<div className="overflow-x-auto">
- {loading && (
- <div className="px-4 py-3 text-xs text-muted-foreground">Loading diffβ¦</div>
- )}
+ {loading && <div className="px-4 py-3 text-xs text-muted-foreground">Loading diffβ¦</div>}
{error && (
- <div className="px-4 py-3 text-xs text-destructive">Failed to load diff: {error.message}</div>
+ <div className="px-4 py-3 text-xs text-destructive">
+ Failed to load diff: {error.message}
+ </div>
)}
- {diff && (
- diff.isBinary ? (
+ {diff &&
+ (diff.isBinary ? (
<div className="px-4 py-3 text-xs text-muted-foreground">Binary file</div>
) : diff.hunks.length === 0 ? (
<div className="px-4 py-3 text-xs text-muted-foreground">No changes</div>
) : (
diff.hunks.map((hunk: HunkType, i: number) => <Hunk key={i} hunk={hunk} />)
- )
- )}
+ ))}
</div>
)}
</div>
- )
+ );
}
-type LineType = { type: string; content: string; oldLine: number; newLine: number }
-type HunkType = { oldStart: number; oldLines: number; newStart: number; newLines: number; lines: LineType[] }
+type LineType = { type: string; content: string; oldLine: number; newLine: number };
+type HunkType = {
+ oldStart: number;
+ oldLines: number;
+ newStart: number;
+ newLines: number;
+ lines: LineType[];
+};
function Hunk({ hunk }: { hunk: HunkType }) {
return (
<div className="font-mono text-xs leading-5">
- <div className="bg-blue-50 px-4 py-0.5 text-blue-600 dark:bg-blue-950/40 dark:text-blue-400 select-none">
+ <div className="select-none bg-blue-50 px-4 py-0.5 text-blue-600 dark:bg-blue-950/40 dark:text-blue-400">
@@ -{hunk.oldStart},{hunk.oldLines} +{hunk.newStart},{hunk.newLines} @@
</div>
{hunk.lines.map((line, i) => (
<div
key={i}
className={cn(
- 'flex',
- line.type === 'ADDED' && 'bg-green-50 dark:bg-green-950/30',
- line.type === 'DELETED' && 'bg-red-50 dark:bg-red-950/30',
+ "flex",
+ line.type === "ADDED" && "bg-green-50 dark:bg-green-950/30",
+ line.type === "DELETED" && "bg-red-50 dark:bg-red-950/30",
)}
>
<span className="w-10 shrink-0 select-none border-r border-border/50 px-2 text-right text-muted-foreground/50">
- {line.oldLine || ''}
+ {line.oldLine || ""}
</span>
<span className="w-10 shrink-0 select-none border-r border-border/50 px-2 text-right text-muted-foreground/50">
- {line.newLine || ''}
+ {line.newLine || ""}
</span>
- <span className={cn(
- 'w-5 shrink-0 select-none text-center',
- line.type === 'ADDED' && 'text-green-600 dark:text-green-400',
- line.type === 'DELETED' && 'text-red-500 dark:text-red-400',
- line.type === 'CONTEXT' && 'text-muted-foreground/40',
- )}>
- {line.type === 'ADDED' ? '+' : line.type === 'DELETED' ? '-' : ' '}
+ <span
+ className={cn(
+ "w-5 shrink-0 select-none text-center",
+ line.type === "ADDED" && "text-green-600 dark:text-green-400",
+ line.type === "DELETED" && "text-red-500 dark:text-red-400",
+ line.type === "CONTEXT" && "text-muted-foreground/40",
+ )}
+ >
+ {line.type === "ADDED" ? "+" : line.type === "DELETED" ? "-" : " "}
</span>
- <pre className={cn(
- 'flex-1 overflow-visible whitespace-pre px-2',
- line.type === 'ADDED' && 'text-green-900 dark:text-green-200',
- line.type === 'DELETED' && 'text-red-900 dark:text-red-200',
- )}>
+ <pre
+ className={cn(
+ "flex-1 overflow-visible whitespace-pre px-2",
+ line.type === "ADDED" && "text-green-900 dark:text-green-200",
+ line.type === "DELETED" && "text-red-900 dark:text-red-200",
+ )}
+ >
{line.content}
</pre>
</div>
))}
</div>
- )
+ );
}
@@ -1,25 +1,26 @@
-import { Folder, File } from 'lucide-react'
-import { Link } from 'react-router-dom'
-import { formatDistanceToNow } from 'date-fns'
-import { Skeleton } from '@/components/ui/skeleton'
-import { useRepo } from '@/lib/repo'
-import type { GitTreeEntry } from '@/__generated__/graphql'
+import { formatDistanceToNow } from "date-fns";
+import { Folder, File } from "lucide-react";
+import { Link } from "react-router-dom";
+
+import type { GitTreeEntry } from "@/__generated__/graphql";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useRepo } from "@/lib/repo";
export interface TreeEntryWithCommit extends GitTreeEntry {
lastCommit?: {
- hash: string
- shortHash: string
- message: string
- date: string
- }
+ hash: string;
+ shortHash: string;
+ message: string;
+ date: string;
+ };
}
interface FileTreeProps {
- entries: TreeEntryWithCommit[]
- path: string
- loading?: boolean
- onNavigate: (entry: TreeEntryWithCommit) => void
- onNavigateUp: () => void
+ entries: TreeEntryWithCommit[];
+ path: string;
+ loading?: boolean;
+ onNavigate: (entry: TreeEntryWithCommit) => void;
+ onNavigateUp: () => void;
}
// Directory listing table for the code browser. Shows each entry's icon,
@@ -27,21 +28,18 @@ interface FileTreeProps {
export function FileTree({ entries, path, loading, onNavigate, onNavigateUp }: FileTreeProps) {
// Directories first, then files β each group alphabetical
const sorted = [...entries].sort((a, b) => {
- if (a.type !== b.type) return a.type === 'TREE' ? -1 : 1
- return a.name.localeCompare(b.name)
- })
+ if (a.type !== b.type) return a.type === "TREE" ? -1 : 1;
+ return a.name.localeCompare(b.name);
+ });
- if (loading) return <FileTreeSkeleton />
+ if (loading) return <FileTreeSkeleton />;
return (
<div className="overflow-hidden rounded-md border border-border">
<table className="w-full text-sm">
<tbody className="divide-y divide-border">
{path && (
- <tr
- className="cursor-pointer hover:bg-muted/40"
- onClick={onNavigateUp}
- >
+ <tr className="cursor-pointer hover:bg-muted/40" onClick={onNavigateUp}>
<td className="w-6 py-2 pl-4">
<Folder className="size-4 text-blue-500 dark:text-blue-400" />
</td>
@@ -56,24 +54,21 @@ export function FileTree({ entries, path, loading, onNavigate, onNavigateUp }: F
</tbody>
</table>
</div>
- )
+ );
}
function FileTreeRow({
entry,
onNavigate,
}: {
- entry: TreeEntryWithCommit
- onNavigate: (entry: TreeEntryWithCommit) => void
+ entry: TreeEntryWithCommit;
+ onNavigate: (entry: TreeEntryWithCommit) => void;
}) {
- const isDir = entry.type === 'TREE'
- const repo = useRepo()
+ const isDir = entry.type === "TREE";
+ const repo = useRepo();
return (
- <tr
- className="cursor-pointer hover:bg-muted/40"
- onClick={() => onNavigate(entry)}
- >
+ <tr className="cursor-pointer hover:bg-muted/40" onClick={() => onNavigate(entry)}>
<td className="w-6 py-2 pl-4">
{isDir ? (
<Folder className="size-4 text-blue-500 dark:text-blue-400" />
@@ -82,14 +77,16 @@ function FileTreeRow({
)}
</td>
<td className="px-3 py-2">
- <span className={`font-mono ${isDir ? 'font-medium text-foreground' : 'text-foreground'}`}>
+ <span className={`font-mono ${isDir ? "font-medium text-foreground" : "text-foreground"}`}>
{entry.name}
</span>
</td>
<td className="hidden max-w-xs truncate px-3 py-2 text-muted-foreground md:table-cell">
{entry.lastCommit && (
<Link
- to={repo ? `/${repo}/commit/${entry.lastCommit.hash}` : `/commit/${entry.lastCommit.hash}`}
+ to={
+ repo ? `/${repo}/commit/${entry.lastCommit.hash}` : `/commit/${entry.lastCommit.hash}`
+ }
className="hover:text-foreground hover:underline"
onClick={(e) => e.stopPropagation()}
>
@@ -102,7 +99,7 @@ function FileTreeRow({
formatDistanceToNow(new Date(entry.lastCommit.date), { addSuffix: true })}
</td>
</tr>
- )
+ );
}
function FileTreeSkeleton() {
@@ -119,5 +116,5 @@ function FileTreeSkeleton() {
))}
</div>
</div>
- )
+ );
}
@@ -1,46 +1,49 @@
// Syntax-highlighted file viewer with line numbers and copy button.
// highlight.js is loaded lazily so it doesn't bloat the initial bundle.
-import { useState, useEffect } from 'react'
-import { Copy } from 'lucide-react'
-import { Button } from '@/components/ui/button'
-import { Skeleton } from '@/components/ui/skeleton'
-import type { GitBlob } from '@/__generated__/graphql'
+import { Copy } from "lucide-react";
+import { useState, useEffect } from "react";
+
+import type { GitBlob } from "@/__generated__/graphql";
+import { Button } from "@/components/ui/button";
+import { Skeleton } from "@/components/ui/skeleton";
interface FileViewerProps {
- blob: GitBlob
- loading?: boolean
+ blob: GitBlob;
+ loading?: boolean;
}
export function FileViewer({ blob, loading }: FileViewerProps) {
- const [highlighted, setHighlighted] = useState<{ html: string; lineCount: number } | null>(null)
+ const [highlighted, setHighlighted] = useState<{ html: string; lineCount: number } | null>(null);
useEffect(() => {
if (blob.isBinary || !blob.text) {
- setHighlighted({ html: '', lineCount: 0 })
- return
+ setHighlighted({ html: "", lineCount: 0 });
+ return;
}
- setHighlighted(null)
- let cancelled = false
- import('highlight.js').then(({ default: hljs }) => {
- if (cancelled) return
- const ext = blob.path.split('.').pop() ?? ''
+ setHighlighted(null);
+ let cancelled = false;
+ import("highlight.js").then(({ default: hljs }) => {
+ if (cancelled) return;
+ const ext = blob.path.split(".").pop() ?? "";
const result = hljs.getLanguage(ext)
? hljs.highlight(blob.text!, { language: ext })
- : hljs.highlightAuto(blob.text!)
+ : hljs.highlightAuto(blob.text!);
setHighlighted({
html: result.value,
- lineCount: blob.text!.split('\n').length,
- })
- })
- return () => { cancelled = true }
- }, [blob])
+ lineCount: blob.text!.split("\n").length,
+ });
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [blob]);
- if (loading || highlighted === null) return <FileViewerSkeleton />
- const { html, lineCount } = highlighted
+ if (loading || highlighted === null) return <FileViewerSkeleton />;
+ const { html, lineCount } = highlighted;
function copyToClipboard() {
- if (blob.text) navigator.clipboard.writeText(blob.text)
+ if (blob.text) navigator.clipboard.writeText(blob.text);
}
return (
@@ -48,9 +51,15 @@ export function FileViewer({ blob, loading }: FileViewerProps) {
<div className="flex items-center justify-between border-b border-border bg-muted/40 px-4 py-2 text-xs text-muted-foreground">
<span>
{lineCount.toLocaleString()} lines Β· {formatBytes(blob.size)}
- {blob.isTruncated && ' Β· truncated'}
+ {blob.isTruncated && " Β· truncated"}
</span>
- <Button variant="ghost" size="icon" className="size-7" onClick={copyToClipboard} title="Copy">
+ <Button
+ variant="ghost"
+ size="icon"
+ className="size-7"
+ onClick={copyToClipboard}
+ title="Copy"
+ >
<Copy className="size-3.5" />
</Button>
</div>
@@ -75,13 +84,13 @@ export function FileViewer({ blob, loading }: FileViewerProps) {
</div>
)}
</div>
- )
+ );
}
function formatBytes(bytes: number): string {
- if (bytes < 1024) return `${bytes} B`
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
- return `${(bytes / 1024 / 1024).toFixed(1)} MB`
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function FileViewerSkeleton() {
@@ -103,5 +112,5 @@ function FileViewerSkeleton() {
</div>
</div>
</div>
- )
+ );
}
@@ -1,28 +1,27 @@
-import { useState } from 'react'
-import { GitBranch, Tag, Check, ChevronsUpDown } from 'lucide-react'
-import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
-import { Button } from '@/components/ui/button'
-import { Input } from '@/components/ui/input'
-import type { GitRef } from '@/__generated__/graphql'
-import { cn } from '@/lib/utils'
+import { GitBranch, Tag, Check, ChevronsUpDown } from "lucide-react";
+import { useState } from "react";
+
+import type { GitRef } from "@/__generated__/graphql";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { cn } from "@/lib/utils";
interface RefSelectorProps {
- refs: GitRef[]
- currentRef: string
- onSelect: (ref: GitRef) => void
+ refs: GitRef[];
+ currentRef: string;
+ onSelect: (ref: GitRef) => void;
}
// Branch / tag selector dropdown for the code browser. Shown in two groups
// (branches, tags) with an inline search filter.
export function RefSelector({ refs, currentRef, onSelect }: RefSelectorProps) {
- const [open, setOpen] = useState(false)
- const [filter, setFilter] = useState('')
+ const [open, setOpen] = useState(false);
+ const [filter, setFilter] = useState("");
- const filtered = refs.filter((r) =>
- r.shortName.toLowerCase().includes(filter.toLowerCase()),
- )
- const branches = filtered.filter((r) => r.type === 'BRANCH')
- const tags = filtered.filter((r) => r.type === 'TAG')
+ const filtered = refs.filter((r) => r.shortName.toLowerCase().includes(filter.toLowerCase()));
+ const branches = filtered.filter((r) => r.type === "BRANCH");
+ const tags = filtered.filter((r) => r.type === "TAG");
return (
<Popover open={open} onOpenChange={setOpen}>
@@ -51,7 +50,11 @@ export function RefSelector({ refs, currentRef, onSelect }: RefSelectorProps) {
key={ref.name}
ref_={ref}
active={ref.shortName === currentRef}
- onSelect={() => { onSelect(ref); setOpen(false); setFilter('') }}
+ onSelect={() => {
+ onSelect(ref);
+ setOpen(false);
+ setFilter("");
+ }}
/>
))}
</div>
@@ -64,7 +67,11 @@ export function RefSelector({ refs, currentRef, onSelect }: RefSelectorProps) {
key={ref.name}
ref_={ref}
active={ref.shortName === currentRef}
- onSelect={() => { onSelect(ref); setOpen(false); setFilter('') }}
+ onSelect={() => {
+ onSelect(ref);
+ setOpen(false);
+ setFilter("");
+ }}
/>
))}
</div>
@@ -75,7 +82,7 @@ export function RefSelector({ refs, currentRef, onSelect }: RefSelectorProps) {
</div>
</PopoverContent>
</Popover>
- )
+ );
}
function RefItem({
@@ -83,19 +90,19 @@ function RefItem({
active,
onSelect,
}: {
- ref_: GitRef
- active: boolean
- onSelect: () => void
+ ref_: GitRef;
+ active: boolean;
+ onSelect: () => void;
}) {
return (
<button
onClick={onSelect}
className={cn(
- 'flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-muted',
- active && 'font-medium',
+ "flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-muted",
+ active && "font-medium",
)}
>
- {ref_.type === 'BRANCH' ? (
+ {ref_.type === "BRANCH" ? (
<GitBranch className="size-3 shrink-0 text-muted-foreground" />
) : (
<Tag className="size-3 shrink-0 text-muted-foreground" />
@@ -103,5 +110,5 @@ function RefItem({
<span className="flex-1 truncate font-mono">{ref_.shortName}</span>
{active && <Check className="size-3 text-muted-foreground" />}
</button>
- )
+ );
}
@@ -1,12 +1,13 @@
-import ReactMarkdown from 'react-markdown'
-import remarkGfm from 'remark-gfm'
-import remarkEmoji from 'remark-emoji'
-import rehypeRaw from 'rehype-raw'
-import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
-import rehypeSlug from 'rehype-slug'
-import rehypeAutolinkHeadings from 'rehype-autolink-headings'
-import rehypeExternalLinks from 'rehype-external-links'
-import { cn } from '@/lib/utils'
+import ReactMarkdown from "react-markdown";
+import rehypeAutolinkHeadings from "rehype-autolink-headings";
+import rehypeExternalLinks from "rehype-external-links";
+import rehypeRaw from "rehype-raw";
+import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
+import rehypeSlug from "rehype-slug";
+import remarkEmoji from "remark-emoji";
+import remarkGfm from "remark-gfm";
+
+import { cn } from "@/lib/utils";
// Sanitization schema: start from the safe default and allow a small set of
// presentational/structural HTML tags commonly found in READMEs.
@@ -16,20 +17,17 @@ import { cn } from '@/lib/utils'
// allow those attributes on anchors.
const sanitizeSchema = {
...defaultSchema,
- tagNames: [
- ...(defaultSchema.tagNames ?? []),
- 'details', 'summary', 'picture', 'source',
- ],
+ tagNames: [...(defaultSchema.tagNames ?? []), "details", "summary", "picture", "source"],
attributes: {
...defaultSchema.attributes,
- a: [...(defaultSchema.attributes?.a ?? []), 'aria-hidden', 'class'],
- '*': [...(defaultSchema.attributes?.['*'] ?? []), 'id'],
+ a: [...(defaultSchema.attributes?.a ?? []), "aria-hidden", "class"],
+ "*": [...(defaultSchema.attributes?.["*"] ?? []), "id"],
},
-}
+};
interface MarkdownProps {
- content: string
- className?: string
+ content: string;
+ className?: string;
}
// Renders a Markdown string with GitHub-flavoured extensions (tables, task
@@ -42,18 +40,18 @@ export function Markdown({ content, className }: MarkdownProps) {
rehypeRaw,
[rehypeSanitize, sanitizeSchema],
rehypeSlug,
- [rehypeAutolinkHeadings, { behavior: 'append' }],
- [rehypeExternalLinks, { target: '_blank', rel: ['noopener', 'noreferrer'] }],
+ [rehypeAutolinkHeadings, { behavior: "append" }],
+ [rehypeExternalLinks, { target: "_blank", rel: ["noopener", "noreferrer"] }],
]}
className={cn(
- 'prose prose-sm dark:prose-invert max-w-none',
- 'prose-pre:bg-muted prose-pre:text-foreground',
- 'prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:text-sm prose-code:before:content-none prose-code:after:content-none',
- 'prose-img:inline prose-img:my-0',
+ "prose prose-sm dark:prose-invert max-w-none",
+ "prose-pre:bg-muted prose-pre:text-foreground",
+ "prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:text-sm prose-code:before:content-none prose-code:after:content-none",
+ "prose-img:inline prose-img:my-0",
className,
)}
>
{content}
</ReactMarkdown>
- )
+ );
}
@@ -6,41 +6,42 @@
// In external mode, shows a "Sign in" button when logged out and a sign-out
// action when logged in.
-import { Link, useMatch, NavLink } from 'react-router-dom'
-import { Bug, Plus, Sun, Moon, LogIn, LogOut } from 'lucide-react'
-import { cn } from '@/lib/utils'
-import { useAuth } from '@/lib/auth'
-import { useTheme } from '@/lib/theme'
-import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
-import { Button } from '@/components/ui/button'
+import { Bug, Plus, Sun, Moon, LogIn, LogOut } from "lucide-react";
+import { Link, useMatch, NavLink } from "react-router-dom";
+
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Button } from "@/components/ui/button";
+import { useAuth } from "@/lib/auth";
+import { useTheme } from "@/lib/theme";
+import { cn } from "@/lib/utils";
// SignOutButton sends a POST to /auth/logout and reloads the page.
// A full reload is the simplest way to reset all Apollo cache + React state.
function SignOutButton() {
function handleSignOut() {
- fetch('/auth/logout', { method: 'POST', credentials: 'include' }).finally(
- () => window.location.assign('/'),
- )
+ fetch("/auth/logout", { method: "POST", credentials: "include" }).finally(() =>
+ window.location.assign("/"),
+ );
}
return (
<Button variant="ghost" size="sm" onClick={handleSignOut} title="Sign out">
<LogOut className="size-4" />
</Button>
- )
+ );
}
export function Header() {
- const { user, mode, loginProviders } = useAuth()
- const { theme, toggle } = useTheme()
+ const { user, mode, loginProviders } = useAuth();
+ const { theme, toggle } = useTheme();
// Detect if we're inside a /:repo route and grab the slug.
// useMatch works from any component in the tree, unlike useParams which is
// scoped to the nearest Route element.
- const repoMatch = useMatch({ path: '/:repo/*', end: false })
- const repo = repoMatch?.params.repo ?? null
+ const repoMatch = useMatch({ path: "/:repo/*", end: false });
+ const repo = repoMatch?.params.repo ?? null;
// Don't show repo nav on the /auth/* pages.
- const effectiveRepo = repo === 'auth' ? null : repo
+ const effectiveRepo = repo === "auth" ? null : repo;
return (
<header className="sticky top-0 z-50 border-b border-border bg-background/95 backdrop-blur">
@@ -57,23 +58,27 @@ export function Header() {
<NavLink
to={`/${effectiveRepo}`}
end
- className={({ isActive }) => cn(
- 'rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
- isActive
- ? 'bg-accent text-accent-foreground'
- : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
- )}
+ className={({ isActive }) =>
+ cn(
+ "rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
+ isActive
+ ? "bg-accent text-accent-foreground"
+ : "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
+ )
+ }
>
Code
</NavLink>
<NavLink
to={`/${effectiveRepo}/issues`}
- className={({ isActive }) => cn(
- 'rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
- isActive
- ? 'bg-accent text-accent-foreground'
- : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
- )}
+ className={({ isActive }) =>
+ cn(
+ "rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
+ isActive
+ ? "bg-accent text-accent-foreground"
+ : "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
+ )
+ }
>
Issues
</NavLink>
@@ -81,23 +86,23 @@ export function Header() {
)}
<div className="ml-auto flex items-center gap-2">
- {mode === 'readonly' && (
- <span className="text-xs text-muted-foreground">Read only</span>
- )}
+ {mode === "readonly" && <span className="text-xs text-muted-foreground">Read only</span>}
<Button variant="ghost" size="icon" onClick={toggle} title="Toggle theme">
- {theme === 'light' ? <Moon className="size-4" /> : <Sun className="size-4" />}
+ {theme === "light" ? <Moon className="size-4" /> : <Sun className="size-4" />}
</Button>
{/* External mode: show sign-in buttons when logged out */}
- {mode === 'external' && !user && loginProviders.map((p) => (
- <Button key={p} asChild size="sm">
- <a href={`/auth/login?provider=${p}`}>
- <LogIn className="size-4" />
- Sign in with {providerLabel(p)}
- </a>
- </Button>
- ))}
+ {mode === "external" &&
+ !user &&
+ loginProviders.map((p) => (
+ <Button key={p} asChild size="sm">
+ <a href={`/auth/login?provider=${p}`}>
+ <LogIn className="size-4" />
+ Sign in with {providerLabel(p)}
+ </a>
+ </Button>
+ ))}
{user && effectiveRepo && (
<>
@@ -119,14 +124,14 @@ export function Header() {
)}
{/* Sign out only shown in external mode when logged in */}
- {mode === 'external' && user && <SignOutButton />}
+ {mode === "external" && user && <SignOutButton />}
</div>
</div>
</header>
- )
+ );
}
function providerLabel(name: string): string {
- const labels: Record<string, string> = { github: 'GitHub', gitlab: 'GitLab', gitea: 'Gitea' }
- return labels[name] ?? name
+ const labels: Record<string, string> = { github: "GitHub", gitlab: "GitLab", gitea: "Gitea" };
+ return labels[name] ?? name;
}
@@ -1,5 +1,6 @@
-import { Outlet } from 'react-router-dom'
-import { Header } from './Header'
+import { Outlet } from "react-router-dom";
+
+import { Header } from "./Header";
// Top-level page wrapper used as the root layout in App.tsx. Renders the
// Header above the current route's page component via <Outlet>.
@@ -11,5 +12,5 @@ export function Shell() {
<Outlet />
</main>
</div>
- )
+ );
}
@@ -1,6 +1,7 @@
-import * as React from 'react'
-import * as AvatarPrimitive from '@radix-ui/react-avatar'
-import { cn } from '@/lib/utils'
+import * as AvatarPrimitive from "@radix-ui/react-avatar";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
@@ -8,11 +9,11 @@ const Avatar = React.forwardRef<
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
- className={cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', className)}
+ className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
{...props}
/>
-))
-Avatar.displayName = AvatarPrimitive.Root.displayName
+));
+Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
@@ -20,11 +21,11 @@ const AvatarImage = React.forwardRef<
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
- className={cn('aspect-square h-full w-full', className)}
+ className={cn("aspect-square h-full w-full", className)}
{...props}
/>
-))
-AvatarImage.displayName = AvatarPrimitive.Image.displayName
+));
+AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
@@ -33,12 +34,12 @@ const AvatarFallback = React.forwardRef<
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
- 'flex h-full w-full items-center justify-center rounded-full bg-muted text-xs font-medium',
+ "flex h-full w-full items-center justify-center rounded-full bg-muted text-xs font-medium",
className,
)}
{...props}
/>
-))
-AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
+));
+AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
-export { Avatar, AvatarImage, AvatarFallback }
+export { Avatar, AvatarImage, AvatarFallback };
@@ -1,30 +1,32 @@
-import * as React from 'react'
-import { cva, type VariantProps } from 'class-variance-authority'
-import { cn } from '@/lib/utils'
+import { cva, type VariantProps } from "class-variance-authority";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
const badgeVariants = cva(
- 'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
+ "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
- default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
- secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
- destructive: 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
- outline: 'text-foreground',
+ default: "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ destructive:
+ "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
+ outline: "text-foreground",
},
},
defaultVariants: {
- variant: 'default',
+ variant: "default",
},
},
-)
+);
export interface BadgeProps
- extends React.HTMLAttributes<HTMLDivElement>,
- VariantProps<typeof badgeVariants> {}
+ extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
- return <div className={cn(badgeVariants({ variant }), className)} {...props} />
+ return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
-export { Badge, badgeVariants }
+export { Badge, badgeVariants };
@@ -1,52 +1,49 @@
-import * as React from 'react'
-import { Slot } from '@radix-ui/react-slot'
-import { cva, type VariantProps } from 'class-variance-authority'
-import { cn } from '@/lib/utils'
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
const buttonVariants = cva(
- 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
- default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
- destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
- outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
- secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
- ghost: 'hover:bg-accent hover:text-accent-foreground',
- link: 'text-primary underline-offset-4 hover:underline',
+ default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
+ destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
+ outline:
+ "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
+ secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
+ ghost: "hover:bg-accent hover:text-accent-foreground",
+ link: "text-primary underline-offset-4 hover:underline",
},
size: {
- default: 'h-9 px-4 py-2',
- sm: 'h-8 rounded-md px-3 text-xs',
- lg: 'h-10 rounded-md px-8',
- icon: 'h-9 w-9',
+ default: "h-9 px-4 py-2",
+ sm: "h-8 rounded-md px-3 text-xs",
+ lg: "h-10 rounded-md px-8",
+ icon: "h-9 w-9",
},
},
defaultVariants: {
- variant: 'default',
- size: 'default',
+ variant: "default",
+ size: "default",
},
},
-)
+);
export interface ButtonProps
- extends React.ButtonHTMLAttributes<HTMLButtonElement>,
- VariantProps<typeof buttonVariants> {
- asChild?: boolean
+ extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
+ asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
- const Comp = asChild ? Slot : 'button'
+ const Comp = asChild ? Slot : "button";
return (
- <Comp
- className={cn(buttonVariants({ variant, size, className }))}
- ref={ref}
- {...props}
- />
- )
+ <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
+ );
},
-)
-Button.displayName = 'Button'
+);
+Button.displayName = "Button";
-export { Button, buttonVariants }
+export { Button, buttonVariants };
@@ -1,21 +1,22 @@
-import * as React from 'react'
-import { cn } from '@/lib/utils'
+import * as React from "react";
-const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
+import { cn } from "@/lib/utils";
+
+const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
- 'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
+ "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}
{...props}
/>
- )
+ );
},
-)
-Input.displayName = 'Input'
+);
+Input.displayName = "Input";
-export { Input }
+export { Input };
@@ -1,28 +1,29 @@
-import * as React from 'react'
-import * as PopoverPrimitive from '@radix-ui/react-popover'
-import { cn } from '@/lib/utils'
+import * as PopoverPrimitive from "@radix-ui/react-popover";
+import * as React from "react";
-const Popover = PopoverPrimitive.Root
-const PopoverTrigger = PopoverPrimitive.Trigger
-const PopoverAnchor = PopoverPrimitive.Anchor
+import { cn } from "@/lib/utils";
+
+const Popover = PopoverPrimitive.Root;
+const PopoverTrigger = PopoverPrimitive.Trigger;
+const PopoverAnchor = PopoverPrimitive.Anchor;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
->(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
+>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
- 'z-50 w-72 rounded-md border border-border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
+ "z-50 w-72 rounded-md border border-border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
-))
-PopoverContent.displayName = PopoverPrimitive.Content.displayName
+));
+PopoverContent.displayName = PopoverPrimitive.Content.displayName;
-export { Popover, PopoverTrigger, PopoverAnchor, PopoverContent }
+export { Popover, PopoverTrigger, PopoverAnchor, PopoverContent };
@@ -1,23 +1,24 @@
-import * as React from 'react'
-import * as SeparatorPrimitive from '@radix-ui/react-separator'
-import { cn } from '@/lib/utils'
+import * as SeparatorPrimitive from "@radix-ui/react-separator";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
->(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
+>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
- 'shrink-0 bg-border',
- orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
+ "shrink-0 bg-border",
+ orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className,
)}
{...props}
/>
-))
-Separator.displayName = SeparatorPrimitive.Root.displayName
+));
+Separator.displayName = SeparatorPrimitive.Root.displayName;
-export { Separator }
+export { Separator };
@@ -1,12 +1,7 @@
-import { cn } from '@/lib/utils'
+import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
- return (
- <div
- className={cn('animate-pulse rounded-md bg-primary/10', className)}
- {...props}
- />
- )
+ return <div className={cn("animate-pulse rounded-md bg-primary/10", className)} {...props} />;
}
-export { Skeleton }
+export { Skeleton };
@@ -1,20 +1,21 @@
-import * as React from 'react'
-import { cn } from '@/lib/utils'
+import * as React from "react";
-const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<'textarea'>>(
+import { cn } from "@/lib/utils";
+
+const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<"textarea">>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
- 'flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
+ "flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}
{...props}
/>
- )
+ );
},
-)
-Textarea.displayName = 'Textarea'
+);
+Textarea.displayName = "Textarea";
-export { Textarea }
+export { Textarea };
@@ -1,5 +1,5 @@
/* highlight.js theme must be imported before any @layer rules (PostCSS requirement) */
-@import 'highlight.js/styles/github.css';
+@import "highlight.js/styles/github.css";
@tailwind base;
@tailwind components;
@@ -71,12 +71,24 @@
}
.dark .hljs-keyword,
.dark .hljs-selector-tag,
-.dark .hljs-built_in { color: #ff7b72; }
+.dark .hljs-built_in {
+ color: #ff7b72;
+}
.dark .hljs-string,
-.dark .hljs-attr { color: #a5d6ff; }
-.dark .hljs-comment { color: hsl(220, 8%, 50%); }
+.dark .hljs-attr {
+ color: #a5d6ff;
+}
+.dark .hljs-comment {
+ color: hsl(220, 8%, 50%);
+}
.dark .hljs-number,
-.dark .hljs-literal { color: #79c0ff; }
+.dark .hljs-literal {
+ color: #79c0ff;
+}
.dark .hljs-title,
-.dark .hljs-name { color: #d2a8ff; }
-.dark .hljs-type { color: #ffa657; }
+.dark .hljs-name {
+ color: #d2a8ff;
+}
+.dark .hljs-type {
+ color: #ffa657;
+}
@@ -1,10 +1,10 @@
-import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client'
+import { ApolloClient, InMemoryCache, createHttpLink } from "@apollo/client";
const httpLink = createHttpLink({
- uri: '/graphql',
+ uri: "/graphql",
// include credentials so future httpOnly auth cookies are sent automatically
- credentials: 'include',
-})
+ credentials: "include",
+});
export const client = new ApolloClient({
link: httpLink,
@@ -16,4 +16,4 @@ export const client = new ApolloClient({
},
},
}),
-})
+});
@@ -14,40 +14,41 @@
// All three modes expose the same AuthContextValue shape, so the rest of the
// component tree doesn't need to know which mode is active.
-import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
-import { gql, useQuery } from '@apollo/client'
-import { useServerConfigQuery } from '@/__generated__/graphql'
+import { gql, useQuery } from "@apollo/client";
+import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
+
+import { useServerConfigQuery } from "@/__generated__/graphql";
// AuthUser matches the Identity type fields we care about for auth purposes.
export interface AuthUser {
- id: string
- humanId: string
- name: string | null
- displayName: string
- avatarUrl: string | null
- email: string | null
- login: string | null
+ id: string;
+ humanId: string;
+ name: string | null;
+ displayName: string;
+ avatarUrl: string | null;
+ email: string | null;
+ login: string | null;
}
// 'local' β single-user mode, identity from git config
// 'external' β multi-user mode, identity from OAuth/OIDC session
// 'readonly' β no identity, write operations disabled
-export type AuthMode = 'local' | 'external' | 'readonly'
+export type AuthMode = "local" | "external" | "readonly";
export interface AuthContextValue {
- user: AuthUser | null
- mode: AuthMode
+ user: AuthUser | null;
+ mode: AuthMode;
// List of enabled login provider names, e.g. ['github']. Only set in external mode.
- loginProviders: string[]
- loading: boolean
+ loginProviders: string[];
+ loading: boolean;
}
const AuthContext = createContext<AuthContextValue>({
user: null,
- mode: 'readonly',
+ mode: "readonly",
loginProviders: [],
loading: true,
-})
+});
// ββ Local mode ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@@ -65,23 +66,23 @@ const USER_IDENTITY_QUERY = gql`
}
}
}
-`
+`;
function LocalAuthProvider({
children,
loginProviders,
}: {
- children: ReactNode
- loginProviders: string[]
+ children: ReactNode;
+ loginProviders: string[];
}) {
- const { data, loading } = useQuery(USER_IDENTITY_QUERY)
- const user: AuthUser | null = data?.repository?.userIdentity ?? null
- const mode: AuthMode = loading ? 'local' : user ? 'local' : 'readonly'
+ const { data, loading } = useQuery(USER_IDENTITY_QUERY);
+ const user: AuthUser | null = data?.repository?.userIdentity ?? null;
+ const mode: AuthMode = loading ? "local" : user ? "local" : "readonly";
return (
<AuthContext.Provider value={{ user, mode, loginProviders, loading }}>
{children}
</AuthContext.Provider>
- )
+ );
}
// ββ External (OAuth / OIDC) mode ββββββββββββββββββββββββββββββββββββββββββββββ
@@ -93,31 +94,29 @@ function ExternalAuthProvider({
children,
loginProviders,
}: {
- children: ReactNode
- loginProviders: string[]
+ children: ReactNode;
+ loginProviders: string[];
}) {
- const [user, setUser] = useState<AuthUser | null>(null)
- const [loading, setLoading] = useState(true)
+ const [user, setUser] = useState<AuthUser | null>(null);
+ const [loading, setLoading] = useState(true);
useEffect(() => {
- fetch('/auth/user', { credentials: 'include' })
+ fetch("/auth/user", { credentials: "include" })
.then((res) => {
- if (res.status === 401) return null
- if (!res.ok) throw new Error(`/auth/user returned ${res.status}`)
- return res.json() as Promise<AuthUser>
+ if (res.status === 401) return null;
+ if (!res.ok) throw new Error(`/auth/user returned ${res.status}`);
+ return res.json() as Promise<AuthUser>;
})
.then((u) => setUser(u))
.catch(() => setUser(null))
- .finally(() => setLoading(false))
- }, [])
+ .finally(() => setLoading(false));
+ }, []);
return (
- <AuthContext.Provider
- value={{ user, mode: 'external', loginProviders, loading }}
- >
+ <AuthContext.Provider value={{ user, mode: "external", loginProviders, loading }}>
{children}
</AuthContext.Provider>
- )
+ );
}
// ββ Read-only mode ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@@ -125,11 +124,11 @@ function ExternalAuthProvider({
function ReadonlyAuthProvider({ children }: { children: ReactNode }) {
return (
<AuthContext.Provider
- value={{ user: null, mode: 'readonly', loginProviders: [], loading: false }}
+ value={{ user: null, mode: "readonly", loginProviders: [], loading: false }}
>
{children}
</AuthContext.Provider>
- )
+ );
}
// ββ Root provider βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@@ -137,41 +136,33 @@ function ReadonlyAuthProvider({ children }: { children: ReactNode }) {
// AuthProvider first fetches serverConfig to learn the auth mode, then renders
// the appropriate sub-provider. The split avoids conditional hook calls.
export function AuthProvider({ children }: { children: ReactNode }) {
- const { data, loading } = useServerConfigQuery()
+ const { data, loading } = useServerConfigQuery();
if (loading || !data) {
// Keep the default context (readonly + loading:true) while the config loads.
return (
<AuthContext.Provider
- value={{ user: null, mode: 'readonly', loginProviders: [], loading: true }}
+ value={{ user: null, mode: "readonly", loginProviders: [], loading: true }}
>
{children}
</AuthContext.Provider>
- )
+ );
}
- const { authMode, loginProviders } = data.serverConfig
+ const { authMode, loginProviders } = data.serverConfig;
- if (authMode === 'readonly') {
- return <ReadonlyAuthProvider>{children}</ReadonlyAuthProvider>
+ if (authMode === "readonly") {
+ return <ReadonlyAuthProvider>{children}</ReadonlyAuthProvider>;
}
- if (authMode === 'external') {
- return (
- <ExternalAuthProvider loginProviders={loginProviders}>
- {children}
- </ExternalAuthProvider>
- )
+ if (authMode === "external") {
+ return <ExternalAuthProvider loginProviders={loginProviders}>{children}</ExternalAuthProvider>;
}
// Default: 'local'
- return (
- <LocalAuthProvider loginProviders={loginProviders}>
- {children}
- </LocalAuthProvider>
- )
+ return <LocalAuthProvider loginProviders={loginProviders}>{children}</LocalAuthProvider>;
}
export function useAuth(): AuthContextValue {
- return useContext(AuthContext)
+ return useContext(AuthContext);
}
@@ -6,24 +6,24 @@
// - Read the current slug in any child component with useRepo().
// - Pass the slug as `ref` to all GraphQL repository queries.
-import { createContext, useContext } from 'react'
-import { useParams, Outlet } from 'react-router-dom'
+import { createContext, useContext } from "react";
+import { useParams, Outlet } from "react-router-dom";
-const RepoContext = createContext<string | null>(null)
+const RepoContext = createContext<string | null>(null);
// Route element for /:repo routes. Reads the :repo param and provides it
// via context so any descendant can call useRepo() without prop drilling.
export function RepoShell() {
- const { repo } = useParams<{ repo: string }>()
+ const { repo } = useParams<{ repo: string }>();
return (
<RepoContext.Provider value={repo ?? null}>
<Outlet />
</RepoContext.Provider>
- )
+ );
}
// Returns the current repo slug from the nearest RepoShell ancestor.
// Returns null when rendered outside of a /:repo route (e.g. the picker page).
export function useRepo(): string | null {
- return useContext(RepoContext)
+ return useContext(RepoContext);
}
@@ -1,38 +1,38 @@
-import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
+import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
-type Theme = 'light' | 'dark'
+type Theme = "light" | "dark";
interface ThemeContextValue {
- theme: Theme
- toggle: () => void
+ theme: Theme;
+ toggle: () => void;
}
const ThemeContext = createContext<ThemeContextValue>({
- theme: 'light',
+ theme: "light",
toggle: () => {},
-})
+});
function getInitialTheme(): Theme {
- const stored = localStorage.getItem('theme')
- if (stored === 'light' || stored === 'dark') return stored
- return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
+ const stored = localStorage.getItem("theme");
+ if (stored === "light" || stored === "dark") return stored;
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export function ThemeProvider({ children }: { children: ReactNode }) {
- const [theme, setTheme] = useState<Theme>(getInitialTheme)
+ const [theme, setTheme] = useState<Theme>(getInitialTheme);
useEffect(() => {
- document.documentElement.classList.toggle('dark', theme === 'dark')
- localStorage.setItem('theme', theme)
- }, [theme])
+ document.documentElement.classList.toggle("dark", theme === "dark");
+ localStorage.setItem("theme", theme);
+ }, [theme]);
function toggle() {
- setTheme((t) => (t === 'light' ? 'dark' : 'light'))
+ setTheme((t) => (t === "light" ? "dark" : "light"));
}
- return <ThemeContext.Provider value={{ theme, toggle }}>{children}</ThemeContext.Provider>
+ return <ThemeContext.Provider value={{ theme, toggle }}>{children}</ThemeContext.Provider>;
}
export function useTheme() {
- return useContext(ThemeContext)
+ return useContext(ThemeContext);
}
@@ -1,6 +1,6 @@
-import { type ClassValue, clsx } from 'clsx'
-import { twMerge } from 'tailwind-merge'
+import { type ClassValue, clsx } from "clsx";
+import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
- return twMerge(clsx(inputs))
+ return twMerge(clsx(inputs));
}
@@ -1,13 +1,15 @@
-import { StrictMode } from 'react'
-import { createRoot } from 'react-dom/client'
-import { ApolloProvider } from '@apollo/client'
-import './index.css'
-import { client } from '@/lib/apollo'
-import { AuthProvider } from '@/lib/auth'
-import { ThemeProvider } from '@/lib/theme'
-import { App } from './App'
+import { ApolloProvider } from "@apollo/client";
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
-createRoot(document.getElementById('root')!).render(
+import "./index.css";
+import { client } from "@/lib/apollo";
+import { AuthProvider } from "@/lib/auth";
+import { ThemeProvider } from "@/lib/theme";
+
+import { App } from "./App";
+
+createRoot(document.getElementById("root")!).render(
<StrictMode>
<ThemeProvider>
<ApolloProvider client={client}>
@@ -17,4 +19,4 @@ createRoot(document.getElementById('root')!).render(
</ApolloProvider>
</ThemeProvider>
</StrictMode>,
-)
+);
@@ -1,47 +1,46 @@
-import { useParams, Link } from 'react-router-dom'
-import { ArrowLeft } from 'lucide-react'
-import { formatDistanceToNow } from 'date-fns'
-import { Skeleton } from '@/components/ui/skeleton'
-import { Separator } from '@/components/ui/separator'
-import { StatusBadge } from '@/components/bugs/StatusBadge'
-import { LabelEditor } from '@/components/bugs/LabelEditor'
-import { TitleEditor } from '@/components/bugs/TitleEditor'
-import { Timeline } from '@/components/bugs/Timeline'
-import { CommentBox } from '@/components/bugs/CommentBox'
-import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
-import { useBugDetailQuery } from '@/__generated__/graphql'
-import { useRepo } from '@/lib/repo'
+import { formatDistanceToNow } from "date-fns";
+import { ArrowLeft } from "lucide-react";
+import { useParams, Link } from "react-router-dom";
+
+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<{ id: string }>()
- const repo = useRepo()
+ const { id } = useParams<{ id: string }>();
+ const repo = useRepo();
const { data, loading, error } = useBugDetailQuery({
variables: { ref: repo, prefix: id! },
- })
+ });
if (error) {
return (
<div className="py-16 text-center text-sm text-destructive">
Failed to load issue: {error.message}
</div>
- )
+ );
}
if (loading && !data) {
- return <BugDetailSkeleton />
+ return <BugDetailSkeleton />;
}
- const bug = data?.repository?.bug
+ const bug = data?.repository?.bug;
if (!bug) {
- return (
- <div className="py-16 text-center text-sm text-muted-foreground">Issue not found.</div>
- )
+ return <div className="py-16 text-center text-sm text-muted-foreground">Issue not found.</div>;
}
- const issuesHref = repo ? `/${repo}/issues` : '/issues'
- const authorHref = repo ? `/${repo}/user/${bug.author.humanId}` : `/user/${bug.author.humanId}`
+ const issuesHref = repo ? `/${repo}/issues` : "/issues";
+ const authorHref = repo ? `/${repo}/user/${bug.author.humanId}` : `/user/${bug.author.humanId}`;
return (
<div>
@@ -63,7 +62,7 @@ export function BugDetailPage() {
<span>
<Link to={authorHref} className="font-medium text-foreground hover:underline">
{bug.author.displayName}
- </Link>{' '}
+ </Link>{" "}
opened this issue {formatDistanceToNow(new Date(bug.createdAt), { addSuffix: true })}
</span>
</div>
@@ -89,7 +88,7 @@ export function BugDetailPage() {
</h3>
<div className="flex flex-wrap gap-1.5">
{bug.participants.nodes.map((p) => {
- const participantHref = repo ? `/${repo}/user/${p.humanId}` : `/user/${p.humanId}`
+ const participantHref = repo ? `/${repo}/user/${p.humanId}` : `/user/${p.humanId}`;
return (
<Link key={p.id} to={participantHref} title={p.displayName}>
<Avatar className="size-6">
@@ -99,14 +98,14 @@ export function BugDetailPage() {
</AvatarFallback>
</Avatar>
</Link>
- )
+ );
})}
</div>
</div>
</aside>
</div>
</div>
- )
+ );
}
function BugDetailSkeleton() {
@@ -130,5 +129,5 @@ function BugDetailSkeleton() {
</div>
</div>
</div>
- )
+ );
}
@@ -1,59 +1,75 @@
-import { useState, useEffect } from 'react'
-import { CircleDot, CircleCheck, ChevronLeft, ChevronRight } from 'lucide-react'
-import { Button } from '@/components/ui/button'
-import { Skeleton } from '@/components/ui/skeleton'
-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 { useBugListQuery } from '@/__generated__/graphql'
-import { cn } from '@/lib/utils'
-import { useRepo } from '@/lib/repo'
+import { CircleDot, CircleCheck, ChevronLeft, ChevronRight } from "lucide-react";
+import { useState, useEffect } from "react";
-const PAGE_SIZE = 25
+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";
-type StatusFilter = 'open' | 'closed'
+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[]>([])
+ 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)
+ 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'))
+ 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
+ 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 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] },
- })
+ 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
+ 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])
+ useEffect(() => {
+ setCursors([undefined]);
+ }, [listQuery]);
// Apply all filters at once, keeping draft in sync with the structured state.
function applyFilters(
@@ -64,13 +80,13 @@ export function BugListPage() {
text: string,
sortVal: SortValue = sort,
) {
- setStatusFilter(status)
- setSelectedLabels(labels)
- setSelectedAuthorId(authorId)
- setSelectedAuthorQuery(authorQuery)
- setFreeText(text)
- setSort(sortVal)
- setDraft(buildQueryString(status, labels, authorQuery, text, sortVal))
+ 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.
@@ -78,19 +94,19 @@ export function BugListPage() {
// 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)
+ 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])
+ const endCursor = bugs?.pageInfo.endCursor;
+ if (!endCursor) return;
+ setCursors((prev) => [...prev, endCursor]);
}
function goPrev() {
- setCursors((prev) => prev.slice(0, -1))
+ setCursors((prev) => prev.slice(0, -1));
}
return (
@@ -103,9 +119,7 @@ export function BugListPage() {
onSubmit={handleSearch}
placeholder="status:open author:β¦ label:β¦"
/>
- <Button type="submit">
- Search
- </Button>
+ <Button type="submit">Search</Button>
</form>
{/* List container */}
@@ -114,33 +128,59 @@ export function BugListPage() {
<div className="flex items-center gap-2 overflow-x-auto border-b border-border px-4 py-2">
<div className="flex shrink-0 items-center gap-1">
<button
- onClick={() => applyFilters('open', selectedLabels, selectedAuthorId, selectedAuthorQuery, freeText)}
+ 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',
+ "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')} />
+ <CircleDot
+ className={cn(
+ "size-4",
+ statusFilter === "open" && "text-green-600 dark:text-green-400",
+ )}
+ />
Open
- <span className="ml-0.5 rounded-full bg-muted px-1.5 py-0.5 text-xs leading-none tabular-nums">
+ <span className="ml-0.5 rounded-full bg-muted px-1.5 py-0.5 text-xs tabular-nums leading-none">
{openCount}
</span>
</button>
<button
- onClick={() => applyFilters('closed', selectedLabels, selectedAuthorId, selectedAuthorQuery, freeText)}
+ 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',
+ "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')} />
+ <CircleCheck
+ className={cn(
+ "size-4",
+ statusFilter === "closed" && "text-purple-600 dark:text-purple-400",
+ )}
+ />
Closed
- <span className="ml-0.5 rounded-full bg-muted px-1.5 py-0.5 text-xs leading-none tabular-nums">
+ <span className="ml-0.5 rounded-full bg-muted px-1.5 py-0.5 text-xs tabular-nums leading-none">
{closedCount}
</span>
</button>
@@ -149,12 +189,25 @@ export function BugListPage() {
<div className="ml-auto">
<IssueFilters
selectedLabels={selectedLabels}
- onLabelsChange={(labels) => applyFilters(statusFilter, labels, selectedAuthorId, selectedAuthorQuery, freeText)}
+ onLabelsChange={(labels) =>
+ applyFilters(statusFilter, labels, selectedAuthorId, selectedAuthorQuery, freeText)
+ }
selectedAuthorId={selectedAuthorId}
- onAuthorChange={(id, qv) => applyFilters(statusFilter, selectedLabels, id, qv, freeText)}
+ 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)}
+ onSortChange={(s) =>
+ applyFilters(
+ statusFilter,
+ selectedLabels,
+ selectedAuthorId,
+ selectedAuthorQuery,
+ freeText,
+ s,
+ )
+ }
/>
</div>
</div>
@@ -188,7 +241,13 @@ export function BugListPage() {
repo={repo}
onLabelClick={(name) => {
if (!selectedLabels.includes(name)) {
- applyFilters(statusFilter, [...selectedLabels, name], selectedAuthorId, selectedAuthorQuery, freeText)
+ applyFilters(
+ statusFilter,
+ [...selectedLabels, name],
+ selectedAuthorId,
+ selectedAuthorQuery,
+ freeText,
+ );
}
}}
/>
@@ -223,21 +282,21 @@ export function BugListPage() {
)}
</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[] = []
+ const parts: string[] = [];
for (const label of labels) {
- parts.push(label.includes(' ') ? `label:"${label}"` : `label:${label}`)
+ parts.push(label.includes(" ") ? `label:"${label}"` : `label:${label}`);
}
if (author) {
- parts.push(author.includes(' ') ? `author:"${author}"` : `author:${author}`)
+ parts.push(author.includes(" ") ? `author:"${author}"` : `author:${author}`);
}
- if (freeText.trim()) parts.push(freeText.trim())
- return parts.join(' ')
+ if (freeText.trim()) parts.push(freeText.trim());
+ return parts.join(" ");
}
// Build the structured query string sent to the GraphQL allBugs(query:) argument.
@@ -248,63 +307,68 @@ function buildQueryString(
labels: string[],
author: string | null,
freeText: string,
- sort: SortValue = 'creation-desc',
+ 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(' ')
+ 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
+ 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 (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
+ 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'])
+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
+ 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[] = []
+ 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)
+ 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 }
+ return { status, labels, author, freeText: free.join(" "), sort };
}
function BugListSkeleton() {
@@ -320,5 +384,5 @@ function BugListSkeleton() {
</div>
))}
</div>
- )
+ );
}
@@ -1,21 +1,22 @@
// Code browser page. Switches between tree view, file viewer, and commit
// history via ?type= search param. Ref is selected via ?ref=.
-import { useEffect } from 'react'
-import { useSearchParams } from 'react-router-dom'
-import { gql, useQuery } from '@apollo/client'
-import { AlertCircle, GitCommit } from 'lucide-react'
-import { CodeBreadcrumb } from '@/components/code/CodeBreadcrumb'
-import { RefSelector } from '@/components/code/RefSelector'
-import { FileTree } from '@/components/code/FileTree'
-import { FileViewer } from '@/components/code/FileViewer'
-import { CommitList } from '@/components/code/CommitList'
-import { Skeleton } from '@/components/ui/skeleton'
-import { Button } from '@/components/ui/button'
-import { useRepo } from '@/lib/repo'
-import { Markdown } from '@/components/content/Markdown'
-import type { GitRef, GitTreeEntry, GitBlob, GitLastCommit } from '@/__generated__/graphql'
-import type { TreeEntryWithCommit } from '@/components/code/FileTree'
+import { gql, useQuery } from "@apollo/client";
+import { AlertCircle, GitCommit } from "lucide-react";
+import { useEffect } from "react";
+import { useSearchParams } from "react-router-dom";
+
+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 { Button } from "@/components/ui/button";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useRepo } from "@/lib/repo";
const REFS_QUERY = gql`
query CodePageRefs($repo: String) {
@@ -32,7 +33,7 @@ const REFS_QUERY = gql`
}
}
}
-`
+`;
const TREE_QUERY = gql`
query CodePageTree($repo: String, $ref: String!, $path: String) {
@@ -44,7 +45,7 @@ const TREE_QUERY = gql`
}
}
}
-`
+`;
const LAST_COMMITS_QUERY = gql`
query CodePageLastCommits($repo: String, $ref: String!, $path: String, $names: [String!]!) {
@@ -60,7 +61,7 @@ const LAST_COMMITS_QUERY = gql`
}
}
}
-`
+`;
const BLOB_QUERY = gql`
query CodePageBlob($repo: String, $ref: String!, $path: String!) {
@@ -75,96 +76,112 @@ const BLOB_QUERY = gql`
}
}
}
-`
+`;
-type ViewMode = 'tree' | 'blob' | 'commits'
+type ViewMode = "tree" | "blob" | "commits";
export function CodePage() {
- const repo = useRepo()
- const [searchParams, setSearchParams] = useSearchParams()
+ const repo = useRepo();
+ const [searchParams, setSearchParams] = useSearchParams();
- const currentRef = searchParams.get('ref') ?? ''
- const currentPath = searchParams.get('path') ?? ''
- const viewMode: ViewMode = (searchParams.get('type') as ViewMode) ?? 'tree'
+ const currentRef = searchParams.get("ref") ?? "";
+ const currentPath = searchParams.get("path") ?? "";
+ const viewMode: ViewMode = (searchParams.get("type") as ViewMode) ?? "tree";
- const { data: refsData, loading: refsLoading, error: refsError } = useQuery(REFS_QUERY, {
+ const {
+ data: refsData,
+ loading: refsLoading,
+ error: refsError,
+ } = useQuery(REFS_QUERY, {
variables: { repo },
- })
- const refs: GitRef[] = refsData?.repository?.refs?.nodes ?? []
+ });
+ const refs: GitRef[] = refsData?.repository?.refs?.nodes ?? [];
// Set default ref from query result once loaded
useEffect(() => {
- if (refsLoading || refs.length === 0 || searchParams.get('ref')) return
- const defaultRef = refs.find((r: GitRef) => r.isDefault) ?? refs[0]
+ if (refsLoading || refs.length === 0 || searchParams.get("ref")) return;
+ const defaultRef = refs.find((r: GitRef) => r.isDefault) ?? refs[0];
if (defaultRef) {
setSearchParams(
- (prev) => { prev.set('ref', defaultRef.shortName); return prev },
+ (prev) => {
+ prev.set("ref", defaultRef.shortName);
+ return prev;
+ },
{ replace: true },
- )
+ );
}
- }, [refsLoading, refs.length]) // eslint-disable-line react-hooks/exhaustive-deps
+ }, [refsLoading, refs.length]); // eslint-disable-line react-hooks/exhaustive-deps
- const inTreeMode = viewMode === 'tree' && !!currentRef
- const inBlobMode = viewMode === 'blob' && !!currentRef && !!currentPath
+ const inTreeMode = viewMode === "tree" && !!currentRef;
+ const inBlobMode = viewMode === "blob" && !!currentRef && !!currentPath;
const { data: treeData, loading: treeLoading } = useQuery(TREE_QUERY, {
variables: { repo, ref: currentRef, path: currentPath || null },
skip: !inTreeMode,
- })
- const entries: GitTreeEntry[] = treeData?.repository?.tree ?? []
+ });
+ const entries: GitTreeEntry[] = treeData?.repository?.tree ?? [];
- const entryNames = entries.map((e: GitTreeEntry) => e.name)
+ const entryNames = entries.map((e: GitTreeEntry) => e.name);
const { data: lastCommitsData } = useQuery(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(BLOB_QUERY, {
variables: { repo, ref: currentRef, path: currentPath },
skip: !inBlobMode,
- })
- const blob: GitBlob | null = blobData?.repository?.blob ?? null
+ });
+ 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),
- )
+ (e: GitTreeEntry) => e.type === "BLOB" && /^readme(\.md|\.txt|\.rst)?$/i.test(e.name),
+ );
const readmePath = readmeEntry
- ? (currentPath ? `${currentPath}/${readmeEntry.name}` : readmeEntry.name)
- : null
+ ? currentPath
+ ? `${currentPath}/${readmeEntry.name}`
+ : readmeEntry.name
+ : null;
const { data: readmeBlobData } = useQuery(BLOB_QUERY, {
variables: { repo, ref: currentRef, path: readmePath },
skip: !inTreeMode || !readmePath,
- })
- const readme: string | null = readmeBlobData?.repository?.blob?.text ?? null
+ });
+ const readme: string | null = readmeBlobData?.repository?.blob?.text ?? null;
- const repoName = refsData?.repository?.name ?? repo ?? 'default-repo'
+ const repoName = refsData?.repository?.name ?? repo ?? "default-repo";
- function navigate(path: string, type: ViewMode = 'tree') {
- setSearchParams((prev) => { prev.set('path', path); prev.set('type', type); return prev })
+ function navigate(path: string, type: ViewMode = "tree") {
+ setSearchParams((prev) => {
+ prev.set("path", path);
+ prev.set("type", type);
+ return prev;
+ });
}
function handleEntryClick(entry: TreeEntryWithCommit) {
- const newPath = currentPath ? `${currentPath}/${entry.name}` : entry.name
- navigate(newPath, entry.type === 'BLOB' ? 'blob' : 'tree')
+ const newPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
+ navigate(newPath, entry.type === "BLOB" ? "blob" : "tree");
}
function handleNavigateUp() {
- const parts = currentPath.split('/').filter(Boolean)
- parts.pop()
- navigate(parts.join('/'), 'tree')
+ const parts = currentPath.split("/").filter(Boolean);
+ parts.pop();
+ navigate(parts.join("/"), "tree");
}
function handleRefSelect(ref: GitRef) {
setSearchParams((prev) => {
- prev.set('ref', ref.shortName); prev.set('path', ''); prev.set('type', 'tree'); return prev
- })
+ prev.set("ref", ref.shortName);
+ prev.set("path", "");
+ prev.set("type", "tree");
+ return prev;
+ });
}
if (refsError) {
@@ -174,7 +191,7 @@ export function CodePage() {
<p className="text-sm font-medium">Code browser unavailable</p>
<p className="max-w-sm text-xs text-muted-foreground">{refsError.message}</p>
</div>
- )
+ );
}
return (
@@ -187,15 +204,15 @@ export function CodePage() {
repoName={repoName}
ref={currentRef}
path={currentPath}
- onNavigate={(p) => navigate(p, 'tree')}
+ onNavigate={(p) => navigate(p, "tree")}
/>
)}
<div className="flex items-center gap-2">
{!refsLoading && (
<Button
- variant={viewMode === 'commits' ? 'secondary' : 'outline'}
+ variant={viewMode === "commits" ? "secondary" : "outline"}
size="sm"
- onClick={() => navigate(currentPath, viewMode === 'commits' ? 'tree' : 'commits')}
+ onClick={() => navigate(currentPath, viewMode === "commits" ? "tree" : "commits")}
>
<GitCommit className="size-3.5" />
History
@@ -209,9 +226,9 @@ export function CodePage() {
</div>
</div>
- {viewMode === 'commits' ? (
+ {viewMode === "commits" ? (
<CommitList ref_={currentRef} path={currentPath || undefined} />
- ) : viewMode === 'tree' || !blob ? (
+ ) : viewMode === "tree" || !blob ? (
<>
<FileTree
entries={entriesWithCommits}
@@ -235,5 +252,5 @@ export function CodePage() {
<FileViewer blob={blob} loading={blobLoading} />
)}
</div>
- )
+ );
}
@@ -1,13 +1,14 @@
// Commit detail page (/:repo/commit/:hash). Shows commit metadata, full
// message, parent links, and changed files with lazy diffs.
-import { Link, useParams, useNavigate } from 'react-router-dom'
-import { format } from 'date-fns'
-import { ArrowLeft, GitCommit } from 'lucide-react'
-import { gql, useQuery } from '@apollo/client'
-import { Skeleton } from '@/components/ui/skeleton'
-import { useRepo } from '@/lib/repo'
-import { FileDiffView } from '@/components/code/FileDiffView'
+import { gql, useQuery } from "@apollo/client";
+import { format } from "date-fns";
+import { ArrowLeft, GitCommit } from "lucide-react";
+import { Link, useParams, useNavigate } from "react-router-dom";
+
+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!) {
@@ -31,33 +32,33 @@ const COMMIT_QUERY = gql`
}
}
}
-`
+`;
export function CommitPage() {
- const { hash } = useParams<{ hash: string }>()
- const navigate = useNavigate()
- const repo = useRepo()
+ const { hash } = useParams<{ hash: string }>();
+ const navigate = useNavigate();
+ const repo = useRepo();
const { data, loading, error } = useQuery(COMMIT_QUERY, {
variables: { repo, hash },
skip: !hash,
- })
+ });
- if (loading) return <CommitPageSkeleton />
+ if (loading) return <CommitPageSkeleton />;
if (error) {
return (
<div className="py-16 text-center text-sm text-destructive">
Failed to load commit: {error.message}
</div>
- )
+ );
}
- const commit = data?.repository?.commit
- if (!commit) return null
+ const commit = data?.repository?.commit;
+ if (!commit) return null;
- const date = new Date(commit.date)
- const files = commit.files?.nodes ?? []
+ const date = new Date(commit.date);
+ const files = commit.files?.nodes ?? [];
return (
<div>
@@ -75,9 +76,9 @@ export function CommitPage() {
<h1 className="text-lg font-semibold leading-snug">{commit.message}</h1>
</div>
- {commit.fullMessage.includes('\n') && (
+ {commit.fullMessage.includes("\n") && (
<pre className="mb-4 ml-8 mt-3 whitespace-pre-wrap font-sans text-sm text-muted-foreground">
- {commit.fullMessage.split('\n').slice(1).join('\n').trim()}
+ {commit.fullMessage.split("\n").slice(1).join("\n").trim()}
</pre>
)}
@@ -86,7 +87,7 @@ export function CommitPage() {
<span className="font-medium text-foreground">{commit.authorName}</span>
{commit.authorEmail && <span> <{commit.authorEmail}></span>}
</span>
- <span title={date.toISOString()}>{format(date, 'PPP')}</span>
+ <span title={date.toISOString()}>{format(date, "PPP")}</span>
</div>
<div className="ml-8 mt-3 flex flex-wrap gap-3 text-xs">
@@ -95,7 +96,7 @@ export function CommitPage() {
</span>
{commit.parents.map((p: string) => (
<span key={p} className="text-muted-foreground">
- parent{' '}
+ parent{" "}
<Link
to={repo ? `/${repo}/commit/${p}` : `/commit/${p}`}
className="font-mono text-foreground hover:underline"
@@ -109,9 +110,9 @@ export function CommitPage() {
<div>
<h2 className="mb-3 text-sm font-semibold text-muted-foreground">
- {files.length} file{files.length !== 1 ? 's' : ''} changed
+ {files.length} file{files.length !== 1 ? "s" : ""} changed
</h2>
- <div className="overflow-hidden rounded-md border border-border divide-y divide-border">
+ <div className="divide-y divide-border overflow-hidden rounded-md border border-border">
{files.length === 0 && (
<p className="px-4 py-4 text-sm text-muted-foreground">No file changes.</p>
)}
@@ -127,19 +128,19 @@ export function CommitPage() {
</div>
</div>
</div>
- )
+ );
}
function CommitPageSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-4 w-24" />
- <div className="rounded-md border border-border p-5 space-y-3">
+ <div className="space-y-3 rounded-md border border-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="rounded-md border border-border divide-y divide-border">
+ <div className="divide-y divide-border rounded-md border border-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" />
@@ -148,5 +149,5 @@ function CommitPageSkeleton() {
))}
</div>
</div>
- )
+ );
}
@@ -2,35 +2,34 @@
// or when navigation results in a 404. Replaces the default "Unexpected
// Application Error!" screen.
-import { useRouteError, isRouteErrorResponse, Link } from 'react-router-dom'
-import { AlertTriangle } from 'lucide-react'
-import { Button } from '@/components/ui/button'
+import { AlertTriangle } from "lucide-react";
+import { useRouteError, isRouteErrorResponse, Link } from "react-router-dom";
+
+import { Button } from "@/components/ui/button";
export function ErrorPage() {
- const error = useRouteError()
+ const error = useRouteError();
- let status: number | undefined
- let message: string
+ let status: number | undefined;
+ let message: string;
if (isRouteErrorResponse(error)) {
- status = error.status
- message = error.statusText || error.data
+ status = error.status;
+ message = error.statusText || error.data;
} else if (error instanceof Error) {
- message = error.message
+ message = error.message;
} else {
- message = 'An unexpected error occurred.'
+ message = "An unexpected error occurred.";
}
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 text-center">
<AlertTriangle className="size-10 text-muted-foreground" />
- {status && (
- <p className="text-5xl font-bold tracking-tight">{status}</p>
- )}
+ {status && <p className="text-5xl font-bold tracking-tight">{status}</p>}
<p className="text-sm text-muted-foreground">{message}</p>
<Button variant="outline" size="sm" asChild>
<Link to="/">Go home</Link>
</Button>
</div>
- )
+ );
}
@@ -6,50 +6,51 @@
// OAuth account for future logins β or create a fresh one from their OAuth
// profile.
-import { useEffect, useState } from 'react'
-import { UserCircle, Plus, AlertCircle } from 'lucide-react'
-import { Button } from '@/components/ui/button'
-import { Skeleton } from '@/components/ui/skeleton'
+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
+ 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)
+ const [identities, setIdentities] = useState<IdentityItem[] | null>(null);
+ const [error, setError] = useState<string | null>(null);
+ const [working, setWorking] = useState(false);
useEffect(() => {
- fetch('/auth/identities', { credentials: 'include' })
+ fetch("/auth/identities", { credentials: "include" })
.then((res) => {
- if (!res.ok) throw new Error(`unexpected status ${res.status}`)
- return res.json() as Promise<IdentityItem[]>
+ if (!res.ok) throw new Error(`unexpected status ${res.status}`);
+ return res.json() as Promise<IdentityItem[]>;
})
.then(setIdentities)
- .catch((e) => setError(String(e)))
- }, [])
+ .catch((e) => setError(String(e)));
+ }, []);
async function adopt(identityId: string | null) {
- setWorking(true)
+ setWorking(true);
try {
- const res = await fetch('/auth/adopt', {
- method: 'POST',
- credentials: 'include',
- headers: { 'Content-Type': 'application/json' },
+ 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}`)
+ });
+ if (!res.ok) throw new Error(`adopt failed: ${res.status}`);
// Full page reload to reset Apollo cache and auth state cleanly.
- window.location.assign('/')
+ window.location.assign("/");
} catch (e) {
- setError(String(e))
- setWorking(false)
+ setError(String(e));
+ setWorking(false);
}
}
@@ -60,8 +61,8 @@ export function IdentitySelectPage() {
<h1 className="text-xl font-semibold">Choose your identity</h1>
</div>
<p className="mb-8 text-sm text-muted-foreground">
- 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.
+ 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 && (
@@ -85,14 +86,11 @@ export function IdentitySelectPage() {
<div className="min-w-0 flex-1">
<p className="font-medium">{id.displayName}</p>
<p className="text-xs text-muted-foreground">
- {id.login ? `@${id.login} Β· ` : ''}{id.repoSlug} Β· {id.humanId}
+ {id.login ? `@${id.login} Β· ` : ""}
+ {id.repoSlug} Β· {id.humanId}
</p>
</div>
- <Button
- size="sm"
- disabled={working}
- onClick={() => adopt(id.id)}
- >
+ <Button size="sm" disabled={working} onClick={() => adopt(id.id)}>
Adopt
</Button>
</div>
@@ -106,16 +104,12 @@ export function IdentitySelectPage() {
A fresh git-bug identity will be created from your OAuth profile.
</p>
</div>
- <Button
- size="sm"
- disabled={working}
- onClick={() => adopt(null)}
- >
+ <Button size="sm" disabled={working} onClick={() => adopt(null)}>
<Plus className="size-4" />
Create
</Button>
</div>
</div>
</div>
- )
+ );
}
@@ -1,36 +1,37 @@
-import { useState } from 'react'
-import { useNavigate, Link } from 'react-router-dom'
-import { ArrowLeft } from 'lucide-react'
-import { Button } from '@/components/ui/button'
-import { Input } from '@/components/ui/input'
-import { Textarea } from '@/components/ui/textarea'
-import { Markdown } from '@/components/content/Markdown'
-import { useBugCreateMutation } from '@/__generated__/graphql'
-import { useRepo } from '@/lib/repo'
+import { ArrowLeft } from "lucide-react";
+import { useState } from "react";
+import { useNavigate, Link } from "react-router-dom";
+
+import { useBugCreateMutation } from "@/__generated__/graphql";
+import { Markdown } from "@/components/content/Markdown";
+import { Button } from "@/components/ui/button";
+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 navigate = useNavigate();
+ const repo = useRepo();
+ const [title, setTitle] = useState("");
+ const [message, setMessage] = useState("");
+ const [preview, setPreview] = useState(false);
- const [createBug, { loading, error }] = useBugCreateMutation()
+ const [createBug, { loading, error }] = useBugCreateMutation();
async function handleSubmit(e: React.FormEvent) {
- e.preventDefault()
- if (!title.trim()) return
+ e.preventDefault();
+ if (!title.trim()) return;
const result = await createBug({
variables: { input: { title: title.trim(), message: message.trim() } },
- })
- const humanId = result.data?.bugCreate.bug.humanId
+ });
+ const humanId = result.data?.bugCreate.bug.humanId;
if (humanId) {
- navigate(repo ? `/${repo}/issues/${humanId}` : `/issues/${humanId}`)
+ navigate(repo ? `/${repo}/issues/${humanId}` : `/issues/${humanId}`);
}
}
- const issuesHref = repo ? `/${repo}/issues` : '/issues'
+ const issuesHref = repo ? `/${repo}/issues` : "/issues";
return (
<div className="mx-auto max-w-3xl">
@@ -67,7 +68,7 @@ export function NewBugPage() {
type="button"
onClick={() => setPreview(false)}
className={`rounded px-2 py-0.5 transition-colors ${
- !preview ? 'bg-muted font-medium' : 'text-muted-foreground hover:text-foreground'
+ !preview ? "bg-muted font-medium" : "text-muted-foreground hover:text-foreground"
}`}
>
Write
@@ -77,7 +78,7 @@ export function NewBugPage() {
onClick={() => setPreview(true)}
disabled={!message.trim()}
className={`rounded px-2 py-0.5 transition-colors disabled:opacity-40 ${
- preview ? 'bg-muted font-medium' : 'text-muted-foreground hover:text-foreground'
+ preview ? "bg-muted font-medium" : "text-muted-foreground hover:text-foreground"
}`}
>
Preview
@@ -105,14 +106,19 @@ export function NewBugPage() {
)}
<div className="flex justify-end gap-2">
- <Button type="button" variant="ghost" onClick={() => navigate(issuesHref)} disabled={loading}>
+ <Button
+ type="button"
+ variant="ghost"
+ onClick={() => navigate(issuesHref)}
+ disabled={loading}
+ >
Cancel
</Button>
<Button type="submit" disabled={!title.trim() || loading}>
- {loading ? 'Creatingβ¦' : 'Submit new issue'}
+ {loading ? "Creatingβ¦" : "Submit new issue"}
</Button>
</div>
</form>
</div>
- )
+ );
}
@@ -1,30 +1,31 @@
// Repository picker page (/). Auto-redirects when there is exactly one repo.
// Shows a list when multiple repos are registered.
-import { useEffect } from 'react'
-import { Link, useNavigate } from 'react-router-dom'
-import { GitFork, FolderOpen, AlertCircle } from 'lucide-react'
-import { Skeleton } from '@/components/ui/skeleton'
-import { useRepositoriesQuery } from '@/__generated__/graphql'
+import { GitFork, FolderOpen, AlertCircle } from "lucide-react";
+import { useEffect } from "react";
+import { Link, useNavigate } from "react-router-dom";
+
+import { useRepositoriesQuery } from "@/__generated__/graphql";
+import { Skeleton } from "@/components/ui/skeleton";
function repoSlug(name: string | null | undefined): string {
- return name ?? '_'
+ return name ?? "_";
}
function repoLabel(name: string | null | undefined): string {
- return name ?? 'default'
+ return name ?? "default";
}
export function RepoPickerPage() {
- const { data, loading, error } = useRepositoriesQuery()
- const navigate = useNavigate()
+ const { data, loading, error } = useRepositoriesQuery();
+ const navigate = useNavigate();
// Auto-redirect when there is exactly one repo β no need to pick.
useEffect(() => {
if (data?.repositories.nodes.length === 1) {
- navigate('/' + repoSlug(data.repositories.nodes[0].name), { replace: true })
+ navigate("/" + repoSlug(data.repositories.nodes[0].name), { replace: true });
}
- }, [data, navigate])
+ }, [data, navigate]);
return (
<div className="mx-auto max-w-lg py-12">
@@ -40,7 +41,7 @@ export function RepoPickerPage() {
</div>
)}
- {(loading && !data) && (
+ {loading && !data && (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full rounded-md" />
@@ -53,7 +54,7 @@ export function RepoPickerPage() {
<Link
key={repoSlug(repo.name)}
to={`/${repoSlug(repo.name)}`}
- className="flex items-center gap-3 px-4 py-4 hover:bg-muted/50 transition-colors"
+ className="flex items-center gap-3 px-4 py-4 transition-colors hover:bg-muted/50"
>
<FolderOpen className="size-5 shrink-0 text-muted-foreground" />
<p className="font-medium text-foreground">{repoLabel(repo.name)}</p>
@@ -67,5 +68,5 @@ export function RepoPickerPage() {
)}
</div>
</div>
- )
+ );
}
@@ -6,32 +6,38 @@
// The :id param is treated as a humanId prefix and passed directly to the
// identity(prefix) and allBugs(query:"author:...") GraphQL arguments.
-import { useState } from 'react'
-import { useParams, Link } from 'react-router-dom'
-import { formatDistanceToNow } from 'date-fns'
+import { formatDistanceToNow } from "date-fns";
import {
- ArrowLeft, MessageSquare, CircleDot, CircleCheck, ShieldCheck,
- ChevronLeft, ChevronRight,
-} from 'lucide-react'
-import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
-import { Button } from '@/components/ui/button'
-import { Skeleton } from '@/components/ui/skeleton'
-import { LabelBadge } from '@/components/bugs/LabelBadge'
-import { cn } from '@/lib/utils'
-import { Status, useUserProfileQuery } from '@/__generated__/graphql'
-import { useRepo } from '@/lib/repo'
+ ArrowLeft,
+ MessageSquare,
+ CircleDot,
+ CircleCheck,
+ ShieldCheck,
+ ChevronLeft,
+ ChevronRight,
+} from "lucide-react";
+import { useState } from "react";
+import { useParams, Link } from "react-router-dom";
-const PAGE_SIZE = 25
+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<{ id: string }>()
- const repo = useRepo()
- const [statusFilter, setStatusFilter] = useState<'open' | 'closed'>('open')
+ const { id } = useParams<{ id: string }>();
+ 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
+ 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
@@ -45,12 +51,12 @@ export function UserProfilePage() {
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
+ function switchStatus(next: "open" | "closed") {
+ if (next === statusFilter) return;
+ setStatusFilter(next);
+ setCursors([undefined]); // reset to page 1 on tab change
}
if (error) {
@@ -58,36 +64,34 @@ export function UserProfilePage() {
<div className="py-16 text-center text-sm text-destructive">
Failed to load profile: {error.message}
</div>
- )
+ );
}
- if (loading && !data) return <ProfileSkeleton />
+ if (loading && !data) return <ProfileSkeleton />;
- const identity = data?.repository?.identity
+ const identity = data?.repository?.identity;
if (!identity) {
- return (
- <div className="py-16 text-center text-sm text-muted-foreground">User not found.</div>
- )
+ return <div className="py-16 text-center text-sm text-muted-foreground">User not found.</div>;
}
- const openCount = data?.repository?.openCount.totalCount ?? 0
- const closedCount = data?.repository?.closedCount.totalCount ?? 0
+ 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
+ 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])
+ const cursor = bugs?.pageInfo.endCursor;
+ if (cursor) setCursors((prev) => [...prev, cursor]);
}
function goPrev() {
- setCursors((prev) => prev.slice(0, -1))
+ setCursors((prev) => prev.slice(0, -1));
}
- const issuesHref = repo ? `/${repo}/issues` : '/issues'
+ const issuesHref = repo ? `/${repo}/issues` : "/issues";
return (
<div>
@@ -143,15 +147,20 @@ export function UserProfilePage() {
{/* Open / Closed toggle β mirrors BugListPage style */}
<div className="flex items-center gap-1 border-b border-border px-4 py-2">
<button
- onClick={() => switchStatus('open')}
+ 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',
+ "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')} />
+ <CircleDot
+ className={cn(
+ "size-4",
+ statusFilter === "open" && "text-green-600 dark:text-green-400",
+ )}
+ />
Open
<span className="ml-0.5 rounded-full bg-muted px-1.5 py-0.5 text-xs leading-none">
{openCount}
@@ -159,15 +168,20 @@ export function UserProfilePage() {
</button>
<button
- onClick={() => switchStatus('closed')}
+ 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',
+ "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')} />
+ <CircleCheck
+ className={cn(
+ "size-4",
+ statusFilter === "closed" && "text-purple-600 dark:text-purple-400",
+ )}
+ />
Closed
<span className="ml-0.5 rounded-full bg-muted px-1.5 py-0.5 text-xs leading-none">
{closedCount}
@@ -182,8 +196,8 @@ export function UserProfilePage() {
)}
{bugs?.nodes.map((bug) => {
- const isOpen = bug.status === Status.Open
- const StatusIcon = isOpen ? CircleDot : CircleCheck
+ const isOpen = bug.status === Status.Open;
+ const StatusIcon = isOpen ? CircleDot : CircleCheck;
return (
<div
key={bug.id}
@@ -191,10 +205,10 @@ export function UserProfilePage() {
>
<StatusIcon
className={cn(
- 'mt-0.5 size-4 shrink-0',
+ "mt-0.5 size-4 shrink-0",
isOpen
- ? 'text-green-600 dark:text-green-400'
- : 'text-purple-600 dark:text-purple-400',
+ ? "text-green-600 dark:text-green-400"
+ : "text-purple-600 dark:text-purple-400",
)}
/>
<div className="min-w-0 flex-1">
@@ -210,7 +224,7 @@ export function UserProfilePage() {
))}
</div>
<p className="mt-0.5 text-xs text-muted-foreground">
- #{bug.humanId} opened{' '}
+ #{bug.humanId} opened{" "}
{formatDistanceToNow(new Date(bug.createdAt), { addSuffix: true })}
</p>
</div>
@@ -221,7 +235,7 @@ export function UserProfilePage() {
</div>
)}
</div>
- )
+ );
})}
{/* Pagination footer β only shown when there is more than one page */}
@@ -254,7 +268,7 @@ export function UserProfilePage() {
)}
</div>
</div>
- )
+ );
}
function ProfileSkeleton() {
@@ -274,5 +288,5 @@ function ProfileSkeleton() {
))}
</div>
</div>
- )
+ );
}
@@ -1,74 +1,71 @@
-import type { Config } from 'tailwindcss'
+import type { Config } from "tailwindcss";
const config: Config = {
- darkMode: ['class'],
- content: ['./index.html', './src/**/*.{ts,tsx}'],
+ darkMode: ["class"],
+ content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
fontFamily: {
- sans: ['ui-sans-serif', 'system-ui', 'sans-serif'],
- mono: ['ui-monospace', 'SFMono-Regular', 'SF Mono', 'Menlo', 'monospace'],
+ sans: ["ui-sans-serif", "system-ui", "sans-serif"],
+ mono: ["ui-monospace", "SFMono-Regular", "SF Mono", "Menlo", "monospace"],
},
colors: {
- border: 'hsl(var(--border))',
- input: 'hsl(var(--input))',
- ring: 'hsl(var(--ring))',
- background: 'hsl(var(--background))',
- foreground: 'hsl(var(--foreground))',
+ border: "hsl(var(--border))",
+ input: "hsl(var(--input))",
+ ring: "hsl(var(--ring))",
+ background: "hsl(var(--background))",
+ foreground: "hsl(var(--foreground))",
primary: {
- DEFAULT: 'hsl(var(--primary))',
- foreground: 'hsl(var(--primary-foreground))',
+ DEFAULT: "hsl(var(--primary))",
+ foreground: "hsl(var(--primary-foreground))",
},
secondary: {
- DEFAULT: 'hsl(var(--secondary))',
- foreground: 'hsl(var(--secondary-foreground))',
+ DEFAULT: "hsl(var(--secondary))",
+ foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
- DEFAULT: 'hsl(var(--destructive))',
- foreground: 'hsl(var(--destructive-foreground))',
+ DEFAULT: "hsl(var(--destructive))",
+ foreground: "hsl(var(--destructive-foreground))",
},
muted: {
- DEFAULT: 'hsl(var(--muted))',
- foreground: 'hsl(var(--muted-foreground))',
+ DEFAULT: "hsl(var(--muted))",
+ foreground: "hsl(var(--muted-foreground))",
},
accent: {
- DEFAULT: 'hsl(var(--accent))',
- foreground: 'hsl(var(--accent-foreground))',
+ DEFAULT: "hsl(var(--accent))",
+ foreground: "hsl(var(--accent-foreground))",
},
popover: {
- DEFAULT: 'hsl(var(--popover))',
- foreground: 'hsl(var(--popover-foreground))',
+ DEFAULT: "hsl(var(--popover))",
+ foreground: "hsl(var(--popover-foreground))",
},
card: {
- DEFAULT: 'hsl(var(--card))',
- foreground: 'hsl(var(--card-foreground))',
+ DEFAULT: "hsl(var(--card))",
+ foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
- lg: 'var(--radius)',
- md: 'calc(var(--radius) - 2px)',
- sm: 'calc(var(--radius) - 4px)',
+ lg: "var(--radius)",
+ md: "calc(var(--radius) - 2px)",
+ sm: "calc(var(--radius) - 4px)",
},
keyframes: {
- 'accordion-down': {
- from: { height: '0' },
- to: { height: 'var(--radix-accordion-content-height)' },
+ "accordion-down": {
+ from: { height: "0" },
+ to: { height: "var(--radix-accordion-content-height)" },
},
- 'accordion-up': {
- from: { height: 'var(--radix-accordion-content-height)' },
- to: { height: '0' },
+ "accordion-up": {
+ from: { height: "var(--radix-accordion-content-height)" },
+ to: { height: "0" },
},
},
animation: {
- 'accordion-down': 'accordion-down 0.2s ease-out',
- 'accordion-up': 'accordion-up 0.2s ease-out',
+ "accordion-down": "accordion-down 0.2s ease-out",
+ "accordion-up": "accordion-up 0.2s ease-out",
},
},
},
- plugins: [
- require('tailwindcss-animate'),
- require('@tailwindcss/typography'),
- ],
-}
+ plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
+};
-export default config
+export default config;
@@ -1 +1 @@
@@ -1,7 +1,4 @@
{
"files": [],
- "references": [
- { "path": "./tsconfig.app.json" },
- { "path": "./tsconfig.node.json" }
- ]
+ "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}
@@ -1 +1 @@
-{"root":["./vite.config.ts","./tailwind.config.ts","./codegen.ts"],"version":"5.9.3"}
+{"root":["./vite.config.ts","./tailwind.config.ts","./codegen.ts"],"version":"6.0.2"}
@@ -1,15 +1,16 @@
-import { defineConfig } from 'vite'
-import react from '@vitejs/plugin-react'
-import path from 'path'
+import path from "path";
+
+import react from "@vitejs/plugin-react";
+import { defineConfig } from "vite";
// The Go backend URL. Run: git-bug webui --port 3000
-const API_URL = process.env.VITE_API_URL || 'http://localhost:3000'
+const API_URL = process.env.VITE_API_URL || "http://localhost:3000";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
- '@': path.resolve(__dirname, './src'),
+ "@": path.resolve(__dirname, "./src"),
},
},
build: {
@@ -18,20 +19,20 @@ export default defineConfig({
rollupOptions: {
output: {
manualChunks: {
- 'vendor-react': ['react', 'react-dom', 'react-router-dom'],
- 'vendor-apollo': ['@apollo/client', 'graphql'],
- 'vendor-markdown': ['react-markdown', 'remark-gfm'],
- 'vendor-highlight': ['highlight.js'],
+ "vendor-react": ["react", "react-dom", "react-router-dom"],
+ "vendor-apollo": ["@apollo/client", "graphql"],
+ "vendor-markdown": ["react-markdown", "remark-gfm"],
+ "vendor-highlight": ["highlight.js"],
},
},
},
},
server: {
proxy: {
- '/graphql': { target: API_URL, changeOrigin: true },
- '/gitfile': { target: API_URL, changeOrigin: true },
- '/upload': { target: API_URL, changeOrigin: true },
-'/auth': { target: API_URL, changeOrigin: true },
+ "/graphql": { target: API_URL, changeOrigin: true },
+ "/gitfile": { target: API_URL, changeOrigin: true },
+ "/upload": { target: API_URL, changeOrigin: true },
+ "/auth": { target: API_URL, changeOrigin: true },
},
},
-})
+});