From 260fa295304c24944b4adb7c4b635425601df48a Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Sun, 29 Mar 2026 19:00:18 +0200 Subject: [PATCH] refactor(web): apply oxfmt formatting run oxfmt across the entire codebase to establish consistent formatting with import sorting and tailwind class sorting Co-Authored-By: Claude Opus 4.6 (1M context) --- webui2/README.md | 4 +- webui2/codegen.ts | 26 +- webui2/postcss.config.js | 2 +- webui2/src/App.tsx | 45 +-- webui2/src/components/bugs/BugRow.tsx | 55 ++-- webui2/src/components/bugs/CommentBox.tsx | 90 +++--- webui2/src/components/bugs/IssueFilters.tsx | 200 +++++++----- webui2/src/components/bugs/LabelBadge.tsx | 24 +- webui2/src/components/bugs/LabelEditor.tsx | 54 ++-- webui2/src/components/bugs/QueryInput.tsx | 275 +++++++++-------- webui2/src/components/bugs/StatusBadge.tsx | 23 +- webui2/src/components/bugs/Timeline.tsx | 161 +++++----- webui2/src/components/bugs/TitleEditor.tsx | 61 ++-- webui2/src/components/code/CodeBreadcrumb.tsx | 22 +- webui2/src/components/code/CommitList.tsx | 113 +++---- webui2/src/components/code/FileDiffView.tsx | 135 ++++---- webui2/src/components/code/FileTree.tsx | 71 ++--- webui2/src/components/code/FileViewer.tsx | 71 +++-- webui2/src/components/code/RefSelector.tsx | 61 ++-- webui2/src/components/content/Markdown.tsx | 48 ++- webui2/src/components/layout/Header.tsx | 93 +++--- webui2/src/components/layout/Shell.tsx | 7 +- webui2/src/components/ui/avatar.tsx | 27 +- webui2/src/components/ui/badge.tsx | 30 +- webui2/src/components/ui/button.tsx | 59 ++-- webui2/src/components/ui/input.tsx | 17 +- webui2/src/components/ui/popover.tsx | 23 +- webui2/src/components/ui/separator.tsx | 19 +- webui2/src/components/ui/skeleton.tsx | 11 +- webui2/src/components/ui/textarea.tsx | 17 +- webui2/src/index.css | 26 +- webui2/src/lib/apollo.ts | 10 +- webui2/src/lib/auth.tsx | 107 +++---- webui2/src/lib/repo.tsx | 12 +- webui2/src/lib/theme.tsx | 32 +- webui2/src/lib/utils.ts | 6 +- webui2/src/main.tsx | 22 +- webui2/src/pages/BugDetailPage.tsx | 57 ++-- webui2/src/pages/BugListPage.tsx | 292 +++++++++++------- webui2/src/pages/CodePage.tsx | 149 +++++---- webui2/src/pages/CommitPage.tsx | 57 ++-- webui2/src/pages/ErrorPage.tsx | 27 +- webui2/src/pages/IdentitySelectPage.tsx | 78 +++-- webui2/src/pages/NewBugPage.tsx | 58 ++-- webui2/src/pages/RepoPickerPage.tsx | 29 +- webui2/src/pages/UserProfilePage.tsx | 136 ++++---- webui2/tailwind.config.ts | 79 +++-- webui2/tsconfig.app.tsbuildinfo | 2 +- webui2/tsconfig.json | 5 +- webui2/tsconfig.node.tsbuildinfo | 2 +- webui2/vite.config.ts | 29 +- 51 files changed, 1641 insertions(+), 1418 deletions(-) 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"} -
- ) + ); } 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(""); + }} + > - + {/* 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 ( - ) + ); })}
{selectedLabels.length > 0 && ( @@ -222,12 +241,18 @@ export function IssueFilters({
{/* Author filter */} - { if (!open) setAuthorSearch('') }}> + { + if (!open) setAuthorSearch(""); + }} + > - + {/* 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 ( - ) + ); })} {!isSearching && allIdentities.length > INITIAL_AUTHOR_LIMIT && (

@@ -317,18 +351,18 @@ export function IssueFilters({ - + {SORT_OPTIONS.map((opt) => ( ))}

- ) + ); } 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 - ) + ); } 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) => ( @@ -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 && (
- ) + ); } // ── 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 (
{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 && (
)}
- ) + ); } 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 (
{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 ( - ) + ); } 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} {/* External mode: show sign-in buttons when logged out */} - {mode === 'external' && !user && loginProviders.map((p) => ( - - ))} + {mode === "external" && + !user && + loginProviders.map((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 (