diff --git a/src/app/project/issues/IssuesPage.tsx b/src/app/project/issues/IssuesPage.tsx index 84c9dfe..326bbf2 100644 --- a/src/app/project/issues/IssuesPage.tsx +++ b/src/app/project/issues/IssuesPage.tsx @@ -1,33 +1,119 @@ import { useNavigate, useParams } from "react-router-dom"; import { useIssuesQuery, useIssueSummaryQuery } from "@/hooks/useIssuesQuery"; +import { useQueryClient } from "@tanstack/react-query"; +import { issueGet } from "@/client/api"; import { LoadingState } from "@/components/ui/LoadingState"; import { EmptyState } from "@/components/ui/EmptyState"; import { ErrorState } from "@/components/ui/ErrorState"; -import { - AlertCircle, - User, - Calendar, - Plus, - Search, - Tag, - ArrowUpDown, - CheckCircle2, +import { + AlertCircle, + User, + Calendar, + Plus, + Search, + Tag, + ArrowUpDown, + CheckCircle2, CircleDot, AlertTriangle, Zap } from "lucide-react"; import { ISSUES_PAGE } from "@/css/issues/styles"; -import { useState, useMemo } from "react"; +import { memo, useState, useMemo, useDeferredValue, useRef, useCallback } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; import { stripMarkdown, truncate } from "@/lib/utils"; import type { IssueResponse, IssueLabelResponse } from "@/client/model"; +interface IssueRowProps { + issue: IssueResponse; + projectName: string; + onNavigate: (path: string) => void; + onPrefetch?: (projectName: string, issueNumber: number) => void; +} + +const IssueRow = memo(function IssueRow({ issue, projectName, onNavigate, onPrefetch }: IssueRowProps) { + const priorityLabel = issue.labels?.find((l: IssueLabelResponse) => l.label_name?.toLowerCase().startsWith('priority:')); + const priority = priorityLabel ? (priorityLabel.label_name ?? '').split(':')[1].toLowerCase() : null; + const otherLabels = issue.labels?.filter((l: IssueLabelResponse) => !l.label_name?.toLowerCase().startsWith('priority:')) || []; + + return ( +
onNavigate(`/${projectName}/issues/${issue.number}`)} + onMouseEnter={() => onPrefetch?.(projectName, issue.number)} + className={ISSUES_PAGE.issueRow} + > +
+ {issue.state === 'open' ? : } +
+ +
+
+ #{issue.number} +

{issue.title}

+
+ {otherLabels.map((l: IssueLabelResponse) => ( + + {l.label_name} + + ))} +
+
+ + {issue.body && ( +

+ {truncate(stripMarkdown(issue.body), 120)} +

+ )} + +
+ {priority && ( + + {priority === 'critical' ? : } + {priority} + + )} + +
+ + {issue.author_username || 'anonymous'} +
+ +
+ + {new Date(issue.created_at).toLocaleDateString()} +
+
+
+
+ ); +}); + +const ESTIMATED_ROW_SIZE = 120; +const OVERSCAN = 5; + export function IssuesPage() { const { projectName } = useParams<{ projectName: string }>(); const navigate = useNavigate(); - + const [activeTab, setActiveTab] = useState<'open' | 'closed'>('open'); const [searchQuery, setSearchQuery] = useState(''); - + const deferredQuery = useDeferredValue(searchQuery); + const isSearchStale = searchQuery !== deferredQuery; + const { data: issues = [], isLoading, error, refetch } = useIssuesQuery(projectName, { state: activeTab }); @@ -35,18 +121,44 @@ export function IssuesPage() { const { data: summary } = useIssueSummaryQuery(projectName); const filteredIssues = useMemo(() => { - if (!searchQuery) return issues; - const lowQuery = searchQuery.toLowerCase(); - return issues.filter(i => - i.title.toLowerCase().includes(lowQuery) || + if (!deferredQuery) return issues; + const lowQuery = deferredQuery.toLowerCase(); + return issues.filter(i => + i.title.toLowerCase().includes(lowQuery) || (i.body && i.body.toLowerCase().includes(lowQuery)) || String(i.number).includes(lowQuery) ); - }, [issues, searchQuery]); + }, [issues, deferredQuery]); const openCount = summary?.open ?? 0; const closedCount = summary?.closed ?? 0; + const scrollRef = useRef(null); + + const virtualizer = useVirtualizer({ + count: filteredIssues.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => ESTIMATED_ROW_SIZE, + overscan: OVERSCAN, + }); + + const handleNavigate = useCallback( + (path: string) => navigate(path), + [navigate] + ); + + const queryClient = useQueryClient(); + const handlePrefetch = useCallback( + (project: string, issueNumber: number) => { + queryClient.prefetchQuery({ + queryKey: ["issues", project, issueNumber], + queryFn: () => issueGet(project, issueNumber).then(r => (r.data?.data as IssueResponse) ?? null), + staleTime: 5 * 60 * 1000, + }); + }, + [queryClient] + ); + if (!projectName) { return (
@@ -80,14 +192,14 @@ export function IssuesPage() { } return ( -
+
{/* Page Header */}

Issues

Track and manage project tasks and bugs

-
) : ( -
- {filteredIssues.map((issue: IssueResponse) => { - // Find priority label if exists - const priorityLabel = issue.labels?.find((l: IssueLabelResponse) => l.label_name?.toLowerCase().startsWith('priority:')); - const priority = priorityLabel ? (priorityLabel.label_name ?? '').split(':')[1].toLowerCase() : null; - - // Other labels - const otherLabels = issue.labels?.filter((l: IssueLabelResponse) => !l.label_name?.toLowerCase().startsWith('priority:')) || []; - - return ( -
navigate(`/${projectName}/issues/${issue.number}`)} - className={ISSUES_PAGE.issueRow} - > -
- {issue.state === 'open' ? : } +
+
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const issue = filteredIssues[virtualItem.index]; + return ( +
+
- -
-
- #{issue.number} -

{issue.title}

- - {/* Inline Labels */} -
- {otherLabels.map((l: IssueLabelResponse) => ( - - {l.label_name} - - ))} -
-
- - {issue.body && ( -

- {truncate(stripMarkdown(issue.body), 120)} -

- )} - -
- {/* Priority Pill */} - {priority && ( - - {priority === 'critical' ? : } - {priority} - - )} - -
- - {issue.author_username || 'anonymous'} -
- -
- - {new Date(issue.created_at).toLocaleDateString()} -
-
-
-
- ); - })} + ); + })} +
)}
); } -export default IssuesPage; +export default IssuesPage; \ No newline at end of file