feat: enhance pr review page to include PRs filters (#423)
* fix: Allow windows to run CC PR Reviewer Signed-off-by: Alex Madera <e.a_madera@hotmail.com> * solve auto claude comments * fix linting * feat: enhance github PR page to include filters * solve pr comments * feat(i18n): add translation keys for PR review status tree Replace hardcoded strings in ReviewStatusTree and PRHeader components with i18n translation keys for proper internationalization: - Add useTranslation hook to ReviewStatusTree and PRHeader components - Replace all status labels, button text, and dynamic descriptions - Add interpolation for count-based strings (findings, commits, files) - Add 13 new translation keys to en/common.json and fr/common.json New translation keys added: - prReview.runAIReview, reviewStarted, analysisInProgress - prReview.analysisComplete (with count interpolation) - prReview.findingsPostedToGitHub, newCommits, runFollowup - prReview.aiReviewInProgress, waitingForChanges, reviewComplete - prReview.reviewStatus, files, filesChanged 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(i18n): add translation keys for PR action bar Replace hardcoded strings in PRDetail Action Bar with i18n keys: - Add useTranslation hook to PRDetail component - Replace "Posting...", "Post X Finding(s)", "Approve", "Merge", and "Posted X finding(s)" with translation keys - Use i18next pluralization (_plural suffix) for count-based strings - Add 7 new translation keys to en/common.json and fr/common.json New translation keys with pluralization support: - prReview.posting - loading state - prReview.postFindings / postFindings_plural - post button - prReview.approve - approve button - prReview.merge - merge button - prReview.postedFindings / postedFindings_plural - success message 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(i18n): add translation keys for follow-up review and description Replace hardcoded strings with i18n translation keys: Follow-up review badges (lines 693-714): - "X resolved" → t('prReview.resolved', { count }) - "X still open" → t('prReview.stillOpen', { count }) - "X new issue(s)" → t('prReview.newIssue', { count }) with pluralization Description section (lines 746-762): - "Description" → t('prReview.description') - "No description provided." → t('prReview.noDescription') - "Review Failed" → t('prReview.reviewFailed') Added 9 new translation keys with plural forms to both locale files. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Signed-off-by: Alex Madera <e.a_madera@hotmail.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -109,6 +109,7 @@ export interface PRReviewResult {
|
||||
// Track if findings have been posted to GitHub (enables follow-up review)
|
||||
hasPostedFindings?: boolean;
|
||||
postedFindingIds?: string[];
|
||||
postedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -205,6 +206,7 @@ function getReviewResult(project: Project, prNumber: number): PRReviewResult | n
|
||||
// Track posted findings for follow-up review eligibility
|
||||
hasPostedFindings: data.has_posted_findings ?? false,
|
||||
postedFindingIds: data.posted_finding_ids ?? [],
|
||||
postedAt: data.posted_at,
|
||||
};
|
||||
} catch {
|
||||
// File doesn't exist or couldn't be read
|
||||
@@ -708,6 +710,7 @@ export function registerPRHandlers(
|
||||
const newPostedIds = findings.map(f => f.id);
|
||||
const existingPostedIds = data.posted_finding_ids || [];
|
||||
data.posted_finding_ids = [...new Set([...existingPostedIds, ...newPostedIds])];
|
||||
data.posted_at = new Date().toISOString();
|
||||
fs.writeFileSync(reviewPath, JSON.stringify(data, null, 2), 'utf-8');
|
||||
debugLog('Updated review result with review ID and posted findings', { prNumber, reviewId, postedCount: newPostedIds.length });
|
||||
} catch {
|
||||
|
||||
@@ -325,6 +325,7 @@ export interface PRReviewResult {
|
||||
// Track if findings have been posted to GitHub (enables follow-up review)
|
||||
hasPostedFindings?: boolean;
|
||||
postedFindingIds?: string[];
|
||||
postedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useCallback } from 'react';
|
||||
import { GitPullRequest, RefreshCw, ExternalLink, Settings } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useProjectStore } from '../../stores/project-store';
|
||||
import { useGitHubPRs } from './hooks';
|
||||
import { PRList, PRDetail } from './components';
|
||||
import { useGitHubPRs, usePRFiltering } from './hooks';
|
||||
import { PRList, PRDetail, PRFilterBar } from './components';
|
||||
import { Button } from '../ui/button';
|
||||
import { ResizablePanels } from '../ui/resizable-panels';
|
||||
|
||||
interface GitHubPRsProps {
|
||||
onOpenSettings?: () => void;
|
||||
@@ -11,23 +13,25 @@ interface GitHubPRsProps {
|
||||
|
||||
function NotConnectedState({
|
||||
error,
|
||||
onOpenSettings
|
||||
onOpenSettings,
|
||||
t
|
||||
}: {
|
||||
error: string | null;
|
||||
onOpenSettings?: () => void;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center p-8">
|
||||
<div className="text-center max-w-md">
|
||||
<GitPullRequest className="h-12 w-12 mx-auto mb-4 text-muted-foreground opacity-50" />
|
||||
<h3 className="text-lg font-medium mb-2">GitHub Not Connected</h3>
|
||||
<h3 className="text-lg font-medium mb-2">{t('prReview.notConnected')}</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{error || 'Connect your GitHub account to view and review pull requests.'}
|
||||
{error || t('prReview.connectPrompt')}
|
||||
</p>
|
||||
{onOpenSettings && (
|
||||
<Button onClick={onOpenSettings} variant="outline">
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Open Settings
|
||||
{t('prReview.openSettings')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -47,6 +51,7 @@ function EmptyState({ message }: { message: string }) {
|
||||
}
|
||||
|
||||
export function GitHubPRs({ onOpenSettings }: GitHubPRsProps) {
|
||||
const { t } = useTranslation('common');
|
||||
const projects = useProjectStore((state) => state.projects);
|
||||
const selectedProjectId = useProjectStore((state) => state.selectedProjectId);
|
||||
const selectedProject = projects.find((p) => p.id === selectedProjectId);
|
||||
@@ -77,6 +82,18 @@ export function GitHubPRs({ onOpenSettings }: GitHubPRsProps) {
|
||||
|
||||
const selectedPR = prs.find(pr => pr.number === selectedPRNumber);
|
||||
|
||||
// PR filtering
|
||||
const {
|
||||
filteredPRs,
|
||||
contributors,
|
||||
filters,
|
||||
setSearchQuery,
|
||||
setContributors,
|
||||
setStatuses,
|
||||
clearFilters,
|
||||
hasActiveFilters,
|
||||
} = usePRFiltering(prs, getReviewStateForPR);
|
||||
|
||||
const handleRunReview = useCallback(() => {
|
||||
if (selectedPRNumber) {
|
||||
runReview(selectedPRNumber);
|
||||
@@ -129,7 +146,7 @@ export function GitHubPRs({ onOpenSettings }: GitHubPRsProps) {
|
||||
|
||||
// Not connected state
|
||||
if (!isConnected) {
|
||||
return <NotConnectedState error={error} onOpenSettings={onOpenSettings} />;
|
||||
return <NotConnectedState error={error} onOpenSettings={onOpenSettings} t={t} />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -139,7 +156,7 @@ export function GitHubPRs({ onOpenSettings }: GitHubPRsProps) {
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-sm font-medium flex items-center gap-2">
|
||||
<GitPullRequest className="h-4 w-4" />
|
||||
Pull Requests
|
||||
{t('prReview.pullRequests')}
|
||||
</h2>
|
||||
{repoFullName && (
|
||||
<a
|
||||
@@ -153,7 +170,7 @@ export function GitHubPRs({ onOpenSettings }: GitHubPRsProps) {
|
||||
</a>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{prs.length} open
|
||||
{prs.length} {t('prReview.open')}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
@@ -166,24 +183,35 @@ export function GitHubPRs({ onOpenSettings }: GitHubPRsProps) {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 flex min-h-0">
|
||||
{/* PR List */}
|
||||
<div className="w-1/2 border-r border-border flex flex-col">
|
||||
<PRList
|
||||
prs={prs}
|
||||
selectedPRNumber={selectedPRNumber}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
activePRReviews={activePRReviews}
|
||||
getReviewStateForPR={getReviewStateForPR}
|
||||
onSelectPR={selectPR}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* PR Detail */}
|
||||
<div className="w-1/2 flex flex-col">
|
||||
{selectedPR ? (
|
||||
{/* Content - Resizable split panels */}
|
||||
<ResizablePanels
|
||||
defaultLeftWidth={50}
|
||||
minLeftWidth={30}
|
||||
maxLeftWidth={70}
|
||||
storageKey="github-prs-panel-width"
|
||||
leftPanel={
|
||||
<div className="flex flex-col h-full">
|
||||
<PRFilterBar
|
||||
filters={filters}
|
||||
contributors={contributors}
|
||||
hasActiveFilters={hasActiveFilters}
|
||||
onSearchChange={setSearchQuery}
|
||||
onContributorsChange={setContributors}
|
||||
onStatusesChange={setStatuses}
|
||||
onClearFilters={clearFilters}
|
||||
/>
|
||||
<PRList
|
||||
prs={filteredPRs}
|
||||
selectedPRNumber={selectedPRNumber}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
getReviewStateForPR={getReviewStateForPR}
|
||||
onSelectPR={selectPR}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
rightPanel={
|
||||
selectedPR ? (
|
||||
<PRDetail
|
||||
pr={selectedPR}
|
||||
reviewResult={reviewResult}
|
||||
@@ -199,10 +227,10 @@ export function GitHubPRs({ onOpenSettings }: GitHubPRsProps) {
|
||||
onAssignPR={handleAssignPR}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState message="Select a pull request to view details" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState message={t('prReview.selectPRToView')} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ExternalLink,
|
||||
User,
|
||||
Users,
|
||||
Clock,
|
||||
GitBranch,
|
||||
FileDiff,
|
||||
@@ -17,6 +17,11 @@ import {
|
||||
MessageSquare,
|
||||
AlertTriangle,
|
||||
CheckCheck,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Circle,
|
||||
CircleDot,
|
||||
Play
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '../../ui/badge';
|
||||
import { Button } from '../../ui/button';
|
||||
@@ -24,7 +29,9 @@ import { Card, CardContent, CardHeader, CardTitle } from '../../ui/card';
|
||||
import { ScrollArea } from '../../ui/scroll-area';
|
||||
import { Progress } from '../../ui/progress';
|
||||
import { ReviewFindings } from './ReviewFindings';
|
||||
import type { PRData, PRReviewResult, PRReviewProgress, PRReviewFinding } from '../hooks/useGitHubPRs';
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '../../ui/collapsible';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import type { PRData, PRReviewResult, PRReviewProgress } from '../hooks/useGitHubPRs';
|
||||
import type { NewCommitsCheck } from '../../../../preload/api/modules/github-api';
|
||||
|
||||
interface PRDetailProps {
|
||||
@@ -63,6 +70,256 @@ function getStatusColor(status: PRReviewResult['overallStatus']): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Compact Tree View for Review Process
|
||||
function ReviewStatusTree({
|
||||
status,
|
||||
isReviewing,
|
||||
reviewResult,
|
||||
postedCount,
|
||||
onRunReview,
|
||||
onRunFollowupReview,
|
||||
onCancelReview,
|
||||
newCommitsCheck,
|
||||
lastPostedAt
|
||||
}: {
|
||||
status: 'not_reviewed' | 'reviewed_pending_post' | 'waiting_for_changes' | 'ready_to_merge' | 'needs_attention' | 'ready_for_followup' | 'followup_issues_remain';
|
||||
isReviewing: boolean;
|
||||
reviewResult: PRReviewResult | null;
|
||||
postedCount: number;
|
||||
onRunReview: () => void;
|
||||
onRunFollowupReview: () => void;
|
||||
onCancelReview: () => void;
|
||||
newCommitsCheck: NewCommitsCheck | null;
|
||||
lastPostedAt?: number | null;
|
||||
}) {
|
||||
const { t } = useTranslation('common');
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
// If not reviewed, show simple status
|
||||
if (status === 'not_reviewed' && !isReviewing) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-y-3 p-4 border rounded-lg bg-card shadow-sm">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="h-2.5 w-2.5 shrink-0 rounded-full bg-muted-foreground/30" />
|
||||
<span className="font-medium text-muted-foreground truncate">{t('prReview.notReviewed')}</span>
|
||||
</div>
|
||||
<Button onClick={onRunReview} size="sm" className="gap-2 shrink-0 ml-auto sm:ml-0">
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
{t('prReview.runAIReview')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Determine steps for the tree
|
||||
const steps: { id: string; label: string; status: string; date?: string | null; action?: React.ReactNode }[] = [];
|
||||
|
||||
// Step 1: Start
|
||||
steps.push({
|
||||
id: 'start',
|
||||
label: t('prReview.reviewStarted'),
|
||||
status: 'completed',
|
||||
date: reviewResult?.reviewedAt || new Date().toISOString()
|
||||
});
|
||||
|
||||
// Step 2: AI Analysis
|
||||
if (isReviewing) {
|
||||
steps.push({
|
||||
id: 'analysis',
|
||||
label: t('prReview.analysisInProgress'),
|
||||
status: 'current',
|
||||
date: null
|
||||
});
|
||||
} else if (reviewResult) {
|
||||
steps.push({
|
||||
id: 'analysis',
|
||||
label: t('prReview.analysisComplete', { count: reviewResult.findings.length }),
|
||||
status: 'completed',
|
||||
date: reviewResult.reviewedAt
|
||||
});
|
||||
}
|
||||
|
||||
// Step 3: Posting
|
||||
if (postedCount > 0 || reviewResult?.hasPostedFindings) {
|
||||
steps.push({
|
||||
id: 'posted',
|
||||
label: t('prReview.findingsPostedToGitHub'),
|
||||
status: 'completed',
|
||||
date: reviewResult?.postedAt || (lastPostedAt ? new Date(lastPostedAt).toISOString() : null)
|
||||
});
|
||||
} else if (reviewResult && reviewResult.findings.length > 0) {
|
||||
steps.push({
|
||||
id: 'posted',
|
||||
label: t('prReview.pendingPost'),
|
||||
status: 'pending',
|
||||
date: null
|
||||
});
|
||||
}
|
||||
|
||||
// Step 4: Follow-up
|
||||
if (newCommitsCheck?.hasNewCommits) {
|
||||
steps.push({
|
||||
id: 'new_commits',
|
||||
label: t('prReview.newCommits', { count: newCommitsCheck.newCommitCount }),
|
||||
status: 'alert',
|
||||
date: null
|
||||
});
|
||||
steps.push({
|
||||
id: 'followup',
|
||||
label: t('prReview.readyForFollowup'),
|
||||
status: 'pending',
|
||||
action: (
|
||||
<Button size="sm" variant="outline" onClick={onRunFollowupReview} className="ml-2 h-6 text-xs px-2">
|
||||
{t('prReview.runFollowup')}
|
||||
</Button>
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
className="border rounded-lg bg-card shadow-sm overflow-hidden"
|
||||
>
|
||||
{/* Header / Status Bar */}
|
||||
<div className="p-4 flex flex-wrap items-center justify-between gap-y-2 bg-muted/30">
|
||||
<div className="flex items-center gap-3 min-w-0 pr-2">
|
||||
<div className={cn("h-2.5 w-2.5 shrink-0 rounded-full",
|
||||
isReviewing ? "bg-blue-500 animate-pulse" :
|
||||
status === 'ready_to_merge' ? "bg-success" :
|
||||
status === 'waiting_for_changes' ? "bg-warning" :
|
||||
status === 'reviewed_pending_post' ? "bg-primary" :
|
||||
status === 'ready_for_followup' ? "bg-info" :
|
||||
"bg-muted-foreground"
|
||||
)} />
|
||||
<span className="font-medium truncate">
|
||||
{isReviewing ? t('prReview.aiReviewInProgress') :
|
||||
status === 'ready_to_merge' ? t('prReview.readyToMerge') :
|
||||
status === 'waiting_for_changes' ? t('prReview.waitingForChanges') :
|
||||
status === 'reviewed_pending_post' ? t('prReview.reviewComplete') :
|
||||
status === 'ready_for_followup' ? t('prReview.readyForFollowup') :
|
||||
t('prReview.reviewStatus')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 ml-auto sm:ml-0">
|
||||
{isReviewing && (
|
||||
<Button variant="ghost" size="sm" onClick={onCancelReview} className="h-7 text-destructive hover:text-destructive">
|
||||
{t('buttons.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6">
|
||||
{isOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Collapsible Tree */}
|
||||
<CollapsibleContent>
|
||||
<div className="p-4 pt-0">
|
||||
<div className="relative pl-2 ml-2 border-l border-border/50 space-y-4 pt-4">
|
||||
{steps.map((step) => (
|
||||
<div key={step.id} className="relative flex items-start gap-3 pl-4">
|
||||
{/* Node Dot */}
|
||||
<div className={cn("absolute -left-[13px] top-1 bg-background rounded-full p-0.5 border",
|
||||
step.status === 'completed' ? "border-success text-success" :
|
||||
step.status === 'current' ? "border-primary text-primary animate-pulse" :
|
||||
step.status === 'alert' ? "border-warning text-warning" :
|
||||
"border-muted-foreground text-muted-foreground"
|
||||
)}>
|
||||
{step.status === 'completed' ? <CheckCircle className="h-3 w-3" /> :
|
||||
step.status === 'current' ? <CircleDot className="h-3 w-3" /> :
|
||||
<Circle className="h-3 w-3" />}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className={cn("text-sm font-medium truncate max-w-full",
|
||||
step.status === 'completed' ? "text-foreground" :
|
||||
step.status === 'current' ? "text-primary" :
|
||||
"text-muted-foreground"
|
||||
)}>
|
||||
{step.label}
|
||||
</span>
|
||||
{step.action}
|
||||
</div>
|
||||
{step.date && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
{formatDate(step.date)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
// Modern Header Component
|
||||
function PRHeader({ pr }: { pr: PRData }) {
|
||||
const { t } = useTranslation('common');
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant={pr.state.toLowerCase() === 'open' ? 'success' : 'secondary'} className={cn(
|
||||
"capitalize px-2.5 py-0.5",
|
||||
pr.state.toLowerCase() === 'open' ? "bg-emerald-500/15 text-emerald-500 hover:bg-emerald-500/25 border-emerald-500/20" : ""
|
||||
)}>
|
||||
{pr.state}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-sm font-mono">#{pr.number}</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8 text-muted-foreground hover:text-foreground">
|
||||
<a href={pr.htmlUrl} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<h1 className="text-xl font-bold mb-4 leading-tight">{pr.title}</h1>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 text-sm text-muted-foreground border-b border-border/40 pb-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-muted rounded-full p-1">
|
||||
<User className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<span className="font-medium text-foreground">{pr.author.login}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 opacity-70" />
|
||||
<span>{formatDate(pr.createdAt)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-muted/50 font-mono text-xs border border-border/50">
|
||||
<GitBranch className="h-3 w-3" />
|
||||
<span className="text-foreground">{pr.headRefName}</span>
|
||||
<span className="text-muted-foreground/50 mx-1">→</span>
|
||||
<span className="text-foreground">{pr.baseRefName}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 ml-auto">
|
||||
<div className="flex items-center gap-1.5" title={t('prReview.filesChanged', { count: pr.changedFiles })}>
|
||||
<FileDiff className="h-4 w-4" />
|
||||
<span className="font-medium text-foreground">{pr.changedFiles}</span>
|
||||
<span className="text-xs">{t('prReview.files')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs font-mono">
|
||||
<span className="text-emerald-500 bg-emerald-500/10 px-1.5 py-0.5 rounded">+{pr.additions}</span>
|
||||
<span className="text-red-500 bg-red-500/10 px-1.5 py-0.5 rounded">-{pr.deletions}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PRDetail({
|
||||
pr,
|
||||
reviewResult,
|
||||
@@ -77,6 +334,7 @@ export function PRDetail({
|
||||
onMergePR,
|
||||
onAssignPR: _onAssignPR,
|
||||
}: PRDetailProps) {
|
||||
const { t } = useTranslation('common');
|
||||
// Selection state for findings
|
||||
const [selectedFindingIds, setSelectedFindingIds] = useState<Set<string>>(new Set());
|
||||
const [postedFindingIds, setPostedFindingIds] = useState<Set<string>>(new Set());
|
||||
@@ -330,230 +588,98 @@ export function PRDetail({
|
||||
|
||||
return (
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="bg-success/20 text-success border-success/50">
|
||||
Open
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">#{pr.number}</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<a href={pr.htmlUrl} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground">{pr.title}</h2>
|
||||
</div>
|
||||
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
||||
|
||||
{/* Refactored Header */}
|
||||
<PRHeader pr={pr} />
|
||||
|
||||
{/* Meta */}
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<User className="h-4 w-4" />
|
||||
{pr.author.login}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-4 w-4" />
|
||||
{formatDate(pr.createdAt)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
{pr.headRefName} → {pr.baseRefName}
|
||||
</div>
|
||||
{pr.assignees && pr.assignees.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Users className="h-4 w-4" />
|
||||
{pr.assignees.map(a => a.login).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Review Status & Actions */}
|
||||
<ReviewStatusTree
|
||||
status={prStatus.status}
|
||||
isReviewing={isReviewing}
|
||||
reviewResult={reviewResult}
|
||||
postedCount={postedFindingIds.size + (reviewResult?.postedFindingIds?.length ?? 0)}
|
||||
onRunReview={onRunReview}
|
||||
onRunFollowupReview={onRunFollowupReview}
|
||||
onCancelReview={onCancelReview}
|
||||
newCommitsCheck={newCommitsCheck}
|
||||
lastPostedAt={postSuccess?.timestamp}
|
||||
/>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Badge variant="outline" className="flex items-center gap-1">
|
||||
<FileDiff className="h-3 w-3" />
|
||||
{pr.changedFiles} files
|
||||
</Badge>
|
||||
<span className="text-sm text-success">+{pr.additions}</span>
|
||||
<span className="text-sm text-destructive">-{pr.deletions}</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Show Follow-up Review button if there are new commits since last review */}
|
||||
{newCommitsCheck?.hasNewCommits && !isReviewing ? (
|
||||
<Button
|
||||
onClick={onRunFollowupReview}
|
||||
disabled={isReviewing}
|
||||
className="flex-1"
|
||||
variant="secondary"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Follow-up Review ({newCommitsCheck.newCommitCount} new commit{newCommitsCheck.newCommitCount !== 1 ? 's' : ''})
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={onRunReview}
|
||||
disabled={isReviewing}
|
||||
className="flex-1"
|
||||
>
|
||||
{isReviewing ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Reviewing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
Run AI Review
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{isReviewing && (
|
||||
<Button onClick={onCancelReview} variant="destructive">
|
||||
<XCircle className="h-4 w-4 mr-2" />
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
{reviewResult && reviewResult.success && selectedCount > 0 && !isReviewing && (
|
||||
<Button onClick={handlePostReview} variant="secondary" disabled={isPostingFindings}>
|
||||
{isPostingFindings ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Posting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
Post {selectedCount} Finding{selectedCount !== 1 ? 's' : ''}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{/* Success message */}
|
||||
{postSuccess && (
|
||||
<div className="flex items-center gap-2 text-success text-sm">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
Posted {postSuccess.count} finding{postSuccess.count !== 1 ? 's' : ''} to GitHub
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Approval and Merge buttons */}
|
||||
{reviewResult && reviewResult.success && isReadyToMerge && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={handleApprove}
|
||||
disabled={isPosting}
|
||||
variant="default"
|
||||
className="flex-1 bg-success hover:bg-success/90"
|
||||
>
|
||||
{isPosting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Posting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Approve
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleMerge}
|
||||
disabled={isMerging}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
>
|
||||
{isMerging ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Merging...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GitMerge className="h-4 w-4 mr-2" />
|
||||
Merge PR
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* PR Review Status Banner */}
|
||||
<Card className={`border-2 ${prStatus.color} ${prStatus.status === 'ready_for_followup' ? 'animate-pulse-subtle' : ''}`}>
|
||||
<CardContent className="py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-full ${prStatus.color}`}>
|
||||
{prStatus.icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium">{prStatus.label}</div>
|
||||
<div className="text-sm text-muted-foreground truncate">{prStatus.description}</div>
|
||||
</div>
|
||||
{prStatus.status === 'ready_for_followup' && (
|
||||
<Button
|
||||
onClick={onRunFollowupReview}
|
||||
disabled={isReviewing}
|
||||
className="bg-info hover:bg-info/90 text-info-foreground shrink-0"
|
||||
>
|
||||
{isReviewing ? (
|
||||
{/* Action Bar (Legacy Actions that fit under the tree context) */}
|
||||
{reviewResult && reviewResult.success && !isReviewing && (
|
||||
<div className="flex flex-wrap items-center gap-3 animate-in fade-in slide-in-from-top-2 duration-300">
|
||||
{selectedCount > 0 && (
|
||||
<Button onClick={handlePostReview} variant="secondary" disabled={isPostingFindings} className="flex-1 sm:flex-none">
|
||||
{isPostingFindings ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Reviewing...
|
||||
{t('prReview.posting')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Run Follow-up Review
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
{t('prReview.postFindings', { count: selectedCount })}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{prStatus.status === 'waiting_for_changes' && newCommitsCheck?.hasNewCommits && (
|
||||
<Badge variant="outline" className="bg-primary/20 text-primary border-primary/50 shrink-0">
|
||||
<RefreshCw className="h-3 w-3 mr-1" />
|
||||
{newCommitsCheck.newCommitCount} new commit{newCommitsCheck.newCommitCount !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isReadyToMerge && (
|
||||
<>
|
||||
<Button
|
||||
onClick={handleApprove}
|
||||
disabled={isPosting}
|
||||
variant="default"
|
||||
className="flex-1 sm:flex-none bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
>
|
||||
{isPosting ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <CheckCircle className="h-4 w-4 mr-2" />}
|
||||
{t('prReview.approve')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleMerge}
|
||||
disabled={isMerging}
|
||||
variant="outline"
|
||||
className="flex-1 sm:flex-none"
|
||||
>
|
||||
{isMerging ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <GitMerge className="h-4 w-4 mr-2" />}
|
||||
{t('prReview.merge')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{postSuccess && (
|
||||
<div className="ml-auto flex items-center gap-2 text-emerald-600 text-sm font-medium animate-pulse">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
{t('prReview.postedFindings', { count: postSuccess.count })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Review Progress */}
|
||||
{reviewProgress && (
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>{reviewProgress.message}</span>
|
||||
<span className="text-muted-foreground">{reviewProgress.progress}%</span>
|
||||
</div>
|
||||
<Progress value={reviewProgress.progress} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium">{reviewProgress.message}</span>
|
||||
<span className="text-muted-foreground">{reviewProgress.progress}%</span>
|
||||
</div>
|
||||
<Progress value={reviewProgress.progress} className="h-2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Review Result */}
|
||||
{/* Review Result / Findings */}
|
||||
{reviewResult && reviewResult.success && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm flex items-center justify-between">
|
||||
<Card className="border shadow-sm">
|
||||
<CardHeader className="pb-3 border-b bg-muted/20">
|
||||
<CardTitle className="text-sm font-medium flex items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
{reviewResult.isFollowupReview ? (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
<RefreshCw className="h-4 w-4 text-blue-500" />
|
||||
) : (
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<Sparkles className="h-4 w-4 text-purple-500" />
|
||||
)}
|
||||
{reviewResult.isFollowupReview ? 'Follow-up Review' : 'AI Review Result'}
|
||||
{reviewResult.isFollowupReview ? 'Follow-up Review Details' : 'AI Analysis Results'}
|
||||
</span>
|
||||
<Badge variant="outline" className={getStatusColor(reviewResult.overallStatus)}>
|
||||
{reviewResult.overallStatus === 'approve' && 'Approve'}
|
||||
@@ -562,32 +688,34 @@ export function PRDetail({
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 overflow-hidden">
|
||||
<CardContent className="space-y-6 pt-6">
|
||||
{/* Follow-up Review Resolution Status */}
|
||||
{reviewResult.isFollowupReview && (
|
||||
<div className="flex flex-wrap gap-2 pb-2 border-b border-border">
|
||||
<div className="flex flex-wrap gap-3 pb-4 border-b border-border/50">
|
||||
{(reviewResult.resolvedFindings?.length ?? 0) > 0 && (
|
||||
<Badge variant="outline" className="bg-success/20 text-success border-success/50">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
{reviewResult.resolvedFindings?.length} resolved
|
||||
<Badge variant="outline" className="bg-success/10 text-success border-success/30 px-3 py-1">
|
||||
<CheckCircle className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('prReview.resolved', { count: reviewResult.resolvedFindings?.length ?? 0 })}
|
||||
</Badge>
|
||||
)}
|
||||
{(reviewResult.unresolvedFindings?.length ?? 0) > 0 && (
|
||||
<Badge variant="outline" className="bg-warning/20 text-warning border-warning/50">
|
||||
<AlertCircle className="h-3 w-3 mr-1" />
|
||||
{reviewResult.unresolvedFindings?.length} still open
|
||||
<Badge variant="outline" className="bg-warning/10 text-warning border-warning/30 px-3 py-1">
|
||||
<AlertCircle className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('prReview.stillOpen', { count: reviewResult.unresolvedFindings?.length ?? 0 })}
|
||||
</Badge>
|
||||
)}
|
||||
{(reviewResult.newFindingsSinceLastReview?.length ?? 0) > 0 && (
|
||||
<Badge variant="outline" className="bg-destructive/20 text-destructive border-destructive/50">
|
||||
<XCircle className="h-3 w-3 mr-1" />
|
||||
{reviewResult.newFindingsSinceLastReview?.length} new issue{reviewResult.newFindingsSinceLastReview?.length !== 1 ? 's' : ''}
|
||||
<Badge variant="outline" className="bg-destructive/10 text-destructive border-destructive/30 px-3 py-1">
|
||||
<XCircle className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('prReview.newIssue', { count: reviewResult.newFindingsSinceLastReview?.length ?? 0 })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-muted-foreground break-words">{reviewResult.summary}</p>
|
||||
<div className="bg-muted/30 p-4 rounded-lg text-sm text-muted-foreground leading-relaxed">
|
||||
{reviewResult.summary}
|
||||
</div>
|
||||
|
||||
{/* Interactive Findings with Selection */}
|
||||
<ReviewFindings
|
||||
@@ -596,26 +724,20 @@ export function PRDetail({
|
||||
postedIds={postedFindingIds}
|
||||
onSelectionChange={setSelectedFindingIds}
|
||||
/>
|
||||
|
||||
{reviewResult.reviewedAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Reviewed: {formatDate(reviewResult.reviewedAt)}
|
||||
{reviewResult.reviewedCommitSha && (
|
||||
<> at commit {reviewResult.reviewedCommitSha.substring(0, 7)}</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Review Error */}
|
||||
{reviewResult && !reviewResult.success && reviewResult.error && (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-2 text-destructive">
|
||||
<XCircle className="h-4 w-4" />
|
||||
<span className="text-sm">{reviewResult.error}</span>
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-3 text-destructive">
|
||||
<XCircle className="h-5 w-5 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold">{t('prReview.reviewFailed')}</p>
|
||||
<p className="text-sm opacity-90">{reviewResult.error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -624,47 +746,20 @@ export function PRDetail({
|
||||
{/* Description */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Description</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t('prReview.description')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-hidden">
|
||||
{pr.body ? (
|
||||
<pre className="whitespace-pre-wrap text-sm text-muted-foreground font-sans break-words max-w-full overflow-hidden">
|
||||
{pr.body}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No description provided.
|
||||
</p>
|
||||
)}
|
||||
<CardContent>
|
||||
<ScrollArea className="h-[400px] w-full rounded-md border p-4 bg-muted/10">
|
||||
{pr.body ? (
|
||||
<pre className="whitespace-pre-wrap text-sm text-muted-foreground font-sans break-words">
|
||||
{pr.body}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic">{t('prReview.noDescription')}</p>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Changed Files */}
|
||||
{pr.files && pr.files.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Changed Files ({pr.files.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-1">
|
||||
{pr.files.map((file) => (
|
||||
<div
|
||||
key={file.path}
|
||||
className="flex items-center justify-between text-xs py-1"
|
||||
>
|
||||
<code className="text-muted-foreground truncate flex-1">
|
||||
{file.path}
|
||||
</code>
|
||||
<div className="flex items-center gap-2 ml-2">
|
||||
<span className="text-success">+{file.additions}</span>
|
||||
<span className="text-destructive">-{file.deletions}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
/**
|
||||
* Filter bar for GitHub PRs list
|
||||
* Grid layout: Contributors (3) | Status (3) | Search (8)
|
||||
* Multi-select dropdowns with visible chip selections
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useRef, useCallback } from 'react';
|
||||
import {
|
||||
Search,
|
||||
Users,
|
||||
Sparkles,
|
||||
CheckCircle2,
|
||||
Send,
|
||||
AlertCircle,
|
||||
CheckCheck,
|
||||
RefreshCw,
|
||||
X,
|
||||
Filter,
|
||||
Check
|
||||
} from 'lucide-react';
|
||||
import { Input } from '../../ui/input';
|
||||
import { Badge } from '../../ui/badge';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Separator } from '../../ui/separator';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '../../ui/dropdown-menu';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { PRFilterState, PRStatusFilter } from '../hooks/usePRFiltering';
|
||||
import { cn } from '../../../lib/utils';
|
||||
|
||||
interface PRFilterBarProps {
|
||||
filters: PRFilterState;
|
||||
contributors: string[];
|
||||
hasActiveFilters: boolean;
|
||||
onSearchChange: (query: string) => void;
|
||||
onContributorsChange: (contributors: string[]) => void;
|
||||
onStatusesChange: (statuses: PRStatusFilter[]) => void;
|
||||
onClearFilters: () => void;
|
||||
}
|
||||
|
||||
// Status options
|
||||
const STATUS_OPTIONS: Array<{
|
||||
value: PRStatusFilter;
|
||||
labelKey: string;
|
||||
icon: typeof Sparkles;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
}> = [
|
||||
{ value: 'not_reviewed', labelKey: 'prReview.notReviewed', icon: Sparkles, color: 'text-slate-500', bgColor: 'bg-slate-500/20' },
|
||||
{ value: 'reviewed', labelKey: 'prReview.reviewed', icon: CheckCircle2, color: 'text-blue-400', bgColor: 'bg-blue-500/20' },
|
||||
{ value: 'posted', labelKey: 'prReview.posted', icon: Send, color: 'text-purple-400', bgColor: 'bg-purple-500/20' },
|
||||
{ value: 'changes_requested', labelKey: 'prReview.changesRequested', icon: AlertCircle, color: 'text-red-400', bgColor: 'bg-red-500/20' },
|
||||
{ value: 'ready_to_merge', labelKey: 'prReview.readyToMerge', icon: CheckCheck, color: 'text-emerald-400', bgColor: 'bg-emerald-500/20' },
|
||||
{ value: 'ready_for_followup', labelKey: 'prReview.readyForFollowup', icon: RefreshCw, color: 'text-cyan-400', bgColor: 'bg-cyan-500/20' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Modern Filter Dropdown Component
|
||||
*/
|
||||
function FilterDropdown<T extends string>({
|
||||
title,
|
||||
icon: Icon,
|
||||
items,
|
||||
selected,
|
||||
onChange,
|
||||
renderItem,
|
||||
renderTrigger,
|
||||
searchable = false,
|
||||
searchPlaceholder,
|
||||
selectedCountLabel,
|
||||
noResultsLabel,
|
||||
clearLabel,
|
||||
}: {
|
||||
title: string;
|
||||
icon: typeof Users;
|
||||
items: T[];
|
||||
selected: T[];
|
||||
onChange: (selected: T[]) => void;
|
||||
renderItem?: (item: T) => React.ReactNode;
|
||||
renderTrigger?: (selected: T[]) => React.ReactNode;
|
||||
searchable?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
selectedCountLabel?: string;
|
||||
noResultsLabel?: string;
|
||||
clearLabel?: string;
|
||||
}) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [focusedIndex, setFocusedIndex] = useState(-1);
|
||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
const toggleItem = useCallback((item: T) => {
|
||||
if (selected.includes(item)) {
|
||||
onChange(selected.filter((s) => s !== item));
|
||||
} else {
|
||||
onChange([...selected, item]);
|
||||
}
|
||||
}, [selected, onChange]);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!searchTerm) return items;
|
||||
return items.filter(item =>
|
||||
item.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}, [items, searchTerm]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (filteredItems.length === 0) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setFocusedIndex(prev =>
|
||||
prev < filteredItems.length - 1 ? prev + 1 : 0
|
||||
);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setFocusedIndex(prev =>
|
||||
prev > 0 ? prev - 1 : filteredItems.length - 1
|
||||
);
|
||||
break;
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
e.preventDefault();
|
||||
if (focusedIndex >= 0 && focusedIndex < filteredItems.length) {
|
||||
toggleItem(filteredItems[focusedIndex]);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
setIsOpen(false);
|
||||
break;
|
||||
}
|
||||
}, [filteredItems, focusedIndex, toggleItem]);
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={(open) => {
|
||||
setIsOpen(open);
|
||||
if (!open) {
|
||||
setSearchTerm('');
|
||||
setFocusedIndex(-1);
|
||||
}
|
||||
}}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 w-full justify-start border-dashed bg-transparent",
|
||||
selected.length > 0 && "border-solid bg-accent/50"
|
||||
)}
|
||||
>
|
||||
<Icon className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||
<span className="truncate">{title}</span>
|
||||
{selected.length > 0 && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="rounded-sm px-1 font-normal lg:hidden"
|
||||
>
|
||||
{selected.length}
|
||||
</Badge>
|
||||
<div className="hidden space-x-1 lg:flex flex-1 truncate">
|
||||
{selected.length > 2 ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="rounded-sm px-1 font-normal"
|
||||
>
|
||||
{selectedCountLabel}
|
||||
</Badge>
|
||||
) : (
|
||||
renderTrigger ? renderTrigger(selected) : (
|
||||
selected.map((item) => (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
key={item}
|
||||
className="rounded-sm px-1 font-normal"
|
||||
>
|
||||
{item}
|
||||
</Badge>
|
||||
))
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-[240px] p-0">
|
||||
<div className="px-3 py-2 border-b border-border/50">
|
||||
<div className="text-xs font-semibold text-muted-foreground mb-1">
|
||||
{title}
|
||||
</div>
|
||||
{searchable && (
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
className="h-7 text-xs pl-7 bg-muted/50 border-none focus-visible:ring-1 focus-visible:ring-primary/50"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="max-h-[300px] overflow-y-auto custom-scrollbar p-1"
|
||||
role="listbox"
|
||||
aria-multiselectable="true"
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={0}
|
||||
>
|
||||
{filteredItems.length === 0 ? (
|
||||
<div className="p-3 text-xs text-muted-foreground text-center">
|
||||
{noResultsLabel}
|
||||
</div>
|
||||
) : (
|
||||
filteredItems.map((item, index) => {
|
||||
const isSelected = selected.includes(item);
|
||||
const isFocused = index === focusedIndex;
|
||||
return (
|
||||
<div
|
||||
key={item}
|
||||
ref={(el) => { itemRefs.current[index] = el; }}
|
||||
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 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
isSelected && "bg-accent/50",
|
||||
isFocused && "ring-2 ring-primary/50 bg-accent"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
toggleItem(item);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggleItem(item);
|
||||
}
|
||||
}}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className={cn(
|
||||
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary/30",
|
||||
isSelected ? "bg-primary border-primary text-primary-foreground" : "opacity-50 [&_svg]:invisible"
|
||||
)}>
|
||||
<Check className={cn("h-3 w-3")} />
|
||||
</div>
|
||||
{renderItem ? renderItem(item) : item}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selected.length > 0 && (
|
||||
<div className="p-1 border-t border-border/50 bg-muted/20">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-center text-xs h-7 hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={() => onChange([])}
|
||||
>
|
||||
{clearLabel}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export function PRFilterBar({
|
||||
filters,
|
||||
contributors,
|
||||
hasActiveFilters,
|
||||
onSearchChange,
|
||||
onContributorsChange,
|
||||
onStatusesChange,
|
||||
onClearFilters,
|
||||
}: PRFilterBarProps) {
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
// Get status option by value
|
||||
const getStatusOption = (value: PRStatusFilter) =>
|
||||
STATUS_OPTIONS.find((opt) => opt.value === value);
|
||||
|
||||
return (
|
||||
<div className="px-4 py-2 border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="flex items-center gap-2 h-9">
|
||||
{/* Search Input - Flexible width */}
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t('prReview.searchPlaceholder')}
|
||||
value={filters.searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="h-8 pl-9 bg-background/50 focus:bg-background transition-colors"
|
||||
/>
|
||||
{filters.searchQuery && (
|
||||
<button
|
||||
onClick={() => onSearchChange('')}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label={t('prReview.clearSearch')}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator orientation="vertical" className="h-5 mx-1" />
|
||||
|
||||
{/* Contributors Filter */}
|
||||
<div className="flex-1 max-w-[240px]">
|
||||
<FilterDropdown
|
||||
title={t('prReview.contributors')}
|
||||
icon={Users}
|
||||
items={contributors}
|
||||
selected={filters.contributors}
|
||||
onChange={onContributorsChange}
|
||||
searchable={true}
|
||||
searchPlaceholder={t('prReview.searchContributors')}
|
||||
selectedCountLabel={t('prReview.selectedCount', { count: filters.contributors.length })}
|
||||
noResultsLabel={t('prReview.noResultsFound')}
|
||||
clearLabel={t('prReview.clearFilters')}
|
||||
renderItem={(contributor) => (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="h-5 w-5 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<span className="text-[10px] font-medium text-primary">
|
||||
{contributor.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<span className="truncate text-sm">{contributor}</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div className="flex-1 max-w-[240px]">
|
||||
<FilterDropdown
|
||||
title={t('prReview.allStatuses')}
|
||||
icon={Filter}
|
||||
items={STATUS_OPTIONS.map((opt) => opt.value)}
|
||||
selected={filters.statuses}
|
||||
onChange={onStatusesChange}
|
||||
selectedCountLabel={t('prReview.selectedCount', { count: filters.statuses.length })}
|
||||
noResultsLabel={t('prReview.noResultsFound')}
|
||||
clearLabel={t('prReview.clearFilters')}
|
||||
renderItem={(status) => {
|
||||
const option = getStatusOption(status);
|
||||
if (!option) return null;
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn("p-1 rounded-full", option.bgColor)}>
|
||||
<Icon className={cn("h-3 w-3", option.color)} />
|
||||
</div>
|
||||
<span className="text-sm">{t(option.labelKey)}</span>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
renderTrigger={(selected) => (
|
||||
selected.map(status => {
|
||||
const option = getStatusOption(status);
|
||||
if (!option) return null;
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
key={status}
|
||||
className={cn(
|
||||
"rounded-sm px-1 font-normal gap-1",
|
||||
option.bgColor,
|
||||
option.color
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3 w-3" />
|
||||
<span className="truncate max-w-[80px]">{t(option.labelKey)}</span>
|
||||
</Badge>
|
||||
);
|
||||
})
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Reset All */}
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClearFilters}
|
||||
className="h-8 px-2 lg:px-3 text-muted-foreground hover:text-foreground ml-auto"
|
||||
>
|
||||
<span className="hidden lg:inline mr-2">{t('prReview.reset')}</span>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { GitPullRequest, User, Clock, FileDiff, Loader2, CheckCircle2, AlertCircle, MessageSquare, RefreshCw } from 'lucide-react';
|
||||
import { GitPullRequest, User, Clock, FileDiff, Loader2, CheckCircle2, AlertCircle, MessageSquare, RefreshCw, Send } from 'lucide-react';
|
||||
import { ScrollArea } from '../../ui/scroll-area';
|
||||
import { Badge } from '../../ui/badge';
|
||||
import { cn } from '../../../lib/utils';
|
||||
@@ -104,7 +104,6 @@ interface PRListProps {
|
||||
selectedPRNumber: number | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
activePRReviews: number[];
|
||||
getReviewStateForPR: (prNumber: number) => PRReviewInfo | null;
|
||||
onSelectPR: (prNumber: number) => void;
|
||||
}
|
||||
@@ -129,7 +128,7 @@ function formatDate(dateString: string): string {
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
export function PRList({ prs, selectedPRNumber, isLoading, error, activePRReviews, getReviewStateForPR, onSelectPR }: PRListProps) {
|
||||
export function PRList({ prs, selectedPRNumber, isLoading, error, getReviewStateForPR, onSelectPR }: PRListProps) {
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
if (isLoading && prs.length === 0) {
|
||||
@@ -137,7 +136,7 @@ export function PRList({ prs, selectedPRNumber, isLoading, error, activePRReview
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<GitPullRequest className="h-8 w-8 mx-auto mb-2 animate-pulse" />
|
||||
<p>Loading pull requests...</p>
|
||||
<p>{t('prReview.loadingPRs')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -158,7 +157,7 @@ export function PRList({ prs, selectedPRNumber, isLoading, error, activePRReview
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<GitPullRequest className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>No open pull requests</p>
|
||||
<p>{t('prReview.noOpenPRs')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -200,12 +199,19 @@ export function PRList({ prs, selectedPRNumber, isLoading, error, activePRReview
|
||||
{!isReviewingPR && hasReviewResult && reviewState?.result && (
|
||||
<>
|
||||
{/* Show "Reviewed" if AI review is complete but not yet posted to GitHub */}
|
||||
{!reviewState.result.reviewId && (
|
||||
{!reviewState.result.reviewId && !reviewState.result.hasPostedFindings && (
|
||||
<Badge variant="outline" className="text-xs flex items-center gap-1 text-blue-500 border-blue-500/50">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
{t('prReview.reviewed')}
|
||||
</Badge>
|
||||
)}
|
||||
{/* Show "Posted" when findings posted to GitHub but no full review ID */}
|
||||
{!reviewState.result.reviewId && reviewState.result.hasPostedFindings && (
|
||||
<Badge variant="purple" className="text-xs flex items-center gap-1">
|
||||
<Send className="h-3 w-3" />
|
||||
{t('prReview.posted')}
|
||||
</Badge>
|
||||
)}
|
||||
{/* Show actual status only after posted to GitHub (has reviewId) */}
|
||||
{reviewState.result.reviewId && reviewState.result.overallStatus === 'approve' && (
|
||||
<Badge variant="outline" className="text-xs flex items-center gap-1 text-success border-success/50">
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { PRList } from './PRList';
|
||||
export { PRDetail } from './PRDetail';
|
||||
export { PRFilterBar } from './PRFilterBar';
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export { useGitHubPRs } from './useGitHubPRs';
|
||||
export { usePRFiltering } from './usePRFiltering';
|
||||
export type { PRFilterState, PRStatusFilter } from './usePRFiltering';
|
||||
export type {
|
||||
PRData,
|
||||
PRReviewFinding,
|
||||
|
||||
@@ -210,7 +210,15 @@ export function useGitHubPRs(projectId?: string): UseGitHubPRsResult {
|
||||
if (!projectId) return false;
|
||||
|
||||
try {
|
||||
return await window.electronAPI.github.postPRReview(projectId, prNumber, selectedFindingIds);
|
||||
const success = await window.electronAPI.github.postPRReview(projectId, prNumber, selectedFindingIds);
|
||||
if (success) {
|
||||
// Reload review result to get updated postedAt and finding status
|
||||
const result = await window.electronAPI.github.getPRReview(projectId, prNumber);
|
||||
if (result) {
|
||||
usePRReviewStore.getState().setPRReviewResult(projectId, result);
|
||||
}
|
||||
}
|
||||
return success;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to post review');
|
||||
return false;
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Hook for filtering and searching GitHub PRs
|
||||
*/
|
||||
|
||||
import { useMemo, useState, useCallback } from 'react';
|
||||
import type { PRData, PRReviewResult } from '../../../../preload/api/modules/github-api';
|
||||
import type { NewCommitsCheck } from '../../../../preload/api/modules/github-api';
|
||||
|
||||
export type PRStatusFilter =
|
||||
| 'not_reviewed'
|
||||
| 'reviewed'
|
||||
| 'posted'
|
||||
| 'changes_requested'
|
||||
| 'ready_to_merge'
|
||||
| 'ready_for_followup';
|
||||
|
||||
export interface PRFilterState {
|
||||
searchQuery: string;
|
||||
contributors: string[];
|
||||
statuses: PRStatusFilter[];
|
||||
}
|
||||
|
||||
interface PRReviewInfo {
|
||||
isReviewing: boolean;
|
||||
result: PRReviewResult | null;
|
||||
newCommitsCheck?: NewCommitsCheck | null;
|
||||
}
|
||||
|
||||
const DEFAULT_FILTERS: PRFilterState = {
|
||||
searchQuery: '',
|
||||
contributors: [],
|
||||
statuses: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Determine the computed status of a PR based on its review state
|
||||
*/
|
||||
function getPRComputedStatus(
|
||||
reviewInfo: PRReviewInfo | null
|
||||
): PRStatusFilter {
|
||||
if (!reviewInfo?.result) {
|
||||
return 'not_reviewed';
|
||||
}
|
||||
|
||||
const result = reviewInfo.result;
|
||||
const hasPosted = Boolean(result.reviewId) || Boolean(result.hasPostedFindings);
|
||||
const hasBlockingFindings = result.findings?.some(
|
||||
f => f.severity === 'critical' || f.severity === 'high'
|
||||
);
|
||||
const hasNewCommits = reviewInfo.newCommitsCheck?.hasNewCommits;
|
||||
|
||||
// Check for ready for follow-up first (highest priority after posting)
|
||||
if (hasPosted && hasNewCommits) {
|
||||
return 'ready_for_followup';
|
||||
}
|
||||
|
||||
// Posted with blocking findings
|
||||
if (hasPosted && hasBlockingFindings) {
|
||||
return 'changes_requested';
|
||||
}
|
||||
|
||||
// Posted without blocking findings
|
||||
if (hasPosted) {
|
||||
return 'ready_to_merge';
|
||||
}
|
||||
|
||||
// Has review result but not yet posted to GitHub
|
||||
// Note: 'posted' is not returned here - it's a meta-filter that matches
|
||||
// any posted state (ready_to_merge, changes_requested, ready_for_followup)
|
||||
// and is handled specially in the filter logic below
|
||||
return 'reviewed';
|
||||
}
|
||||
|
||||
export function usePRFiltering(
|
||||
prs: PRData[],
|
||||
getReviewStateForPR: (prNumber: number) => PRReviewInfo | null
|
||||
) {
|
||||
const [filters, setFiltersState] = useState<PRFilterState>(DEFAULT_FILTERS);
|
||||
|
||||
// Derive unique contributors from PRs
|
||||
const contributors = useMemo(() => {
|
||||
const authorSet = new Set<string>();
|
||||
prs.forEach(pr => {
|
||||
if (pr.author?.login) {
|
||||
authorSet.add(pr.author.login);
|
||||
}
|
||||
});
|
||||
return Array.from(authorSet).sort((a, b) =>
|
||||
a.toLowerCase().localeCompare(b.toLowerCase())
|
||||
);
|
||||
}, [prs]);
|
||||
|
||||
// Filter PRs based on current filters
|
||||
const filteredPRs = useMemo(() => {
|
||||
return prs.filter(pr => {
|
||||
// Search filter - matches title or body
|
||||
if (filters.searchQuery) {
|
||||
const query = filters.searchQuery.toLowerCase();
|
||||
const matchesTitle = pr.title.toLowerCase().includes(query);
|
||||
const matchesBody = pr.body?.toLowerCase().includes(query);
|
||||
const matchesNumber = pr.number.toString().includes(query);
|
||||
if (!matchesTitle && !matchesBody && !matchesNumber) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Contributors filter (multi-select)
|
||||
if (filters.contributors.length > 0) {
|
||||
const authorLogin = pr.author?.login;
|
||||
if (!authorLogin || !filters.contributors.includes(authorLogin)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Status filter (multi-select)
|
||||
if (filters.statuses.length > 0) {
|
||||
const reviewInfo = getReviewStateForPR(pr.number);
|
||||
const computedStatus = getPRComputedStatus(reviewInfo);
|
||||
|
||||
// Check if PR matches any of the selected statuses
|
||||
const matchesStatus = filters.statuses.some(status => {
|
||||
// Special handling: 'posted' should match any posted state
|
||||
if (status === 'posted') {
|
||||
const hasPosted = reviewInfo?.result?.reviewId || reviewInfo?.result?.hasPostedFindings;
|
||||
return hasPosted;
|
||||
}
|
||||
return computedStatus === status;
|
||||
});
|
||||
|
||||
if (!matchesStatus) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [prs, filters, getReviewStateForPR]);
|
||||
|
||||
// Filter setters
|
||||
const setSearchQuery = useCallback((query: string) => {
|
||||
setFiltersState(prev => ({ ...prev, searchQuery: query }));
|
||||
}, []);
|
||||
|
||||
const setContributors = useCallback((contributors: string[]) => {
|
||||
setFiltersState(prev => ({ ...prev, contributors }));
|
||||
}, []);
|
||||
|
||||
const setStatuses = useCallback((statuses: PRStatusFilter[]) => {
|
||||
setFiltersState(prev => ({ ...prev, statuses }));
|
||||
}, []);
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setFiltersState(DEFAULT_FILTERS);
|
||||
}, []);
|
||||
|
||||
const hasActiveFilters = useMemo(() => {
|
||||
return (
|
||||
filters.searchQuery !== '' ||
|
||||
filters.contributors.length > 0 ||
|
||||
filters.statuses.length > 0
|
||||
);
|
||||
}, [filters]);
|
||||
|
||||
return {
|
||||
filteredPRs,
|
||||
contributors,
|
||||
filters,
|
||||
setSearchQuery,
|
||||
setContributors,
|
||||
setStatuses,
|
||||
clearFilters,
|
||||
hasActiveFilters,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* ResizablePanels - A split panel layout with a draggable divider
|
||||
*
|
||||
* Features:
|
||||
* - Smooth drag-to-resize functionality
|
||||
* - Min/max width constraints
|
||||
* - Persists width to localStorage
|
||||
* - Visual feedback on hover and drag
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect, useCallback, type ReactNode } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface ResizablePanelsProps {
|
||||
leftPanel: ReactNode;
|
||||
rightPanel: ReactNode;
|
||||
defaultLeftWidth?: number; // percentage, default 50
|
||||
minLeftWidth?: number; // percentage, default 30
|
||||
maxLeftWidth?: number; // percentage, default 70
|
||||
storageKey?: string; // localStorage key for persistence
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ResizablePanels({
|
||||
leftPanel,
|
||||
rightPanel,
|
||||
defaultLeftWidth = 50,
|
||||
minLeftWidth = 30,
|
||||
maxLeftWidth = 70,
|
||||
storageKey,
|
||||
className,
|
||||
}: ResizablePanelsProps) {
|
||||
// Load initial width from storage or use default
|
||||
const [leftWidth, setLeftWidth] = useState(() => {
|
||||
if (storageKey) {
|
||||
try {
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
if (stored) {
|
||||
const parsed = parseFloat(stored);
|
||||
if (!isNaN(parsed) && parsed >= minLeftWidth && parsed <= maxLeftWidth) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// localStorage may be unavailable (e.g., private browsing)
|
||||
}
|
||||
}
|
||||
return defaultLeftWidth;
|
||||
});
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Save to storage when width changes (debounced by only saving when not dragging)
|
||||
useEffect(() => {
|
||||
if (storageKey && !isDragging) {
|
||||
try {
|
||||
localStorage.setItem(storageKey, leftWidth.toString());
|
||||
} catch {
|
||||
// localStorage may be unavailable (e.g., private browsing, quota exceeded)
|
||||
}
|
||||
}
|
||||
}, [leftWidth, storageKey, isDragging]);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
// Guard against division by zero when container has no width
|
||||
if (rect.width <= 0) return;
|
||||
const newWidth = ((e.clientX - rect.left) / rect.width) * 100;
|
||||
const clampedWidth = Math.max(minLeftWidth, Math.min(maxLeftWidth, newWidth));
|
||||
setLeftWidth(clampedWidth);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
if (!containerRef.current || e.touches.length === 0) return;
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
if (rect.width <= 0) return;
|
||||
const touch = e.touches[0];
|
||||
const newWidth = ((touch.clientX - rect.left) / rect.width) * 100;
|
||||
const clampedWidth = Math.max(minLeftWidth, Math.min(maxLeftWidth, newWidth));
|
||||
setLeftWidth(clampedWidth);
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
// Add user-select: none to body during drag to prevent text selection
|
||||
document.body.style.userSelect = 'none';
|
||||
document.body.style.cursor = 'col-resize';
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', handleTouchEnd);
|
||||
|
||||
return () => {
|
||||
document.body.style.userSelect = '';
|
||||
document.body.style.cursor = '';
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
document.removeEventListener('touchend', handleTouchEnd);
|
||||
};
|
||||
}, [isDragging, minLeftWidth, maxLeftWidth]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn("flex-1 flex min-h-0", className)}
|
||||
>
|
||||
{/* Left panel */}
|
||||
<div
|
||||
className="flex flex-col min-w-0 overflow-hidden"
|
||||
style={{ width: `${leftWidth}%` }}
|
||||
>
|
||||
{leftPanel}
|
||||
</div>
|
||||
|
||||
{/* Resizable divider */}
|
||||
<div
|
||||
className={cn(
|
||||
"w-1 flex-shrink-0 relative cursor-col-resize touch-none",
|
||||
"bg-border transition-colors duration-150",
|
||||
"hover:bg-primary/40",
|
||||
isDragging && "bg-primary/60"
|
||||
)}
|
||||
onMouseDown={handleMouseDown}
|
||||
onTouchStart={handleTouchStart}
|
||||
>
|
||||
{/* Wider invisible hit area for easier grabbing */}
|
||||
<div className="absolute inset-y-0 -left-1 -right-1 z-10" />
|
||||
</div>
|
||||
|
||||
{/* Right panel */}
|
||||
<div
|
||||
className="flex flex-col min-w-0 overflow-hidden"
|
||||
style={{ width: `${100 - leftWidth}%` }}
|
||||
>
|
||||
{rightPanel}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -97,7 +97,59 @@
|
||||
"commented": "Commented",
|
||||
"readyForFollowup": "Ready for Follow-up",
|
||||
"readyToMerge": "Ready to Merge",
|
||||
"pendingPost": "Pending Post"
|
||||
"pendingPost": "Pending Post",
|
||||
"posted": "Posted",
|
||||
"notReviewed": "Not Reviewed",
|
||||
"allStatuses": "All statuses",
|
||||
"allContributors": "All contributors",
|
||||
"searchPlaceholder": "Search PRs...",
|
||||
"contributors": "Contributors",
|
||||
"contributorsSelected": "Contributors ({{count}})",
|
||||
"status": "Status",
|
||||
"filters": "Filters",
|
||||
"clearFilters": "Clear",
|
||||
"clearSearch": "Clear search",
|
||||
"searchContributors": "Search contributors...",
|
||||
"selectedCount": "{{count}} selected",
|
||||
"noResultsFound": "No results found",
|
||||
"reset": "Reset",
|
||||
"pullRequests": "Pull Requests",
|
||||
"open": "open",
|
||||
"selectPRToView": "Select a pull request to view details",
|
||||
"loadingPRs": "Loading pull requests...",
|
||||
"noOpenPRs": "No open pull requests",
|
||||
"notConnected": "GitHub Not Connected",
|
||||
"connectPrompt": "Connect your GitHub account to view and review pull requests.",
|
||||
"openSettings": "Open Settings",
|
||||
"runAIReview": "Run AI Review",
|
||||
"reviewStarted": "Review Started",
|
||||
"analysisInProgress": "AI Analysis in Progress...",
|
||||
"analysisComplete": "Analysis Complete ({{count}} findings)",
|
||||
"findingsPostedToGitHub": "Findings Posted to GitHub",
|
||||
"newCommits": "{{count}} New Commits",
|
||||
"runFollowup": "Run Follow-up",
|
||||
"aiReviewInProgress": "AI Review in Progress",
|
||||
"waitingForChanges": "Waiting for Changes",
|
||||
"reviewComplete": "Review Complete",
|
||||
"reviewStatus": "Review Status",
|
||||
"files": "files",
|
||||
"filesChanged": "{{count}} files changed",
|
||||
"posting": "Posting...",
|
||||
"postFindings": "Post {{count}} Finding",
|
||||
"postFindings_plural": "Post {{count}} Findings",
|
||||
"approve": "Approve",
|
||||
"merge": "Merge",
|
||||
"postedFindings": "Posted {{count}} finding",
|
||||
"postedFindings_plural": "Posted {{count}} findings",
|
||||
"resolved": "{{count}} resolved",
|
||||
"resolved_plural": "{{count}} resolved",
|
||||
"stillOpen": "{{count}} still open",
|
||||
"stillOpen_plural": "{{count}} still open",
|
||||
"newIssue": "{{count}} new issue",
|
||||
"newIssue_plural": "{{count}} new issues",
|
||||
"reviewFailed": "Review Failed",
|
||||
"description": "Description",
|
||||
"noDescription": "No description provided."
|
||||
},
|
||||
"downloads": {
|
||||
"toggleExpand": "Toggle download details",
|
||||
|
||||
@@ -97,7 +97,59 @@
|
||||
"commented": "Commenté",
|
||||
"readyForFollowup": "Prêt pour suivi",
|
||||
"readyToMerge": "Prêt à fusionner",
|
||||
"pendingPost": "En attente de publication"
|
||||
"pendingPost": "En attente de publication",
|
||||
"posted": "Publié",
|
||||
"notReviewed": "Non révisé",
|
||||
"allStatuses": "Tous les statuts",
|
||||
"allContributors": "Tous les contributeurs",
|
||||
"searchPlaceholder": "Rechercher des PRs...",
|
||||
"contributors": "Contributeurs",
|
||||
"contributorsSelected": "Contributeurs ({{count}})",
|
||||
"status": "Statut",
|
||||
"filters": "Filtres",
|
||||
"clearFilters": "Effacer",
|
||||
"clearSearch": "Effacer la recherche",
|
||||
"searchContributors": "Rechercher des contributeurs...",
|
||||
"selectedCount": "{{count}} sélectionné(s)",
|
||||
"noResultsFound": "Aucun résultat trouvé",
|
||||
"reset": "Réinitialiser",
|
||||
"pullRequests": "Pull Requests",
|
||||
"open": "ouvert",
|
||||
"selectPRToView": "Sélectionnez une pull request pour voir les détails",
|
||||
"loadingPRs": "Chargement des pull requests...",
|
||||
"noOpenPRs": "Aucune pull request ouverte",
|
||||
"notConnected": "GitHub non connecté",
|
||||
"connectPrompt": "Connectez votre compte GitHub pour voir et réviser les pull requests.",
|
||||
"openSettings": "Ouvrir les paramètres",
|
||||
"runAIReview": "Lancer la révision IA",
|
||||
"reviewStarted": "Révision commencée",
|
||||
"analysisInProgress": "Analyse IA en cours...",
|
||||
"analysisComplete": "Analyse terminée ({{count}} résultats)",
|
||||
"findingsPostedToGitHub": "Résultats publiés sur GitHub",
|
||||
"newCommits": "{{count}} nouveaux commits",
|
||||
"runFollowup": "Lancer le suivi",
|
||||
"aiReviewInProgress": "Révision IA en cours",
|
||||
"waitingForChanges": "En attente de modifications",
|
||||
"reviewComplete": "Révision terminée",
|
||||
"reviewStatus": "Statut de révision",
|
||||
"files": "fichiers",
|
||||
"filesChanged": "{{count}} fichiers modifiés",
|
||||
"posting": "Publication...",
|
||||
"postFindings": "Publier {{count}} résultat",
|
||||
"postFindings_plural": "Publier {{count}} résultats",
|
||||
"approve": "Approuver",
|
||||
"merge": "Fusionner",
|
||||
"postedFindings": "{{count}} résultat publié",
|
||||
"postedFindings_plural": "{{count}} résultats publiés",
|
||||
"resolved": "{{count}} résolu",
|
||||
"resolved_plural": "{{count}} résolus",
|
||||
"stillOpen": "{{count}} encore ouvert",
|
||||
"stillOpen_plural": "{{count}} encore ouverts",
|
||||
"newIssue": "{{count}} nouveau problème",
|
||||
"newIssue_plural": "{{count}} nouveaux problèmes",
|
||||
"reviewFailed": "Révision échouée",
|
||||
"description": "Description",
|
||||
"noDescription": "Aucune description fournie."
|
||||
},
|
||||
"downloads": {
|
||||
"toggleExpand": "Afficher/masquer les détails",
|
||||
|
||||
Reference in New Issue
Block a user