diff --git a/webui2/README.md b/webui2/README.md
index a9e1442799eb52b6119bfd255b306b7a773c0738..7cc6f4d6649deacd88c521a84a4442b295dd5b62 100644
--- a/webui2/README.md
+++ b/webui2/README.md
@@ -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 |
diff --git a/webui2/codegen.ts b/webui2/codegen.ts
index 94cf759a0786e9e7ce63d3cc1aec875925594bc0..ae11238110b44eb5677dbd7b9577c320fcc428f1 100644
--- a/webui2/codegen.ts
+++ b/webui2/codegen.ts
@@ -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;
diff --git a/webui2/postcss.config.js b/webui2/postcss.config.js
index 2e7af2b7f1a6f391da1631d93968a9d487ba977d..2aa7205d4b402a1bdfbe07110c61df920b370066 100644
--- a/webui2/postcss.config.js
+++ b/webui2/postcss.config.js
@@ -3,4 +3,4 @@ export default {
tailwindcss: {},
autoprefixer: {},
},
-}
+};
diff --git a/webui2/src/App.tsx b/webui2/src/App.tsx
index 8a904ed47b9dd9fb25bb49aed112506ac370f74d..bc94c95ec9b81ad0ff255ea782ab6e052796e9fe 100644
--- a/webui2/src/App.tsx
+++ b/webui2/src/App.tsx
@@ -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: ,
errorElement: ,
children: [
{ index: true, element: },
- { path: 'auth/select-identity', element: },
+ { path: "auth/select-identity", element: },
{
- path: ':repo',
+ path: ":repo",
element: ,
children: [
{ index: true, element: },
- { path: 'issues', element: },
- { path: 'issues/new', element: },
- { path: 'issues/:id', element: },
- { path: 'user/:id', element: },
- { path: 'commit/:hash', element: },
+ { path: "issues", element: },
+ { path: "issues/new", element: },
+ { path: "issues/:id", element: },
+ { path: "user/:id", element: },
+ { path: "commit/:hash", element: },
],
},
],
},
-])
+]);
export function App() {
- return
+ return ;
}
diff --git a/webui2/src/components/bugs/BugRow.tsx b/webui2/src/components/bugs/BugRow.tsx
index 3053b84433fba99bd8592b2eb792dd9528485578..e73afd58338f6c312aded50e15856396085b35d3 100644
--- a/webui2/src/components/bugs/BugRow.tsx
+++ b/webui2/src/components/bugs/BugRow.tsx
@@ -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 (
@@ -56,11 +58,16 @@ export function BugRow({
{title}
{labels.map((label) => (
-
+
))}
- #{humanId} opened {formatDistanceToNow(new Date(createdAt), { addSuffix: true })} by{' '}
+ #{humanId} opened {formatDistanceToNow(new Date(createdAt), { addSuffix: true })} by{" "}
{author.displayName}
@@ -74,5 +81,5 @@ export function BugRow({
)}
- )
+ );
}
diff --git a/webui2/src/components/bugs/CommentBox.tsx b/webui2/src/components/bugs/CommentBox.tsx
index 476b120303fa4b379e7db76aeafda1fbae1b2cc0..8edf8a67c3c115da2800f2c3cc6bcdbc8dc039d2 100644
--- a/webui2/src/components/bugs/CommentBox.tsx
+++ b/webui2/src/components/bugs/CommentBox.tsx
@@ -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 (
@@ -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"}
-
+
Comment
- )
+ );
}
diff --git a/webui2/src/components/bugs/IssueFilters.tsx b/webui2/src/components/bugs/IssueFilters.tsx
index 45d420ab561c7fd409b15a249d3c4f1d78b08be7..c90fa87dd3c82f6f48be49cafe2903f0599225f2 100644
--- a/webui2/src/components/bugs/IssueFilters.tsx
+++ b/webui2/src/components/bugs/IssueFilters.tsx
@@ -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()
- const result: typeof allIdentities = []
+ const pinned = new Set();
+ 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 (
{/* Label filter */}
-
{ if (!open) setLabelSearch('') }}>
+ {
+ if (!open) setLabelSearch("");
+ }}
+ >
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",
)}
>
@@ -170,7 +189,7 @@ export function IssueFilters({
-
+
{/* Search */}
@@ -187,7 +206,7 @@ export function IssueFilters({
No labels found
)}
{sortedLabels.map((label) => {
- const active = selectedLabels.includes(label.name)
+ const active = selectedLabels.includes(label.name);
return (
{active &&
}
- )
+ );
})}
{selectedLabels.length > 0 && (
@@ -222,12 +241,18 @@ export function IssueFilters({
{/* Author filter */}
- { if (!open) setAuthorSearch('') }}>
+ {
+ if (!open) setAuthorSearch("");
+ }}
+ >
{selectedAuthorIdentity ? (
@@ -252,7 +277,7 @@ export function IssueFilters({
-
+
{/* Search */}
@@ -266,14 +291,21 @@ export function IssueFilters({
{visibleIdentities.length === 0 && (
-
No authors found
+
+ No authors found
+
)}
{visibleIdentities.map((identity) => {
- const active = selectedAuthorId === identity.humanId
+ const active = selectedAuthorId === identity.humanId;
return (
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"
>
@@ -285,12 +317,14 @@ export function IssueFilters({
{identity.displayName}
{identity.login && identity.login !== identity.displayName && (
-
@{identity.login}
+
+ @{identity.login}
+
)}
{active && }
- )
+ );
})}
{!isSearching && allIdentities.length > INITIAL_AUTHOR_LIMIT && (
@@ -317,18 +351,18 @@ export function IssueFilters({
- {SORT_OPTIONS.find((o) => o.value === sort)?.label ?? 'Sort'}
+ {SORT_OPTIONS.find((o) => o.value === sort)?.label ?? "Sort"}
-
+
{SORT_OPTIONS.map((opt) => (
{opt.label}
- {sort === opt.value && }
+ {sort === opt.value && (
+
+ )}
))}
- )
+ );
}
diff --git a/webui2/src/components/bugs/LabelBadge.tsx b/webui2/src/components/bugs/LabelBadge.tsx
index 6ba207b5c7c6ac1be660c0d01555b5c8a9ebae74..fa2c98e3e20d3e0a44b2e539ed616a0b7d7c2755 100644
--- a/webui2/src/components/bugs/LabelBadge.tsx
+++ b/webui2/src/components/bugs/LabelBadge.tsx
@@ -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 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}
- )
+ );
}
return (
@@ -36,5 +40,5 @@ export function LabelBadge({ name, color, onClick }: LabelBadgeProps) {
>
{name}
- )
+ );
}
diff --git a/webui2/src/components/bugs/LabelEditor.tsx b/webui2/src/components/bugs/LabelEditor.tsx
index 5bae27c34afad6a10c340040c5a8923772dbd174..479cef6f6f2df3f2f702c3657565d18c7d23720b 100644
--- a/webui2/src/components/bugs/LabelEditor.tsx
+++ b/webui2/src/components/bugs/LabelEditor.tsx
@@ -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
-
- Apply labels
-
+ Apply labels
{validLabels.map((label) => {
- const active = currentNames.has(label.name)
+ const active = currentNames.has(label.name);
return (
- )
+ );
})}
@@ -93,5 +101,5 @@ export function LabelEditor({ bugPrefix, currentLabels, ref_ }: LabelEditorProps
)}
- )
+ );
}
diff --git a/webui2/src/components/bugs/QueryInput.tsx b/webui2/src/components/bugs/QueryInput.tsx
index 7e9a3343982e6c3865984898a7b8adc9eee9ff50..33124c134be9a0fd1dfacc377db61f9497209a1d 100644
--- a/webui2/src/components/bugs/QueryInput.tsx
+++ b/webui2/src/components/bugs/QueryInput.tsx
@@ -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 {seg.text}
+ if (seg.type === "space" || seg.type === "text") {
+ return {seg.text} ;
}
- 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 (
{key}
{val}
- )
+ );
}
// ── 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(null)
- const repo = useRepo()
+ const inputRef = useRef(null);
+ const repo = useRepo();
// Autocomplete state: null when the dropdown is hidden.
- const [completion, setCompletion] = useState(null)
+ const [completion, setCompletion] = useState(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) {
- 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) {
- updateCompletion(value, e.currentTarget.selectionStart ?? value.length)
+ updateCompletion(value, e.currentTarget.selectionStart ?? value.length);
}
// ── Keyboard navigation ───────────────────────────────────────────────────
function handleKeyDown(e: React.KeyboardEvent) {
- 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 (
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. */}
- {value === '' ? null : segments.map((seg, i) => renderSegment(seg, i))}
+ {value === "" ? null : segments.map((seg, i) => renderSegment(seg, i))}
{/* 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) => (
{ 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 }
/>
)}
{s.completedToken}
- {s.display !== s.completedToken.split(':')[1]?.replace(/"/g, '') && (
+ {s.display !== s.completedToken.split(":")[1]?.replace(/"/g, "") && (
{s.display}
)}
@@ -337,5 +354,5 @@ export function QueryInput({ value, onChange, onSubmit, placeholder, className }
)}
- )
+ );
}
diff --git a/webui2/src/components/bugs/StatusBadge.tsx b/webui2/src/components/bugs/StatusBadge.tsx
index e2c7bd8af39f729b1e727958e56be10d96a04904..e57a555cd06d741eda3fa53ba7549e32e7a8651d 100644
--- a/webui2/src/components/bugs/StatusBadge.tsx
+++ b/webui2/src/components/bugs/StatusBadge.tsx
@@ -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 (
{isOpen ? : }
- {isOpen ? 'Open' : 'Closed'}
+ {isOpen ? "Open" : "Closed"}
- )
+ );
}
diff --git a/webui2/src/components/bugs/Timeline.tsx b/webui2/src/components/bugs/Timeline.tsx
index 4bda0f626b278b837acf7faecd5c384f16736769..f4a3776fd40549529da8b4992ebe208e657994c5 100644
--- a/webui2/src/components/bugs/Timeline.tsx
+++ b/webui2/src/components/bugs/Timeline.tsx
@@ -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['bug']>['timeline']['nodes'][number]
->
+ NonNullable["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) {
{items.map((item) => {
switch (item.__typename) {
- case 'BugCreateTimelineItem':
- case 'BugAddCommentTimelineItem':
- return
- case 'BugLabelChangeTimelineItem':
- return
- case 'BugSetStatusTimelineItem':
- return
- case 'BugSetTitleTimelineItem':
- return
+ case "BugCreateTimelineItem":
+ case "BugAddCommentTimelineItem":
+ return ;
+ case "BugLabelChangeTimelineItem":
+ return ;
+ case "BugSetStatusTimelineItem":
+ return ;
+ case "BugSetTitleTimelineItem":
+ return ;
default:
- return null
+ return null;
}
})}
- )
+ );
}
// ── 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 (
@@ -95,15 +97,16 @@ function CommentItem({ item, bugPrefix }: { item: CommentItem; bugPrefix: string
-
+
{item.author.displayName}
{formatDistanceToNow(new Date(item.createdAt), { addSuffix: true })}
- {item.edited && !editing && (
-
edited
- )}
+ {item.edited && !editing &&
edited }
{canEdit && !editing && (
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();
+ }
}}
/>
- {loading ? 'Saving…' : 'Save'}
+ {loading ? "Saving…" : "Save"}
Cancel
@@ -147,14 +153,14 @@ function CommentItem({ item, bugPrefix }: { item: CommentItem; bugPrefix: string
)}
- )
+ );
}
// ── Inline events ─────────────────────────────────────────────────────────────
-type LabelChangeItem = Extract
-type StatusChangeItem = Extract
-type TitleChangeItem = Extract
+type LabelChangeItem = Extract;
+type StatusChangeItem = Extract;
+type TitleChangeItem = Extract;
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
{icon}
{children}
- )
+ );
}
function LabelChangeItem({ item }: { item: LabelChangeItem }) {
- const repo = useRepo()
+ const repo = useRepo();
return (
}>
- {item.author.displayName}{' '}
+
+ {item.author.displayName}
+ {" "}
{item.added.length > 0 && (
<>
- added{' '}
+ added{" "}
{item.added.map((l) => (
- ))}{' '}
+ ))}{" "}
>
)}
{item.removed.length > 0 && (
<>
- removed{' '}
+ removed{" "}
{item.removed.map((l) => (
- ))}{' '}
+ ))}{" "}
>
)}
{formatDistanceToNow(new Date(item.date), { addSuffix: true })}
- )
+ );
}
function StatusChangeItem({ item }: { item: StatusChangeItem }) {
- const repo = useRepo()
- const isOpen = item.status === Status.Open
+ const repo = useRepo();
+ const isOpen = item.status === Status.Open;
return (
- {item.author.displayName}{' '}
- {isOpen ? 'reopened' : 'closed'} this{' '}
+
+ {item.author.displayName}
+ {" "}
+ {isOpen ? "reopened" : "closed"} this{" "}
{formatDistanceToNow(new Date(item.date), { addSuffix: true })}
- )
+ );
}
function TitleChangeItem({ item }: { item: TitleChangeItem }) {
- const repo = useRepo()
+ const repo = useRepo();
return (
}>
- {item.author.displayName} changed the
- title from {item.was} to{' '}
- {item.title} {' '}
+
+ {item.author.displayName}
+ {" "}
+ changed the title from {item.was} to{" "}
+ {item.title} {" "}
{formatDistanceToNow(new Date(item.date), { addSuffix: true })}
- )
+ );
}
diff --git a/webui2/src/components/bugs/TitleEditor.tsx b/webui2/src/components/bugs/TitleEditor.tsx
index a0769036d066f2ea74967d043bd49fda12b97627..dfe7036567cb592b98d53ae33da421ee22625eb6 100644
--- a/webui2/src/components/bugs/TitleEditor.tsx
+++ b/webui2/src/components/bugs/TitleEditor.tsx
@@ -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
(null)
+ const { user } = useAuth();
+ const [editing, setEditing] = useState(false);
+ const [value, setValue] = useState(title);
+ const inputRef = useRef(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
- )
+ );
}
return (
@@ -94,5 +95,5 @@ export function TitleEditor({ bugPrefix, title, humanId, ref_ }: TitleEditorProp
)}
- )
+ );
}
diff --git a/webui2/src/components/code/CodeBreadcrumb.tsx b/webui2/src/components/code/CodeBreadcrumb.tsx
index c6df620e69289cf3c1bd533db34eab54d71f7ba8..3c008230acfe7755ac8e4bb4098d088217971c48 100644
--- a/webui2/src/components/code/CodeBreadcrumb.tsx
+++ b/webui2/src/components/code/CodeBreadcrumb.tsx
@@ -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 (
onNavigate('')}
+ onClick={() => onNavigate("")}
className="font-medium text-foreground hover:underline"
>
{repoName}
{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 (
@@ -39,10 +39,10 @@ export function CodeBreadcrumb({ repoName, ref, path, onNavigate }: CodeBreadcru
)}
- )
+ );
})}
@ {ref}
- )
+ );
}
diff --git a/webui2/src/components/code/CommitList.tsx b/webui2/src/components/code/CommitList.tsx
index 588916acfdff8d04dbf5be59d86af4e4f2afcaaa..a1ec4696e15de5f635992731dd4915c4c751d61f 100644
--- a/webui2/src/components/code/CommitList.tsx
+++ b/webui2/src/components/code/CommitList.tsx
@@ -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(null)
- const [allCommits, setAllCommits] = useState([])
+ const repo = useRepo();
+ const [cursor, setCursor] = useState(null);
+ const [allCommits, setAllCommits] = useState([]);
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
+ if (loading) return ;
if (error) {
return (
{error.message}
- )
+ );
}
- const groups = groupByDate(allCommits)
+ const groups = groupByDate(allCommits);
return (
@@ -94,7 +97,7 @@ export function CommitList({ ref_, path }: CommitListProps) {
Commits on {date}
-
+
{group.map((commit) => (
))}
@@ -105,16 +108,16 @@ export function CommitList({ ref_, path }: CommitListProps) {
{hasMore && (
- {loadingMore ? 'Loading…' : 'Load more commits'}
+ {loadingMore ? "Loading…" : "Load more commits"}
)}
- )
+ );
}
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 (
@@ -126,7 +129,7 @@ function CommitRow({ commit, repo }: { commit: CommitNode; repo: string | null }
{commit.message}
- {commit.authorName} ·{' '}
+ {commit.authorName} ·{" "}
{formatDistanceToNow(new Date(commit.date), { addSuffix: true })}
@@ -138,22 +141,22 @@ function CommitRow({ commit, repo }: { commit: CommitNode; repo: string | null }
{commit.shortHash}
- )
+ );
}
function groupByDate(commits: CommitNode[]): [string, CommitNode[]][] {
- const map = new Map
()
+ const map = new Map();
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) => (
-
+
{Array.from({ length: 4 }).map((_, i) => (
@@ -177,5 +180,5 @@ function CommitListSkeleton() {
))}
- )
+ );
}
diff --git a/webui2/src/components/code/FileDiffView.tsx b/webui2/src/components/code/FileDiffView.tsx
index 7eaf02674636e12dc6db0039537bde2a4a0dd858..fb55a105a34ad1e247220461f762c8d2bfda68aa 100644
--- a/webui2/src/components/code/FileDiffView.tsx
+++ b/webui2/src/components/code/FileDiffView.tsx
@@ -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
= {
- ADDED: ,
- DELETED: ,
- MODIFIED: ,
- RENAMED: ,
-}
-const statusBadge: Record = { ADDED: 'A', DELETED: 'D', MODIFIED: 'M', RENAMED: 'R' }
+ ADDED: ,
+ DELETED: ,
+ MODIFIED: ,
+ RENAMED: ,
+};
+const statusBadge: Record = {
+ 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 (
{statusIcon[status] ?? }
- {status === 'RENAMED' ? (
+ {status === "RENAMED" ? (
<>
{oldPath}
- {' → '}
+ {" → "}
{path}
>
- ) : path}
+ ) : (
+ path
+ )}
- {statusBadge[status] ?? '?'}
+ {statusBadge[status] ?? "?"}
{open && (
- {loading && (
-
Loading diff…
- )}
+ {loading &&
Loading diff…
}
{error && (
-
Failed to load diff: {error.message}
+
+ Failed to load diff: {error.message}
+
)}
- {diff && (
- diff.isBinary ? (
+ {diff &&
+ (diff.isBinary ? (
Binary file
) : diff.hunks.length === 0 ? (
No changes
) : (
diff.hunks.map((hunk: HunkType, i: number) =>
)
- )
- )}
+ ))}
)}
- )
+ );
}
-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 (
-
+
@@ -{hunk.oldStart},{hunk.oldLines} +{hunk.newStart},{hunk.newLines} @@
{hunk.lines.map((line, i) => (
- {line.oldLine || ''}
+ {line.oldLine || ""}
- {line.newLine || ''}
+ {line.newLine || ""}
-
- {line.type === 'ADDED' ? '+' : line.type === 'DELETED' ? '-' : ' '}
+
+ {line.type === "ADDED" ? "+" : line.type === "DELETED" ? "-" : " "}
-
+
{line.content}
))}
- )
+ );
}
diff --git a/webui2/src/components/code/FileTree.tsx b/webui2/src/components/code/FileTree.tsx
index 5b1d4bf8851c083c51b32cf92ddf7b00a36349b0..5d1090ecefcdcb7dd20b14f37980853263503fda 100644
--- a/webui2/src/components/code/FileTree.tsx
+++ b/webui2/src/components/code/FileTree.tsx
@@ -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
+ if (loading) return
;
return (
{path && (
-
+
@@ -56,24 +54,21 @@ export function FileTree({ entries, path, loading, onNavigate, onNavigateUp }: F
- )
+ );
}
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 (
-
onNavigate(entry)}
- >
+ onNavigate(entry)}>
{isDir ? (
@@ -82,14 +77,16 @@ function FileTreeRow({
)}
-
+
{entry.name}
{entry.lastCommit && (
e.stopPropagation()}
>
@@ -102,7 +99,7 @@ function FileTreeRow({
formatDistanceToNow(new Date(entry.lastCommit.date), { addSuffix: true })}
- )
+ );
}
function FileTreeSkeleton() {
@@ -119,5 +116,5 @@ function FileTreeSkeleton() {
))}
- )
+ );
}
diff --git a/webui2/src/components/code/FileViewer.tsx b/webui2/src/components/code/FileViewer.tsx
index 605d356f170d6f0bed929b37623781c864305a9a..1e6d8d1410b530288c4f4115466f5eea2c8c0091 100644
--- a/webui2/src/components/code/FileViewer.tsx
+++ b/webui2/src/components/code/FileViewer.tsx
@@ -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
- const { html, lineCount } = highlighted
+ if (loading || highlighted === null) return
;
+ 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) {
{lineCount.toLocaleString()} lines · {formatBytes(blob.size)}
- {blob.isTruncated && ' · truncated'}
+ {blob.isTruncated && " · truncated"}
-
+
@@ -75,13 +84,13 @@ export function FileViewer({ blob, loading }: FileViewerProps) {
)}
- )
+ );
}
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() {
- )
+ );
}
diff --git a/webui2/src/components/code/RefSelector.tsx b/webui2/src/components/code/RefSelector.tsx
index f6d86230c4a3346bf2282a2a6b2f6972cdc46155..90d38e3d454bf93eb5e81f8cbce2d315defa9f41 100644
--- a/webui2/src/components/code/RefSelector.tsx
+++ b/webui2/src/components/code/RefSelector.tsx
@@ -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 (
@@ -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("");
+ }}
/>
))}
@@ -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("");
+ }}
/>
))}
@@ -75,7 +82,7 @@ export function RefSelector({ refs, currentRef, onSelect }: RefSelectorProps) {
- )
+ );
}
function RefItem({
@@ -83,19 +90,19 @@ function RefItem({
active,
onSelect,
}: {
- ref_: GitRef
- active: boolean
- onSelect: () => void
+ ref_: GitRef;
+ active: boolean;
+ onSelect: () => void;
}) {
return (
- {ref_.type === 'BRANCH' ? (
+ {ref_.type === "BRANCH" ? (
) : (
@@ -103,5 +110,5 @@ function RefItem({
{ref_.shortName}
{active && }
- )
+ );
}
diff --git a/webui2/src/components/content/Markdown.tsx b/webui2/src/components/content/Markdown.tsx
index 43f05235cf3aca507085f42ee1b23f2bb70adbff..1e62bfd9c5a36e6312aa81f2099d11143c4a436f 100644
--- a/webui2/src/components/content/Markdown.tsx
+++ b/webui2/src/components/content/Markdown.tsx
@@ -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}
- )
+ );
}
diff --git a/webui2/src/components/layout/Header.tsx b/webui2/src/components/layout/Header.tsx
index c254b8de24209b2cd3f9bb3812315561607a5cda..8c2046675f997a73e95ecd3704f0c92c47acddc3 100644
--- a/webui2/src/components/layout/Header.tsx
+++ b/webui2/src/components/layout/Header.tsx
@@ -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 (
- )
+ );
}
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 (
@@ -57,23 +58,27 @@ export function Header() {
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
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
@@ -81,23 +86,23 @@ export function Header() {
)}
- {mode === 'readonly' && (
-
Read only
- )}
+ {mode === "readonly" &&
Read only }
- {theme === 'light' ? : }
+ {theme === "light" ? : }
{/* External mode: show sign-in buttons when logged out */}
- {mode === 'external' && !user && loginProviders.map((p) => (
-
-
-
- Sign in with {providerLabel(p)}
-
-
- ))}
+ {mode === "external" &&
+ !user &&
+ loginProviders.map((p) => (
+
+
+
+ Sign in with {providerLabel(p)}
+
+
+ ))}
{user && effectiveRepo && (
<>
@@ -119,14 +124,14 @@ export function Header() {
)}
{/* Sign out only shown in external mode when logged in */}
- {mode === 'external' && user &&
}
+ {mode === "external" && user &&
}
- )
+ );
}
function providerLabel(name: string): string {
- const labels: Record = { github: 'GitHub', gitlab: 'GitLab', gitea: 'Gitea' }
- return labels[name] ?? name
+ const labels: Record = { github: "GitHub", gitlab: "GitLab", gitea: "Gitea" };
+ return labels[name] ?? name;
}
diff --git a/webui2/src/components/layout/Shell.tsx b/webui2/src/components/layout/Shell.tsx
index 702816f340fede2785108f21911ac057dd1bcd90..39cfc54cc9e681a3ceacdfc3f2672147e9a60475 100644
--- a/webui2/src/components/layout/Shell.tsx
+++ b/webui2/src/components/layout/Shell.tsx
@@ -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 .
@@ -11,5 +12,5 @@ export function Shell() {
- )
+ );
}
diff --git a/webui2/src/components/ui/avatar.tsx b/webui2/src/components/ui/avatar.tsx
index bee491505642258dbad2ff01950b2ee8df5b5aec..902dbc49eb9ebac3d744c516d1da9fc814de940c 100644
--- a/webui2/src/components/ui/avatar.tsx
+++ b/webui2/src/components/ui/avatar.tsx
@@ -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,
@@ -8,11 +9,11 @@ const Avatar = React.forwardRef<
>(({ className, ...props }, ref) => (
-))
-Avatar.displayName = AvatarPrimitive.Root.displayName
+));
+Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef,
@@ -20,11 +21,11 @@ const AvatarImage = React.forwardRef<
>(({ className, ...props }, ref) => (
-))
-AvatarImage.displayName = AvatarPrimitive.Image.displayName
+));
+AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef,
@@ -33,12 +34,12 @@ const AvatarFallback = React.forwardRef<
-))
-AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
+));
+AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
-export { Avatar, AvatarImage, AvatarFallback }
+export { Avatar, AvatarImage, AvatarFallback };
diff --git a/webui2/src/components/ui/badge.tsx b/webui2/src/components/ui/badge.tsx
index 92d97c72ad67285dc4f6fefa80388ecd6c8493c3..3493483ccab7b0f201a568148cddab57d3df1d0e 100644
--- a/webui2/src/components/ui/badge.tsx
+++ b/webui2/src/components/ui/badge.tsx
@@ -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,
- VariantProps {}
+ extends React.HTMLAttributes, VariantProps {}
function Badge({ className, variant, ...props }: BadgeProps) {
- return
+ return
;
}
-export { Badge, badgeVariants }
+export { Badge, badgeVariants };
diff --git a/webui2/src/components/ui/button.tsx b/webui2/src/components/ui/button.tsx
index b554d64a4fd51d87057ab7fbb6b61760d2bba93a..85d130dce812d7aa21b215c3de139adfe34b7e9e 100644
--- a/webui2/src/components/ui/button.tsx
+++ b/webui2/src/components/ui/button.tsx
@@ -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,
- VariantProps {
- asChild?: boolean
+ extends React.ButtonHTMLAttributes, VariantProps {
+ asChild?: boolean;
}
const Button = React.forwardRef(
({ className, variant, size, asChild = false, ...props }, ref) => {
- const Comp = asChild ? Slot : 'button'
+ const Comp = asChild ? Slot : "button";
return (
-
- )
+
+ );
},
-)
-Button.displayName = 'Button'
+);
+Button.displayName = "Button";
-export { Button, buttonVariants }
+export { Button, buttonVariants };
diff --git a/webui2/src/components/ui/input.tsx b/webui2/src/components/ui/input.tsx
index 35b75bde5a61d025574aa17adc6320d0f4778d91..7db524115c08e80b10d5282d1bb504b06b238d17 100644
--- a/webui2/src/components/ui/input.tsx
+++ b/webui2/src/components/ui/input.tsx
@@ -1,21 +1,22 @@
-import * as React from 'react'
-import { cn } from '@/lib/utils'
+import * as React from "react";
-const Input = React.forwardRef>(
+import { cn } from "@/lib/utils";
+
+const Input = React.forwardRef>(
({ className, type, ...props }, ref) => {
return (
- )
+ );
},
-)
-Input.displayName = 'Input'
+);
+Input.displayName = "Input";
-export { Input }
+export { Input };
diff --git a/webui2/src/components/ui/popover.tsx b/webui2/src/components/ui/popover.tsx
index 95879cdcac809379fcd239c50fa603681cec7361..630ccb540eee1c4fab3e6f9780735bc74d5c7cca 100644
--- a/webui2/src/components/ui/popover.tsx
+++ b/webui2/src/components/ui/popover.tsx
@@ -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,
React.ComponentPropsWithoutRef
->(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
+>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
-))
-PopoverContent.displayName = PopoverPrimitive.Content.displayName
+));
+PopoverContent.displayName = PopoverPrimitive.Content.displayName;
-export { Popover, PopoverTrigger, PopoverAnchor, PopoverContent }
+export { Popover, PopoverTrigger, PopoverAnchor, PopoverContent };
diff --git a/webui2/src/components/ui/separator.tsx b/webui2/src/components/ui/separator.tsx
index 75af9e79319d013d177c98802ea4a0eacd69956b..053e61e54ca7771a19ebb68da128de0b3399326b 100644
--- a/webui2/src/components/ui/separator.tsx
+++ b/webui2/src/components/ui/separator.tsx
@@ -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,
React.ComponentPropsWithoutRef
->(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
+>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
-))
-Separator.displayName = SeparatorPrimitive.Root.displayName
+));
+Separator.displayName = SeparatorPrimitive.Root.displayName;
-export { Separator }
+export { Separator };
diff --git a/webui2/src/components/ui/skeleton.tsx b/webui2/src/components/ui/skeleton.tsx
index 6654d1def0799a4f5d0136578584e35d61158e7d..6d9a616758e39fcbc07480484a308ffb9d9fba5d 100644
--- a/webui2/src/components/ui/skeleton.tsx
+++ b/webui2/src/components/ui/skeleton.tsx
@@ -1,12 +1,7 @@
-import { cn } from '@/lib/utils'
+import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.HTMLAttributes) {
- return (
-
- )
+ return
;
}
-export { Skeleton }
+export { Skeleton };
diff --git a/webui2/src/components/ui/textarea.tsx b/webui2/src/components/ui/textarea.tsx
index 60a4370d9f9db3eb62767e91cdf7279c8e318a8b..7206f8fcac690fd220fab7cc84e73ca3dac7323b 100644
--- a/webui2/src/components/ui/textarea.tsx
+++ b/webui2/src/components/ui/textarea.tsx
@@ -1,20 +1,21 @@
-import * as React from 'react'
-import { cn } from '@/lib/utils'
+import * as React from "react";
-const Textarea = React.forwardRef>(
+import { cn } from "@/lib/utils";
+
+const Textarea = React.forwardRef>(
({ className, ...props }, ref) => {
return (
- )
+ );
},
-)
-Textarea.displayName = 'Textarea'
+);
+Textarea.displayName = "Textarea";
-export { Textarea }
+export { Textarea };
diff --git a/webui2/src/index.css b/webui2/src/index.css
index d06470cac6a28d7d6783d2f55debb8e110a57200..ee5f8be1b2680b2df7eebac40c0afedb1eb80dfd 100644
--- a/webui2/src/index.css
+++ b/webui2/src/index.css
@@ -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;
+}
diff --git a/webui2/src/lib/apollo.ts b/webui2/src/lib/apollo.ts
index 0b45f950e0a42b36b60eb193ce865610dfb8b3fd..991ead2ec2ad65981110ef4f88c25a4db157d61b 100644
--- a/webui2/src/lib/apollo.ts
+++ b/webui2/src/lib/apollo.ts
@@ -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({
},
},
}),
-})
+});
diff --git a/webui2/src/lib/auth.tsx b/webui2/src/lib/auth.tsx
index 58724cb4dd5f577e1949ab7fd18764a08278a965..6b8d4bc5440d402b0103d25fc9ef1385979c5a0f 100644
--- a/webui2/src/lib/auth.tsx
+++ b/webui2/src/lib/auth.tsx
@@ -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({
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 (
{children}
- )
+ );
}
// ── External (OAuth / OIDC) mode ──────────────────────────────────────────────
@@ -93,31 +94,29 @@ function ExternalAuthProvider({
children,
loginProviders,
}: {
- children: ReactNode
- loginProviders: string[]
+ children: ReactNode;
+ loginProviders: string[];
}) {
- const [user, setUser] = useState(null)
- const [loading, setLoading] = useState(true)
+ const [user, setUser] = useState(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
+ if (res.status === 401) return null;
+ if (!res.ok) throw new Error(`/auth/user returned ${res.status}`);
+ return res.json() as Promise;
})
.then((u) => setUser(u))
.catch(() => setUser(null))
- .finally(() => setLoading(false))
- }, [])
+ .finally(() => setLoading(false));
+ }, []);
return (
-
+
{children}
- )
+ );
}
// ── Read-only mode ────────────────────────────────────────────────────────────
@@ -125,11 +124,11 @@ function ExternalAuthProvider({
function ReadonlyAuthProvider({ children }: { children: ReactNode }) {
return (
{children}
- )
+ );
}
// ── 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 (
{children}
- )
+ );
}
- const { authMode, loginProviders } = data.serverConfig
+ const { authMode, loginProviders } = data.serverConfig;
- if (authMode === 'readonly') {
- return {children}
+ if (authMode === "readonly") {
+ return {children} ;
}
- if (authMode === 'external') {
- return (
-
- {children}
-
- )
+ if (authMode === "external") {
+ return {children} ;
}
// Default: 'local'
- return (
-
- {children}
-
- )
+ return {children} ;
}
export function useAuth(): AuthContextValue {
- return useContext(AuthContext)
+ return useContext(AuthContext);
}
diff --git a/webui2/src/lib/repo.tsx b/webui2/src/lib/repo.tsx
index fe9df1c2887fae8d3713bb7e6a3e37e46fc7de03..766f16e1a6f3555e67bc9497f8a5ff529be8dbf0 100644
--- a/webui2/src/lib/repo.tsx
+++ b/webui2/src/lib/repo.tsx
@@ -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(null)
+const RepoContext = createContext(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 (
- )
+ );
}
// 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);
}
diff --git a/webui2/src/lib/theme.tsx b/webui2/src/lib/theme.tsx
index 882e07e8eedcebb6a7ba8f61eb7ffa2ae245a5c4..59cbc9ccfa35c2c40371ef71a68b022a95647ef4 100644
--- a/webui2/src/lib/theme.tsx
+++ b/webui2/src/lib/theme.tsx
@@ -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({
- 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(getInitialTheme)
+ const [theme, setTheme] = useState(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 {children}
+ return {children} ;
}
export function useTheme() {
- return useContext(ThemeContext)
+ return useContext(ThemeContext);
}
diff --git a/webui2/src/lib/utils.ts b/webui2/src/lib/utils.ts
index d32b0fe652e3a7129dd9a4a2fb82bfbb6faad449..365058cebd7d2bdb327ca1c081b16e4937e11050 100644
--- a/webui2/src/lib/utils.ts
+++ b/webui2/src/lib/utils.ts
@@ -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));
}
diff --git a/webui2/src/main.tsx b/webui2/src/main.tsx
index ef9a2caabd80162a934bd0fee39210a0c86aee65..93848a6ad607af567399d1c3b376188a861c5fa6 100644
--- a/webui2/src/main.tsx
+++ b/webui2/src/main.tsx
@@ -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(
@@ -17,4 +19,4 @@ createRoot(document.getElementById('root')!).render(
,
-)
+);
diff --git a/webui2/src/pages/BugDetailPage.tsx b/webui2/src/pages/BugDetailPage.tsx
index dc8b1325f21b018e7daff00e2522a96277173abd..cd5059df8f562918405be30be662cbe36a4d2748 100644
--- a/webui2/src/pages/BugDetailPage.tsx
+++ b/webui2/src/pages/BugDetailPage.tsx
@@ -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 (
Failed to load issue: {error.message}
- )
+ );
}
if (loading && !data) {
- return
+ return ;
}
- const bug = data?.repository?.bug
+ const bug = data?.repository?.bug;
if (!bug) {
- return (
- Issue not found.
- )
+ return Issue not found.
;
}
- 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 (
@@ -63,7 +62,7 @@ export function BugDetailPage() {
{bug.author.displayName}
- {' '}
+ {" "}
opened this issue {formatDistanceToNow(new Date(bug.createdAt), { addSuffix: true })}
@@ -89,7 +88,7 @@ export function BugDetailPage() {
{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 (
@@ -99,14 +98,14 @@ export function BugDetailPage() {
- )
+ );
})}
- )
+ );
}
function BugDetailSkeleton() {
@@ -130,5 +129,5 @@ function BugDetailSkeleton() {
- )
+ );
}
diff --git a/webui2/src/pages/BugListPage.tsx b/webui2/src/pages/BugListPage.tsx
index 0979360f1bda5d6efef7ba6d8327ab56484149a9..da33a2a0fe74a7b43a2f30e1277dedb0ed92f7c7 100644
--- a/webui2/src/pages/BugListPage.tsx
+++ b/webui2/src/pages/BugListPage.tsx
@@ -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('open')
- const [selectedLabels, setSelectedLabels] = useState([])
+ const repo = useRepo();
+ const [statusFilter, setStatusFilter] = useState("open");
+ const [selectedLabels, setSelectedLabels] = useState([]);
// humanId — uniquely identifies the selection for the dropdown UI
- const [selectedAuthorId, setSelectedAuthorId] = useState(null)
+ const [selectedAuthorId, setSelectedAuthorId] = useState(null);
// query value (login/name) — what goes into author:... in the query string
- const [selectedAuthorQuery, setSelectedAuthorQuery] = useState(null)
- const [freeText, setFreeText] = useState('')
- const [sort, setSort] = useState('creation-desc')
- const [draft, setDraft] = useState(() => buildQueryString('open', [], null, '', 'creation-desc'))
+ const [selectedAuthorQuery, setSelectedAuthorQuery] = useState(null);
+ const [freeText, setFreeText] = useState("");
+ const [sort, setSort] = useState("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
{/* List container */}
@@ -114,33 +128,59 @@ export function BugListPage() {
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",
)}
>
-
+
Open
-
+
{openCount}
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",
)}
>
-
+
Closed
-
+
{closedCount}
@@ -149,12 +189,25 @@ export function BugListPage() {
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,
+ )
+ }
/>
@@ -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() {
)}
- )
+ );
}
// 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(['creation-desc', 'creation-asc', 'edit-desc', 'edit-asc'])
+const VALID_SORTS = new Set(["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() {
))}
- )
+ );
}
diff --git a/webui2/src/pages/CodePage.tsx b/webui2/src/pages/CodePage.tsx
index 254a79cb0faf2ccd7efd53848a2f47d624b5b939..e76aa3f0af5d0eda479ea949e575619e2db7d48e 100644
--- a/webui2/src/pages/CodePage.tsx
+++ b/webui2/src/pages/CodePage.tsx
@@ -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(
(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() {
Code browser unavailable
{refsError.message}
- )
+ );
}
return (
@@ -187,15 +204,15 @@ export function CodePage() {
repoName={repoName}
ref={currentRef}
path={currentPath}
- onNavigate={(p) => navigate(p, 'tree')}
+ onNavigate={(p) => navigate(p, "tree")}
/>
)}
{!refsLoading && (
navigate(currentPath, viewMode === 'commits' ? 'tree' : 'commits')}
+ onClick={() => navigate(currentPath, viewMode === "commits" ? "tree" : "commits")}
>
History
@@ -209,9 +226,9 @@ export function CodePage() {
- {viewMode === 'commits' ? (
+ {viewMode === "commits" ? (
- ) : viewMode === 'tree' || !blob ? (
+ ) : viewMode === "tree" || !blob ? (
<>
)}
- )
+ );
}
diff --git a/webui2/src/pages/CommitPage.tsx b/webui2/src/pages/CommitPage.tsx
index 442418c213622d7a8db810d74b8951b79ba08059..1ad5f8aa8f190a07b1c034d1c9f0b2796318bd03 100644
--- a/webui2/src/pages/CommitPage.tsx
+++ b/webui2/src/pages/CommitPage.tsx
@@ -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
+ if (loading) return ;
if (error) {
return (
Failed to load commit: {error.message}
- )
+ );
}
- 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 (
@@ -75,9 +76,9 @@ export function CommitPage() {
{commit.message}
- {commit.fullMessage.includes('\n') && (
+ {commit.fullMessage.includes("\n") && (
- {commit.fullMessage.split('\n').slice(1).join('\n').trim()}
+ {commit.fullMessage.split("\n").slice(1).join("\n").trim()}
)}
@@ -86,7 +87,7 @@ export function CommitPage() {
{commit.authorName}
{commit.authorEmail && <{commit.authorEmail}> }
- {format(date, 'PPP')}
+ {format(date, "PPP")}
@@ -95,7 +96,7 @@ export function CommitPage() {
{commit.parents.map((p: string) => (
- parent{' '}
+ parent{" "}
- {files.length} file{files.length !== 1 ? 's' : ''} changed
+ {files.length} file{files.length !== 1 ? "s" : ""} changed
-
+
{files.length === 0 && (
No file changes.
)}
@@ -127,19 +128,19 @@ export function CommitPage() {
- )
+ );
}
function CommitPageSkeleton() {
return (
-
+
-
+
{Array.from({ length: 5 }).map((_, i) => (
@@ -148,5 +149,5 @@ function CommitPageSkeleton() {
))}
- )
+ );
}
diff --git a/webui2/src/pages/ErrorPage.tsx b/webui2/src/pages/ErrorPage.tsx
index da05b969d4fa1a90d79cdf9030a37d863df74493..c5dd663f7ec3818e9959c4d4b465be73ad40519a 100644
--- a/webui2/src/pages/ErrorPage.tsx
+++ b/webui2/src/pages/ErrorPage.tsx
@@ -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 (
- {status && (
-
{status}
- )}
+ {status &&
{status}
}
{message}
Go home
- )
+ );
}
diff --git a/webui2/src/pages/IdentitySelectPage.tsx b/webui2/src/pages/IdentitySelectPage.tsx
index b63daf7008400af3bfe7d2db79883d1a26c72da4..1504efd562e8efd45c9fa773aeba053a315fe959 100644
--- a/webui2/src/pages/IdentitySelectPage.tsx
+++ b/webui2/src/pages/IdentitySelectPage.tsx
@@ -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
(null)
- const [error, setError] = useState(null)
- const [working, setWorking] = useState(false)
+ const [identities, setIdentities] = useState(null);
+ const [error, setError] = useState(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
+ if (!res.ok) throw new Error(`unexpected status ${res.status}`);
+ return res.json() as Promise;
})
.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() {
Choose your identity
- 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.
{error && (
@@ -85,14 +86,11 @@ export function IdentitySelectPage() {
{id.displayName}
- {id.login ? `@${id.login} · ` : ''}{id.repoSlug} · {id.humanId}
+ {id.login ? `@${id.login} · ` : ""}
+ {id.repoSlug} · {id.humanId}
-
adopt(id.id)}
- >
+ adopt(id.id)}>
Adopt
@@ -106,16 +104,12 @@ export function IdentitySelectPage() {
A fresh git-bug identity will be created from your OAuth profile.
- adopt(null)}
- >
+ adopt(null)}>
Create
- )
+ );
}
diff --git a/webui2/src/pages/NewBugPage.tsx b/webui2/src/pages/NewBugPage.tsx
index a03ae91c37baa2543f025c7e53d637773e9f8c44..19e0ce2f9e38560a631ef1cd5cc1e5455634bc2b 100644
--- a/webui2/src/pages/NewBugPage.tsx
+++ b/webui2/src/pages/NewBugPage.tsx
@@ -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 (
@@ -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() {
)}
- navigate(issuesHref)} disabled={loading}>
+ navigate(issuesHref)}
+ disabled={loading}
+ >
Cancel
- {loading ? 'Creating…' : 'Submit new issue'}
+ {loading ? "Creating…" : "Submit new issue"}
- )
+ );
}
diff --git a/webui2/src/pages/RepoPickerPage.tsx b/webui2/src/pages/RepoPickerPage.tsx
index 666f374ba833f792c2b73d56dc04248bf22d6203..8d6407c8ee4bd98cb04b0fed58a9f7cc8a76f0e7 100644
--- a/webui2/src/pages/RepoPickerPage.tsx
+++ b/webui2/src/pages/RepoPickerPage.tsx
@@ -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 (
@@ -40,7 +41,7 @@ export function RepoPickerPage() {
)}
- {(loading && !data) && (
+ {loading && !data && (
{Array.from({ length: 3 }).map((_, i) => (
@@ -53,7 +54,7 @@ export function RepoPickerPage() {
{repoLabel(repo.name)}
@@ -67,5 +68,5 @@ export function RepoPickerPage() {
)}
- )
+ );
}
diff --git a/webui2/src/pages/UserProfilePage.tsx b/webui2/src/pages/UserProfilePage.tsx
index 058a25f1834137535900383c25f9ac753a8196c0..7f18d8723de23a6f63cc37427e68e64d07a7f27b 100644
--- a/webui2/src/pages/UserProfilePage.tsx
+++ b/webui2/src/pages/UserProfilePage.tsx
@@ -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() {
Failed to load profile: {error.message}
- )
+ );
}
- if (loading && !data) return
+ if (loading && !data) return ;
- const identity = data?.repository?.identity
+ const identity = data?.repository?.identity;
if (!identity) {
- return (
- User not found.
- )
+ return User not found.
;
}
- 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 (
@@ -143,15 +147,20 @@ export function UserProfilePage() {
{/* Open / Closed toggle — mirrors BugListPage style */}
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",
)}
>
-
+
Open
{openCount}
@@ -159,15 +168,20 @@ export function UserProfilePage() {
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",
)}
>
-
+
Closed
{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 (
@@ -210,7 +224,7 @@ export function UserProfilePage() {
))}
- #{bug.humanId} opened{' '}
+ #{bug.humanId} opened{" "}
{formatDistanceToNow(new Date(bug.createdAt), { addSuffix: true })}
@@ -221,7 +235,7 @@ export function UserProfilePage() {
)}
- )
+ );
})}
{/* Pagination footer — only shown when there is more than one page */}
@@ -254,7 +268,7 @@ export function UserProfilePage() {
)}
- )
+ );
}
function ProfileSkeleton() {
@@ -274,5 +288,5 @@ function ProfileSkeleton() {
))}
- )
+ );
}
diff --git a/webui2/tailwind.config.ts b/webui2/tailwind.config.ts
index 6dc6943f5ff9c380aaa1432fe2133a38d829ce2d..1d3e0fd0f4ab805ded6a456fe01c1c2534708992 100644
--- a/webui2/tailwind.config.ts
+++ b/webui2/tailwind.config.ts
@@ -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;
diff --git a/webui2/tsconfig.app.tsbuildinfo b/webui2/tsconfig.app.tsbuildinfo
index fec9c8b0abff03570d8e5b425ccd0dbfdbe1bbec..89df92860707d447a0b55d3ae5633596dedfbd89 100644
--- a/webui2/tsconfig.app.tsbuildinfo
+++ b/webui2/tsconfig.app.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/App.tsx","./src/main.tsx","./src/__generated__/graphql.ts","./src/components/bugs/BugRow.tsx","./src/components/bugs/CommentBox.tsx","./src/components/bugs/IssueFilters.tsx","./src/components/bugs/LabelBadge.tsx","./src/components/bugs/LabelEditor.tsx","./src/components/bugs/QueryInput.tsx","./src/components/bugs/StatusBadge.tsx","./src/components/bugs/Timeline.tsx","./src/components/bugs/TitleEditor.tsx","./src/components/code/CodeBreadcrumb.tsx","./src/components/code/CommitList.tsx","./src/components/code/FileDiffView.tsx","./src/components/code/FileTree.tsx","./src/components/code/FileViewer.tsx","./src/components/code/RefSelector.tsx","./src/components/content/Markdown.tsx","./src/components/layout/Header.tsx","./src/components/layout/Shell.tsx","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/popover.tsx","./src/components/ui/separator.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/textarea.tsx","./src/lib/apollo.ts","./src/lib/auth.tsx","./src/lib/repo.tsx","./src/lib/theme.tsx","./src/lib/utils.ts","./src/pages/BugDetailPage.tsx","./src/pages/BugListPage.tsx","./src/pages/CodePage.tsx","./src/pages/CommitPage.tsx","./src/pages/ErrorPage.tsx","./src/pages/IdentitySelectPage.tsx","./src/pages/NewBugPage.tsx","./src/pages/RepoPickerPage.tsx","./src/pages/UserProfilePage.tsx"],"version":"5.9.3"}
\ No newline at end of file
+{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/__generated__/graphql.ts","./src/components/bugs/bugrow.tsx","./src/components/bugs/commentbox.tsx","./src/components/bugs/issuefilters.tsx","./src/components/bugs/labelbadge.tsx","./src/components/bugs/labeleditor.tsx","./src/components/bugs/queryinput.tsx","./src/components/bugs/statusbadge.tsx","./src/components/bugs/timeline.tsx","./src/components/bugs/titleeditor.tsx","./src/components/code/codebreadcrumb.tsx","./src/components/code/commitlist.tsx","./src/components/code/filediffview.tsx","./src/components/code/filetree.tsx","./src/components/code/fileviewer.tsx","./src/components/code/refselector.tsx","./src/components/content/markdown.tsx","./src/components/layout/header.tsx","./src/components/layout/shell.tsx","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/popover.tsx","./src/components/ui/separator.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/textarea.tsx","./src/lib/apollo.ts","./src/lib/auth.tsx","./src/lib/repo.tsx","./src/lib/theme.tsx","./src/lib/utils.ts","./src/pages/bugdetailpage.tsx","./src/pages/buglistpage.tsx","./src/pages/codepage.tsx","./src/pages/commitpage.tsx","./src/pages/errorpage.tsx","./src/pages/identityselectpage.tsx","./src/pages/newbugpage.tsx","./src/pages/repopickerpage.tsx","./src/pages/userprofilepage.tsx"],"version":"6.0.2"}
\ No newline at end of file
diff --git a/webui2/tsconfig.json b/webui2/tsconfig.json
index 1ffef600d959ec9e396d5a260bd3f5b927b2cef8..d32ff682003e0ff5d8a6e6bb3663d4c35a45b116 100644
--- a/webui2/tsconfig.json
+++ b/webui2/tsconfig.json
@@ -1,7 +1,4 @@
{
"files": [],
- "references": [
- { "path": "./tsconfig.app.json" },
- { "path": "./tsconfig.node.json" }
- ]
+ "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}
diff --git a/webui2/tsconfig.node.tsbuildinfo b/webui2/tsconfig.node.tsbuildinfo
index b659e8b993028dc1645e07cb3194626d99499dac..b6440670e3e69cbe3bbbf2773033b46e92fedc87 100644
--- a/webui2/tsconfig.node.tsbuildinfo
+++ b/webui2/tsconfig.node.tsbuildinfo
@@ -1 +1 @@
-{"root":["./vite.config.ts","./tailwind.config.ts","./codegen.ts"],"version":"5.9.3"}
\ No newline at end of file
+{"root":["./vite.config.ts","./tailwind.config.ts","./codegen.ts"],"version":"6.0.2"}
\ No newline at end of file
diff --git a/webui2/vite.config.ts b/webui2/vite.config.ts
index 95ff69ca651762f5d4ede6e90ce68e69bc02fce4..330ea093fa0de57ba715feb6e076303d2f562afb 100644
--- a/webui2/vite.config.ts
+++ b/webui2/vite.config.ts
@@ -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 },
},
},
-})
+});