auto-claude: 182-implement-pagination-and-filtering-for-github-pr-l (#1654)
* auto-claude: subtask-1-1 - Add GITHUB_PR_LIST_MORE IPC channel constant Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * auto-claude: subtask-1-2 - Add listMorePRs IPC handler for pagination - Add GITHUB_PR_LIST_MORE handler that accepts cursor parameter - Update PRListResult interface to include endCursor field - Update existing GITHUB_PR_LIST handler to also return endCursor - Both handlers use GraphQL pagination with cursor-based navigation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * auto-claude: subtask-1-3 - Update PRListResult type to include endCursor field Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * auto-claude: subtask-1-4 - Add listMorePRs method to GitHubAPI interface Add listMorePRs method for cursor-based pagination to the GitHubAPI interface and createGitHubAPI implementation in preload. Also update browser-mock.ts to include the new method for type consistency. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * auto-claude: subtask-2-1 - Add sortBy option to PRFilterState interface and DEFAULT_FILTERS - Added PRSortOption type with 'newest' | 'oldest' | 'largest' options - Added sortBy field to PRFilterState interface - Set default sortBy to 'newest' in DEFAULT_FILTERS Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * auto-claude: subtask-2-2 - Add sorting logic to filteredPRs useMemo in usePRF * auto-claude: subtask-2-4 - Add loadMore function, endCursor state, and isLoadingMore state - Add isLoadingMore state to track pagination loading - Add endCursor state to track pagination cursor - Add loadMore function for cursor-based pagination - Update UseGitHubPRsResult interface with new properties - Store endCursor from fetchPRs API response - Reset endCursor when project changes - Batch preload review results for newly loaded PRs Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * auto-claude: subtask-3-1 - Add sort dropdown to PRFilterBar using FilterDropdown - Add SortDropdown component with single-select behavior for sorting PRs - Add SORT_OPTIONS constant with newest/oldest/largest options - Add onSortChange prop to PRFilterBarProps interface - Import ArrowUpDown, Clock, FileCode icons from lucide-react - Import PRSortOption type from usePRFiltering hook - Update GitHubPRs.tsx to pass setSortBy as onSortChange prop - Add i18n translations for sort labels (en/fr) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * auto-claude: subtask-3-2 - Add onLoadMore and isLoadingMore props to PRList component Add pagination props to PRListProps interface: - onLoadMore: Optional callback to load more PRs when hasMore is true - isLoadingMore: Optional boolean to track loading state for pagination Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * auto-claude: subtask-3-3 - Replace status indicator text with Load More button - Add Load More button component to PRList when hasMore is true - Show loading spinner with "Loading..." text when isLoadingMore is true - Keep "All PRs loaded" text when all PRs are displayed - Add prReview.loadMore and prReview.loadingMore translation keys - Import Button and Loader2 components Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * auto-claude: subtask-3-4 - Wire up new props in GitHubPRs.tsx parent component - Add loadMore and isLoadingMore to useGitHubPRs destructuring - Pass loadMore as onLoadMore prop to PRList component - Pass isLoadingMore to PRList component for loading state - setSortBy already wired to PRFilterBar's onSortChange Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * auto-claude: subtask-4-1 - Add English translation keys for sortBy, sortNewest, sortOldest, sortLargest, loadMore, loadingMore Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * auto-claude: subtask-4-2 - Add French translation keys for sortBy, sortNewest Adds French translations for pagination and sorting UI elements: - sort.sortBy: "Trier par" - sort.sortNewest: "Plus récent" - sort.sortOldest: "Plus ancien" - sort.sortLargest: "Plus grand" - pagination.loadMore: "Charger plus" - pagination.loadingMore: "Chargement..." Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Fix ruff formatting and Windows dataclass import error - Apply ruff formatting to parallel_orchestrator_reviewer.py (3 long lines) - Apply ruff formatting to pydantic_models.py (Field on single line) - Fix Windows test collection error: register module in sys.modules before exec_module so dataclass decorator can find it Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Fix PR review findings: dedup mapping, race conditions, unused i18n keys - Extract shared mapGraphQLPRToData helper to eliminate duplicated PR mapping logic between listPRs and listMorePRs handlers - Add staleness checks to loadMore using generation counter and projectId ref to prevent race conditions with refresh and project switching - Reset isLoadingMore on project change to prevent stuck loading state - Remove unused common.sort.* and common.pagination.* translation keys from en/fr locale files (code uses prReview.* keys instead) - Align endCursor type to string | null in preload PRListResult - Preserve sortBy preference when clearing filters for consistency with hasActiveFilters Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Fix PR review findings: dedup PR handlers, stable sort order - Extract fetchPRsFromGraphQL helper to deduplicate listPRs/listMorePRs handlers - Add secondary sort key (createdAt) to 'largest' sort for stable ordering Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Fix PR review findings: deduplication and keyboard navigation - Add deduplication by PR number when appending paginated PRs to prevent duplicates if a PR shifts position between pagination requests - Add keyboard navigation to SortDropdown (ArrowUp/Down, Enter, Space, Escape) matching the pattern used in FilterDropdown for accessibility consistency Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Fix follow-up review findings: pagination, sort, keyboard UX - Preserve pagination state on failure response to allow retry - Pre-compute timestamps before sorting to avoid Date object creation - Add scrollIntoView for keyboard-focused items in FilterDropdown - Focus current selection when SortDropdown opens for better keyboard UX Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Test User <test@example.com>
This commit is contained in:
@@ -40,15 +40,7 @@ import {
|
||||
* GraphQL response type for PR list query
|
||||
* Note: repository can be null if the repo doesn't exist or user lacks access
|
||||
*/
|
||||
interface GraphQLPRListResponse {
|
||||
data: {
|
||||
repository: {
|
||||
pullRequests: {
|
||||
pageInfo: {
|
||||
hasNextPage: boolean;
|
||||
endCursor: string | null;
|
||||
};
|
||||
nodes: Array<{
|
||||
interface GraphQLPRNode {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
@@ -63,13 +55,47 @@ interface GraphQLPRListResponse {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
url: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface GraphQLPRListResponse {
|
||||
data: {
|
||||
repository: {
|
||||
pullRequests: {
|
||||
pageInfo: {
|
||||
hasNextPage: boolean;
|
||||
endCursor: string | null;
|
||||
};
|
||||
nodes: GraphQLPRNode[];
|
||||
};
|
||||
} | null;
|
||||
};
|
||||
errors?: Array<{ message: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a GraphQL PR node to the frontend PRData format.
|
||||
* Shared between listPRs and listMorePRs handlers.
|
||||
*/
|
||||
function mapGraphQLPRToData(pr: GraphQLPRNode): PRData {
|
||||
return {
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
body: pr.body ?? "",
|
||||
state: pr.state.toLowerCase(),
|
||||
author: { login: pr.author?.login ?? "unknown" },
|
||||
headRefName: pr.headRefName,
|
||||
baseRefName: pr.baseRefName,
|
||||
additions: pr.additions,
|
||||
deletions: pr.deletions,
|
||||
changedFiles: pr.changedFiles,
|
||||
assignees: pr.assignees.nodes.map((a) => ({ login: a.login })),
|
||||
files: [],
|
||||
createdAt: pr.createdAt,
|
||||
updatedAt: pr.updatedAt,
|
||||
htmlUrl: pr.url,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a GraphQL request to GitHub API
|
||||
*/
|
||||
@@ -487,6 +513,7 @@ export interface PRData {
|
||||
export interface PRListResult {
|
||||
prs: PRData[];
|
||||
hasNextPage: boolean; // True if more PRs exist beyond the 100 limit
|
||||
endCursor?: string | null; // Cursor for fetching next page (null if no more pages)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1348,33 +1375,28 @@ async function runPRReview(
|
||||
}
|
||||
|
||||
/**
|
||||
* Register PR-related handlers
|
||||
* Shared helper to fetch PRs via GraphQL API.
|
||||
* Used by both listPRs and listMorePRs handlers to avoid code duplication.
|
||||
*/
|
||||
export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): void {
|
||||
debugLog("Registering PR handlers");
|
||||
|
||||
// List open PRs - fetches up to 100 open PRs at once, returns hasNextPage from API
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.GITHUB_PR_LIST,
|
||||
async (_, projectId: string): Promise<PRListResult> => {
|
||||
debugLog("listPRs handler called", { projectId });
|
||||
const result = await withProjectOrNull(projectId, async (project) => {
|
||||
const config = getGitHubConfig(project);
|
||||
if (!config) {
|
||||
debugLog("No GitHub config found for project");
|
||||
return { prs: [], hasNextPage: false };
|
||||
}
|
||||
|
||||
try {
|
||||
async function fetchPRsFromGraphQL(
|
||||
config: { token: string; repo: string },
|
||||
cursor: string | null,
|
||||
debugContext: string
|
||||
): Promise<PRListResult> {
|
||||
// Parse owner/repo from config - must be exactly "owner/repo" format
|
||||
const normalizedRepo = normalizeRepoReference(config.repo);
|
||||
const repoParts = normalizedRepo.split("/");
|
||||
if (repoParts.length !== 2 || !repoParts[0] || !repoParts[1]) {
|
||||
debugLog("Invalid repo format - expected 'owner/repo'", { repo: config.repo, normalized: normalizedRepo });
|
||||
return { prs: [], hasNextPage: false };
|
||||
debugLog("Invalid repo format - expected 'owner/repo'", {
|
||||
repo: config.repo,
|
||||
normalized: normalizedRepo,
|
||||
context: debugContext,
|
||||
});
|
||||
return { prs: [], hasNextPage: false, endCursor: null };
|
||||
}
|
||||
const [owner, repo] = repoParts;
|
||||
|
||||
try {
|
||||
// Use GraphQL API to get PRs with diff stats (REST list endpoint doesn't include them)
|
||||
// Fetches up to 100 open PRs (GitHub GraphQL max per request)
|
||||
const response = await githubGraphQL<GraphQLPRListResponse>(
|
||||
@@ -1384,47 +1406,73 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v
|
||||
owner,
|
||||
repo,
|
||||
first: 100, // GitHub GraphQL max is 100
|
||||
after: null, // Start from beginning
|
||||
after: cursor,
|
||||
}
|
||||
);
|
||||
|
||||
// Handle case where repository doesn't exist or user lacks access
|
||||
if (!response.data.repository) {
|
||||
debugLog("Repository not found or access denied", { owner, repo });
|
||||
return { prs: [], hasNextPage: false };
|
||||
debugLog("Repository not found or access denied", { owner, repo, context: debugContext });
|
||||
return { prs: [], hasNextPage: false, endCursor: null };
|
||||
}
|
||||
|
||||
const { nodes: prNodes, pageInfo } = response.data.repository.pullRequests;
|
||||
|
||||
debugLog("Fetched PRs via GraphQL", { count: prNodes.length, hasNextPage: pageInfo.hasNextPage });
|
||||
return {
|
||||
prs: prNodes.map((pr) => ({
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
body: pr.body ?? "",
|
||||
state: pr.state.toLowerCase(),
|
||||
author: { login: pr.author?.login ?? "unknown" },
|
||||
headRefName: pr.headRefName,
|
||||
baseRefName: pr.baseRefName,
|
||||
additions: pr.additions,
|
||||
deletions: pr.deletions,
|
||||
changedFiles: pr.changedFiles,
|
||||
assignees: pr.assignees.nodes.map((a) => ({ login: a.login })),
|
||||
files: [],
|
||||
createdAt: pr.createdAt,
|
||||
updatedAt: pr.updatedAt,
|
||||
htmlUrl: pr.url,
|
||||
})),
|
||||
debugLog(`Fetched PRs via GraphQL (${debugContext})`, {
|
||||
count: prNodes.length,
|
||||
hasNextPage: pageInfo.hasNextPage,
|
||||
endCursor: pageInfo.endCursor,
|
||||
});
|
||||
return {
|
||||
prs: prNodes.map(mapGraphQLPRToData),
|
||||
hasNextPage: pageInfo.hasNextPage,
|
||||
endCursor: pageInfo.endCursor,
|
||||
};
|
||||
} catch (error) {
|
||||
debugLog("Failed to fetch PRs", {
|
||||
debugLog(`Failed to fetch PRs (${debugContext})`, {
|
||||
error: error instanceof Error ? error.message : error,
|
||||
});
|
||||
return { prs: [], hasNextPage: false };
|
||||
return { prs: [], hasNextPage: false, endCursor: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register PR-related handlers
|
||||
*/
|
||||
export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): void {
|
||||
debugLog("Registering PR handlers");
|
||||
|
||||
// List open PRs - fetches up to 100 open PRs at once, returns hasNextPage and endCursor from API
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.GITHUB_PR_LIST,
|
||||
async (_, projectId: string): Promise<PRListResult> => {
|
||||
debugLog("listPRs handler called", { projectId });
|
||||
const result = await withProjectOrNull(projectId, async (project) => {
|
||||
const config = getGitHubConfig(project);
|
||||
if (!config) {
|
||||
debugLog("No GitHub config found for project");
|
||||
return { prs: [], hasNextPage: false, endCursor: null };
|
||||
}
|
||||
return fetchPRsFromGraphQL(config, null, "initial");
|
||||
});
|
||||
return result ?? { prs: [], hasNextPage: false };
|
||||
return result ?? { prs: [], hasNextPage: false, endCursor: null };
|
||||
}
|
||||
);
|
||||
|
||||
// Load more PRs (pagination) - fetches next page of PRs using cursor
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.GITHUB_PR_LIST_MORE,
|
||||
async (_, projectId: string, cursor: string): Promise<PRListResult> => {
|
||||
debugLog("listMorePRs handler called", { projectId, cursor });
|
||||
const result = await withProjectOrNull(projectId, async (project) => {
|
||||
const config = getGitHubConfig(project);
|
||||
if (!config) {
|
||||
debugLog("No GitHub config found for project");
|
||||
return { prs: [], hasNextPage: false, endCursor: null };
|
||||
}
|
||||
return fetchPRsFromGraphQL(config, cursor, "pagination");
|
||||
});
|
||||
return result ?? { prs: [], hasNextPage: false, endCursor: null };
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -269,6 +269,8 @@ export interface GitHubAPI {
|
||||
|
||||
// PR operations (fetches up to 100 open PRs at once - GitHub GraphQL limit)
|
||||
listPRs: (projectId: string) => Promise<PRListResult>;
|
||||
/** Load more PRs using cursor-based pagination */
|
||||
listMorePRs: (projectId: string, cursor: string) => Promise<PRListResult>;
|
||||
getPR: (projectId: string, prNumber: number) => Promise<PRData | null>;
|
||||
runPRReview: (projectId: string, prNumber: number) => void;
|
||||
cancelPRReview: (projectId: string, prNumber: number) => Promise<boolean>;
|
||||
@@ -338,6 +340,7 @@ export interface PRData {
|
||||
export interface PRListResult {
|
||||
prs: PRData[];
|
||||
hasNextPage: boolean; // True if more PRs exist beyond the 100 limit
|
||||
endCursor?: string | null; // Cursor for fetching next page (null if no more pages)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -675,6 +678,10 @@ export const createGitHubAPI = (): GitHubAPI => ({
|
||||
listPRs: (projectId: string): Promise<PRListResult> =>
|
||||
invokeIpc(IPC_CHANNELS.GITHUB_PR_LIST, projectId),
|
||||
|
||||
// Load more PRs using cursor-based pagination
|
||||
listMorePRs: (projectId: string, cursor: string): Promise<PRListResult> =>
|
||||
invokeIpc(IPC_CHANNELS.GITHUB_PR_LIST_MORE, projectId, cursor),
|
||||
|
||||
getPR: (projectId: string, prNumber: number): Promise<PRData | null> =>
|
||||
invokeIpc(IPC_CHANNELS.GITHUB_PR_GET, projectId, prNumber),
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps)
|
||||
const {
|
||||
prs,
|
||||
isLoading,
|
||||
isLoadingMore,
|
||||
isLoadingPRDetails,
|
||||
error,
|
||||
selectedPRNumber,
|
||||
@@ -78,6 +79,7 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps)
|
||||
assignPR,
|
||||
markReviewPosted,
|
||||
refresh,
|
||||
loadMore,
|
||||
isConnected,
|
||||
repoFullName,
|
||||
getReviewStateForPR,
|
||||
@@ -96,6 +98,7 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps)
|
||||
setSearchQuery,
|
||||
setContributors,
|
||||
setStatuses,
|
||||
setSortBy,
|
||||
clearFilters,
|
||||
hasActiveFilters,
|
||||
} = usePRFiltering(prs, getReviewStateForPR);
|
||||
@@ -226,6 +229,7 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps)
|
||||
onSearchChange={setSearchQuery}
|
||||
onContributorsChange={setContributors}
|
||||
onStatusesChange={setStatuses}
|
||||
onSortChange={setSortBy}
|
||||
onClearFilters={clearFilters}
|
||||
/>
|
||||
<PRList
|
||||
@@ -236,6 +240,8 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps)
|
||||
error={error}
|
||||
getReviewStateForPR={getReviewStateForPR}
|
||||
onSelectPR={selectPR}
|
||||
onLoadMore={loadMore}
|
||||
isLoadingMore={isLoadingMore}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Multi-select dropdowns with visible chip selections
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useRef, useCallback } from 'react';
|
||||
import { useState, useMemo, useRef, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
Search,
|
||||
Users,
|
||||
@@ -17,7 +17,10 @@ import {
|
||||
X,
|
||||
Filter,
|
||||
Check,
|
||||
Loader2
|
||||
Loader2,
|
||||
ArrowUpDown,
|
||||
Clock,
|
||||
FileCode
|
||||
} from 'lucide-react';
|
||||
import { Input } from '../../ui/input';
|
||||
import { Badge } from '../../ui/badge';
|
||||
@@ -29,7 +32,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '../../ui/dropdown-menu';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { PRFilterState, PRStatusFilter } from '../hooks/usePRFiltering';
|
||||
import type { PRFilterState, PRStatusFilter, PRSortOption } from '../hooks/usePRFiltering';
|
||||
import { cn } from '../../../lib/utils';
|
||||
|
||||
interface PRFilterBarProps {
|
||||
@@ -39,6 +42,7 @@ interface PRFilterBarProps {
|
||||
onSearchChange: (query: string) => void;
|
||||
onContributorsChange: (contributors: string[]) => void;
|
||||
onStatusesChange: (statuses: PRStatusFilter[]) => void;
|
||||
onSortChange: (sortBy: PRSortOption) => void;
|
||||
onClearFilters: () => void;
|
||||
}
|
||||
|
||||
@@ -59,6 +63,17 @@ const STATUS_OPTIONS: Array<{
|
||||
{ value: 'ready_for_followup', labelKey: 'prReview.readyForFollowup', icon: RefreshCw, color: 'text-cyan-400', bgColor: 'bg-cyan-500/20' },
|
||||
];
|
||||
|
||||
// Sort options
|
||||
const SORT_OPTIONS: Array<{
|
||||
value: PRSortOption;
|
||||
labelKey: string;
|
||||
icon: typeof Clock;
|
||||
}> = [
|
||||
{ value: 'newest', labelKey: 'prReview.sort.newest', icon: Clock },
|
||||
{ value: 'oldest', labelKey: 'prReview.sort.oldest', icon: Clock },
|
||||
{ value: 'largest', labelKey: 'prReview.sort.largest', icon: FileCode },
|
||||
];
|
||||
|
||||
/**
|
||||
* Modern Filter Dropdown Component
|
||||
*/
|
||||
@@ -138,6 +153,13 @@ function FilterDropdown<T extends string>({
|
||||
}
|
||||
}, [filteredItems, focusedIndex, toggleItem]);
|
||||
|
||||
// Scroll focused item into view for keyboard navigation
|
||||
useEffect(() => {
|
||||
if (focusedIndex >= 0 && itemRefs.current[focusedIndex]) {
|
||||
itemRefs.current[focusedIndex]?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}, [focusedIndex]);
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={(open) => {
|
||||
setIsOpen(open);
|
||||
@@ -279,6 +301,127 @@ function FilterDropdown<T extends string>({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-select Sort Dropdown Component
|
||||
*/
|
||||
function SortDropdown({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
title,
|
||||
}: {
|
||||
value: PRSortOption;
|
||||
onChange: (value: PRSortOption) => void;
|
||||
options: typeof SORT_OPTIONS;
|
||||
title: string;
|
||||
}) {
|
||||
const { t } = useTranslation('common');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [focusedIndex, setFocusedIndex] = useState(-1);
|
||||
|
||||
const currentOption = options.find((opt) => opt.value === value) || options[0];
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (options.length === 0) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setFocusedIndex((prev) => (prev < options.length - 1 ? prev + 1 : 0));
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setFocusedIndex((prev) => (prev > 0 ? prev - 1 : options.length - 1));
|
||||
break;
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
e.preventDefault();
|
||||
if (focusedIndex >= 0 && focusedIndex < options.length) {
|
||||
onChange(options[focusedIndex].value);
|
||||
setIsOpen(false);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
setIsOpen(false);
|
||||
break;
|
||||
}
|
||||
}, [options, focusedIndex, onChange]);
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsOpen(open);
|
||||
if (open) {
|
||||
// Focus current selection on open for better keyboard UX
|
||||
setFocusedIndex(options.findIndex((o) => o.value === value));
|
||||
} else {
|
||||
setFocusedIndex(-1);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 justify-start border-dashed bg-transparent"
|
||||
>
|
||||
<ArrowUpDown className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||
<span className="truncate">{title}</span>
|
||||
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||
<Badge variant="secondary" className="rounded-sm px-1 font-normal">
|
||||
{t(currentOption.labelKey)}
|
||||
</Badge>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-[180px] p-0">
|
||||
<div className="px-3 py-2 border-b border-border/50">
|
||||
<div className="text-xs font-semibold text-muted-foreground">
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="p-1"
|
||||
role="listbox"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{options.map((option, index) => {
|
||||
const isSelected = value === option.value;
|
||||
const isFocused = focusedIndex === index;
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-2 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground",
|
||||
isSelected && "bg-accent/50",
|
||||
isFocused && "bg-accent text-accent-foreground"
|
||||
)}
|
||||
onClick={() => {
|
||||
onChange(option.value);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className={cn(
|
||||
"mr-2 flex h-4 w-4 items-center justify-center rounded-full border border-primary/30",
|
||||
isSelected ? "bg-primary border-primary text-primary-foreground" : "opacity-50"
|
||||
)}>
|
||||
{isSelected && <Check className="h-2.5 w-2.5" />}
|
||||
</div>
|
||||
<Icon className="mr-2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span>{t(option.labelKey)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export function PRFilterBar({
|
||||
filters,
|
||||
contributors,
|
||||
@@ -286,6 +429,7 @@ export function PRFilterBar({
|
||||
onSearchChange,
|
||||
onContributorsChange,
|
||||
onStatusesChange,
|
||||
onSortChange,
|
||||
onClearFilters,
|
||||
}: PRFilterBarProps) {
|
||||
const { t } = useTranslation('common');
|
||||
@@ -393,6 +537,16 @@ export function PRFilterBar({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sort Dropdown */}
|
||||
<div className="flex-shrink-0">
|
||||
<SortDropdown
|
||||
value={filters.sortBy}
|
||||
onChange={onSortChange}
|
||||
options={SORT_OPTIONS}
|
||||
title={t('prReview.sort.label')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Reset All */}
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { GitPullRequest, User, Clock, FileDiff } from 'lucide-react';
|
||||
import { GitPullRequest, User, Clock, FileDiff, Loader2 } from 'lucide-react';
|
||||
import { ScrollArea } from '../../ui/scroll-area';
|
||||
import { Badge } from '../../ui/badge';
|
||||
import { Button } from '../../ui/button';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import type { PRData, PRReviewProgress, PRReviewResult } from '../hooks/useGitHubPRs';
|
||||
import type { NewCommitsCheck } from '../../../../preload/api/modules/github-api';
|
||||
@@ -170,6 +171,10 @@ interface PRListProps {
|
||||
error: string | null;
|
||||
getReviewStateForPR: (prNumber: number) => PRReviewInfo | null;
|
||||
onSelectPR: (prNumber: number) => void;
|
||||
/** Callback to load more PRs when hasMore is true */
|
||||
onLoadMore?: () => void;
|
||||
/** Whether additional PRs are currently being loaded */
|
||||
isLoadingMore?: boolean;
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
@@ -200,6 +205,8 @@ export function PRList({
|
||||
error,
|
||||
getReviewStateForPR,
|
||||
onSelectPR,
|
||||
onLoadMore,
|
||||
isLoadingMore,
|
||||
}: PRListProps) {
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
@@ -306,12 +313,30 @@ export function PRList({
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Status indicator */}
|
||||
{/* Status indicator / Load More button */}
|
||||
{prs.length > 0 && (
|
||||
<div className="py-4 flex justify-center">
|
||||
{hasMore && onLoadMore ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onLoadMore}
|
||||
disabled={isLoadingMore}
|
||||
>
|
||||
{isLoadingMore ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('prReview.loadingMore')}
|
||||
</>
|
||||
) : (
|
||||
t('prReview.loadMore')
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground opacity-50">
|
||||
{hasMore ? t('prReview.maxPRsShown') : t('prReview.allPRsLoaded')}
|
||||
{t('prReview.allPRsLoaded')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,7 @@ interface UseGitHubPRsOptions {
|
||||
interface UseGitHubPRsResult {
|
||||
prs: PRData[];
|
||||
isLoading: boolean;
|
||||
isLoadingMore: boolean; // Loading additional PRs via pagination
|
||||
isLoadingPRDetails: boolean; // Loading full PR details including files
|
||||
error: string | null;
|
||||
selectedPR: PRData | null;
|
||||
@@ -38,6 +39,7 @@ interface UseGitHubPRsResult {
|
||||
hasMore: boolean; // True when 100 PRs returned (GitHub limit) - more may exist
|
||||
selectPR: (prNumber: number | null) => void;
|
||||
refresh: () => Promise<void>;
|
||||
loadMore: () => Promise<void>; // Load next page of PRs
|
||||
runReview: (prNumber: number) => void;
|
||||
runFollowupReview: (prNumber: number) => void;
|
||||
checkNewCommits: (prNumber: number) => Promise<NewCommitsCheck>;
|
||||
@@ -76,6 +78,8 @@ export function useGitHubPRs(
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [repoFullName, setRepoFullName] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [endCursor, setEndCursor] = useState<string | null>(null);
|
||||
|
||||
// Track previous isActive state to detect tab navigation
|
||||
const wasActiveRef = useRef(isActive);
|
||||
@@ -85,6 +89,10 @@ export function useGitHubPRs(
|
||||
const currentFetchPRNumberRef = useRef<number | null>(null);
|
||||
// AbortController for cancelling pending checkNewCommits calls on rapid PR switching
|
||||
const checkNewCommitsAbortRef = useRef<AbortController | null>(null);
|
||||
// Track current projectId for staleness checks in async operations
|
||||
const currentProjectIdRef = useRef(projectId);
|
||||
// Counter to detect stale loadMore responses after a refresh
|
||||
const fetchGenerationRef = useRef(0);
|
||||
|
||||
// Get PR review state from the global store
|
||||
const prReviews = usePRReviewStore((state) => state.prReviews);
|
||||
@@ -143,6 +151,9 @@ export function useGitHubPRs(
|
||||
async () => {
|
||||
if (!projectId) return;
|
||||
|
||||
// Increment generation to invalidate any in-flight loadMore requests
|
||||
fetchGenerationRef.current += 1;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
@@ -159,6 +170,8 @@ export function useGitHubPRs(
|
||||
if (result) {
|
||||
// Use hasNextPage from API to determine if more PRs exist
|
||||
setHasMore(result.hasNextPage);
|
||||
// Store endCursor for pagination
|
||||
setEndCursor(result.endCursor ?? null);
|
||||
setPrs(result.prs);
|
||||
|
||||
// Batch preload review results for PRs not in store (single IPC call)
|
||||
@@ -223,11 +236,15 @@ export function useGitHubPRs(
|
||||
|
||||
// Reset state and selected PR when project changes
|
||||
useEffect(() => {
|
||||
currentProjectIdRef.current = projectId;
|
||||
fetchGenerationRef.current += 1;
|
||||
hasLoadedRef.current = false;
|
||||
setHasMore(false);
|
||||
setEndCursor(null);
|
||||
setPrs([]);
|
||||
setSelectedPRNumber(null);
|
||||
setSelectedPRDetails(null);
|
||||
setIsLoadingMore(false);
|
||||
currentFetchPRNumberRef.current = null;
|
||||
// Cancel any pending checkNewCommits request
|
||||
if (checkNewCommitsAbortRef.current) {
|
||||
@@ -377,6 +394,91 @@ export function useGitHubPRs(
|
||||
await fetchPRs();
|
||||
}, [fetchPRs]);
|
||||
|
||||
// Load more PRs using cursor-based pagination
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!projectId || !endCursor || !hasMore || isLoadingMore) return;
|
||||
|
||||
// Capture current state for staleness checks
|
||||
const requestProjectId = projectId;
|
||||
const requestGeneration = fetchGenerationRef.current;
|
||||
|
||||
setIsLoadingMore(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.github.listMorePRs(projectId, endCursor);
|
||||
|
||||
// Discard response if project changed or a refresh happened while loading
|
||||
if (
|
||||
requestProjectId !== currentProjectIdRef.current ||
|
||||
requestGeneration !== fetchGenerationRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result) {
|
||||
// Check if this is a failure response (empty result with no next page)
|
||||
// In this case, preserve existing pagination state to allow retry
|
||||
const isFailureResponse = result.prs.length === 0 && !result.hasNextPage && !result.endCursor;
|
||||
|
||||
if (!isFailureResponse) {
|
||||
// Update pagination state only on successful response
|
||||
setHasMore(result.hasNextPage);
|
||||
setEndCursor(result.endCursor ?? null);
|
||||
|
||||
// Append new PRs to existing list, deduplicating by PR number
|
||||
// (handles edge case where PR shifts position between pagination requests)
|
||||
setPrs((prevPrs) => {
|
||||
const existingNumbers = new Set(prevPrs.map((pr) => pr.number));
|
||||
const newPrs = result.prs.filter((pr) => !existingNumbers.has(pr.number));
|
||||
return [...prevPrs, ...newPrs];
|
||||
});
|
||||
}
|
||||
|
||||
// Batch preload review results for new PRs not in store
|
||||
const prsNeedingPreload = result.prs.filter((pr) => {
|
||||
const existingState = getPRReviewState(requestProjectId, pr.number);
|
||||
return !existingState?.result && !existingState?.isReviewing;
|
||||
});
|
||||
|
||||
if (prsNeedingPreload.length > 0) {
|
||||
const prNumbers = prsNeedingPreload.map((pr) => pr.number);
|
||||
const batchReviews = await window.electronAPI.github.getPRReviewsBatch(
|
||||
requestProjectId,
|
||||
prNumbers
|
||||
);
|
||||
|
||||
// Check staleness again after async batch fetch
|
||||
if (
|
||||
requestProjectId !== currentProjectIdRef.current ||
|
||||
requestGeneration !== fetchGenerationRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update store with loaded results
|
||||
for (const reviewResult of Object.values(batchReviews)) {
|
||||
if (reviewResult) {
|
||||
usePRReviewStore.getState().setPRReviewResult(requestProjectId, reviewResult, {
|
||||
preserveNewCommitsCheck: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Only show error if still relevant
|
||||
if (
|
||||
requestProjectId === currentProjectIdRef.current &&
|
||||
requestGeneration === fetchGenerationRef.current
|
||||
) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load more PRs");
|
||||
}
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
}, [projectId, endCursor, hasMore, isLoadingMore, getPRReviewState]);
|
||||
|
||||
const runReview = useCallback(
|
||||
(prNumber: number) => {
|
||||
if (!projectId) return;
|
||||
@@ -567,6 +669,7 @@ export function useGitHubPRs(
|
||||
return {
|
||||
prs,
|
||||
isLoading,
|
||||
isLoadingMore,
|
||||
isLoadingPRDetails,
|
||||
error,
|
||||
selectedPR,
|
||||
@@ -582,6 +685,7 @@ export function useGitHubPRs(
|
||||
hasMore,
|
||||
selectPR,
|
||||
refresh,
|
||||
loadMore,
|
||||
runReview,
|
||||
runFollowupReview,
|
||||
checkNewCommits,
|
||||
|
||||
@@ -16,10 +16,13 @@ export type PRStatusFilter =
|
||||
| 'ready_to_merge'
|
||||
| 'ready_for_followup';
|
||||
|
||||
export type PRSortOption = 'newest' | 'oldest' | 'largest';
|
||||
|
||||
export interface PRFilterState {
|
||||
searchQuery: string;
|
||||
contributors: string[];
|
||||
statuses: PRStatusFilter[];
|
||||
sortBy: PRSortOption;
|
||||
}
|
||||
|
||||
interface PRReviewInfo {
|
||||
@@ -32,6 +35,7 @@ const DEFAULT_FILTERS: PRFilterState = {
|
||||
searchQuery: '',
|
||||
contributors: [],
|
||||
statuses: [],
|
||||
sortBy: 'newest',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -98,9 +102,9 @@ export function usePRFiltering(
|
||||
);
|
||||
}, [prs]);
|
||||
|
||||
// Filter PRs based on current filters
|
||||
// Filter and sort PRs based on current filters
|
||||
const filteredPRs = useMemo(() => {
|
||||
return prs.filter(pr => {
|
||||
const filtered = prs.filter(pr => {
|
||||
// Search filter - matches title or body
|
||||
if (filters.searchQuery) {
|
||||
const query = filters.searchQuery.toLowerCase();
|
||||
@@ -142,6 +146,36 @@ export function usePRFiltering(
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Pre-compute timestamps to avoid creating Date objects on every comparison
|
||||
const timestamps = new Map(
|
||||
filtered.map((pr) => [pr.number, new Date(pr.createdAt).getTime()])
|
||||
);
|
||||
|
||||
// Sort the filtered results
|
||||
return filtered.sort((a, b) => {
|
||||
const aTime = timestamps.get(a.number)!;
|
||||
const bTime = timestamps.get(b.number)!;
|
||||
|
||||
switch (filters.sortBy) {
|
||||
case 'newest':
|
||||
// Sort by createdAt descending (most recent first)
|
||||
return bTime - aTime;
|
||||
case 'oldest':
|
||||
// Sort by createdAt ascending (oldest first)
|
||||
return aTime - bTime;
|
||||
case 'largest': {
|
||||
// Sort by total changes (additions + deletions) descending
|
||||
const aChanges = (a.additions || 0) + (a.deletions || 0);
|
||||
const bChanges = (b.additions || 0) + (b.deletions || 0);
|
||||
if (bChanges !== aChanges) return bChanges - aChanges;
|
||||
// Secondary sort by createdAt (newest first) for stable ordering
|
||||
return bTime - aTime;
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
}, [prs, filters, getReviewStateForPR]);
|
||||
|
||||
// Filter setters
|
||||
@@ -157,8 +191,15 @@ export function usePRFiltering(
|
||||
setFiltersState(prev => ({ ...prev, statuses }));
|
||||
}, []);
|
||||
|
||||
const setSortBy = useCallback((sortBy: PRSortOption) => {
|
||||
setFiltersState(prev => ({ ...prev, sortBy }));
|
||||
}, []);
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setFiltersState(DEFAULT_FILTERS);
|
||||
setFiltersState((prev) => ({
|
||||
...DEFAULT_FILTERS,
|
||||
sortBy: prev.sortBy, // Preserve sort preference when clearing filters
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const hasActiveFilters = useMemo(() => {
|
||||
@@ -176,6 +217,7 @@ export function usePRFiltering(
|
||||
setSearchQuery,
|
||||
setContributors,
|
||||
setStatuses,
|
||||
setSortBy,
|
||||
clearFilters,
|
||||
hasActiveFilters,
|
||||
};
|
||||
|
||||
@@ -203,6 +203,7 @@ const browserMockAPI: ElectronAPI = {
|
||||
onAutoFixComplete: () => () => {},
|
||||
onAutoFixError: () => () => {},
|
||||
listPRs: async () => ({ prs: [], hasNextPage: false }),
|
||||
listMorePRs: async () => ({ prs: [], hasNextPage: false }),
|
||||
getPR: async () => null,
|
||||
runPRReview: () => {},
|
||||
cancelPRReview: async () => true,
|
||||
|
||||
@@ -388,6 +388,7 @@ export const IPC_CHANNELS = {
|
||||
|
||||
// GitHub PR Review operations
|
||||
GITHUB_PR_LIST: 'github:pr:list',
|
||||
GITHUB_PR_LIST_MORE: 'github:pr:listMore', // Load more PRs (pagination)
|
||||
GITHUB_PR_GET: 'github:pr:get',
|
||||
GITHUB_PR_GET_DIFF: 'github:pr:getDiff',
|
||||
GITHUB_PR_REVIEW: 'github:pr:review',
|
||||
|
||||
@@ -243,6 +243,12 @@
|
||||
"selectedCount": "{{count}} selected",
|
||||
"noResultsFound": "No results found",
|
||||
"reset": "Reset",
|
||||
"sort": {
|
||||
"label": "Sort",
|
||||
"newest": "Newest",
|
||||
"oldest": "Oldest",
|
||||
"largest": "Largest"
|
||||
},
|
||||
"pullRequests": "Pull Requests",
|
||||
"open": "open",
|
||||
"selectPRToView": "Select a pull request to view details",
|
||||
@@ -361,6 +367,8 @@
|
||||
"branchUpdateFailed": "Failed to update branch",
|
||||
"allPRsLoaded": "All PRs loaded",
|
||||
"maxPRsShown": "Showing first 100 PRs",
|
||||
"loadMore": "Load More",
|
||||
"loadingMore": "Loading...",
|
||||
"workflowsAwaitingApproval": "{{count}} Workflow Awaiting Approval",
|
||||
"workflowsAwaitingApproval_plural": "{{count}} Workflows Awaiting Approval",
|
||||
"blockedByWorkflows": "Blocked",
|
||||
|
||||
@@ -243,6 +243,12 @@
|
||||
"selectedCount": "{{count}} sélectionné(s)",
|
||||
"noResultsFound": "Aucun résultat trouvé",
|
||||
"reset": "Réinitialiser",
|
||||
"sort": {
|
||||
"label": "Trier",
|
||||
"newest": "Plus récent",
|
||||
"oldest": "Plus ancien",
|
||||
"largest": "Plus grand"
|
||||
},
|
||||
"pullRequests": "Pull Requests",
|
||||
"open": "ouvert",
|
||||
"selectPRToView": "Sélectionnez une pull request pour voir les détails",
|
||||
@@ -370,6 +376,8 @@
|
||||
"branchUpdateFailed": "Échec de la mise à jour de la branche",
|
||||
"allPRsLoaded": "Tous les PRs chargés",
|
||||
"maxPRsShown": "Affichage des 100 premières PRs",
|
||||
"loadMore": "Charger plus",
|
||||
"loadingMore": "Chargement...",
|
||||
"workflowsAwaitingApproval": "{{count}} workflow en attente d'approbation",
|
||||
"workflowsAwaitingApproval_plural": "{{count}} workflows en attente d'approbation",
|
||||
"blockedByWorkflows": "Bloqué",
|
||||
|
||||
Reference in New Issue
Block a user