diff --git a/src/renderer/services/github.ts b/src/renderer/services/github.ts index 80297d10..820c5bbf 100644 --- a/src/renderer/services/github.ts +++ b/src/renderer/services/github.ts @@ -194,6 +194,31 @@ export class GitHubAPI { }); } + async getRepositoryLabels( + owner: string, + repo: string, + ): Promise> { + const allLabels: Array<{ name: string; color: string }> = []; + let page = 1; + const perPage = 100; + while (true) { + const { data } = await this.octokit.issues.listLabelsForRepo({ + owner, + repo, + per_page: perPage, + page, + }); + if (!Array.isArray(data) || data.length === 0) break; + for (const l of data) { + if (!l) continue; + allLabels.push({ name: l.name, color: (l as any).color || "000000" }); + } + if (data.length < perPage) break; + page++; + } + return allLabels; + } + async getRepositories(page = 1, perPage = 100): Promise { const { data } = await this.octokit.repos.listForAuthenticatedUser({ page, diff --git a/src/renderer/stores/uiStore.ts b/src/renderer/stores/uiStore.ts index a8880fbb..ffdbc5cb 100644 --- a/src/renderer/stores/uiStore.ts +++ b/src/renderer/stores/uiStore.ts @@ -30,6 +30,9 @@ interface UIState { sortBy: SortByType; selectedAuthors: string[]; selectedStatuses: PRStatusFilter[]; + selectedLabels: string[]; + labelMode: "OR" | "AND" | "NOT" | "ONLY"; + includeNoLabel: boolean; }; toggleSidebar: () => void; @@ -73,6 +76,9 @@ export const useUIStore = create()( sortBy: "updated", selectedAuthors: [], selectedStatuses: ["open", "draft"], + selectedLabels: [], + labelMode: "OR", + includeNoLabel: false, }, toggleSidebar: () => @@ -131,6 +137,9 @@ export const useUIStore = create()( sortBy: "updated", selectedAuthors: [], selectedStatuses: ["open", "draft"], + selectedLabels: [], + labelMode: "OR", + includeNoLabel: false, }, }), }), diff --git a/src/renderer/views/PRListView.tsx b/src/renderer/views/PRListView.tsx index 965f1166..97d4d79c 100644 --- a/src/renderer/views/PRListView.tsx +++ b/src/renderer/views/PRListView.tsx @@ -1,6 +1,6 @@ import { useState, useMemo, useCallback, useEffect, useRef } from "react"; -import { useNavigate } from "react-router-dom"; -import { GitPullRequest } from "lucide-react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { GitPullRequest, Tag, X, Search } from "lucide-react"; import { usePRStore } from "../stores/prStore"; import { useUIStore } from "../stores/uiStore"; import { useAuthStore } from "../stores/authStore"; @@ -13,6 +13,7 @@ import WelcomeView from "./WelcomeView"; import { GitHubAPI, PullRequest } from "../services/github"; import { PRTreeView } from "../components/PRTreeView"; import type { SortByType, PRWithMetadata } from "../types/prList"; +import { getLabelColors } from "../utils/labelColors"; type StatusType = PRStatusType; // Use the centralized type @@ -30,6 +31,7 @@ const statusOptions = [ export default function PRListView() { const navigate = useNavigate(); + const location = useLocation(); const { pullRequests, loading, @@ -55,6 +57,10 @@ export default function PRListView() { const [showStatusDropdown, setShowStatusDropdown] = useState(false); const statusDropdownRef = useRef(null); const [isClosing, setIsClosing] = useState(false); + const [showLabelDropdown, setShowLabelDropdown] = useState(false); + const labelDropdownRef = useRef(null); + const [labelSearch, setLabelSearch] = useState(""); + const [repoLabels, setRepoLabels] = useState>([]); const sortBy = prListFilters.sortBy; const selectedAuthors = useMemo( @@ -65,6 +71,12 @@ export default function PRListView() { () => new Set(prListFilters.selectedStatuses), [prListFilters.selectedStatuses], ); + const selectedLabels = useMemo( + () => new Set(prListFilters.selectedLabels), + [prListFilters.selectedLabels], + ); + const labelMode = prListFilters.labelMode; + const includeNoLabel = prListFilters.includeNoLabel; // Close dropdown when clicking outside useEffect(() => { @@ -75,16 +87,19 @@ export default function PRListView() { if (statusDropdownRef.current && !statusDropdownRef.current.contains(event.target as Node)) { setShowStatusDropdown(false); } + if (labelDropdownRef.current && !labelDropdownRef.current.contains(event.target as Node)) { + setShowLabelDropdown(false); + } }; - if (showAuthorDropdown || showStatusDropdown) { + if (showAuthorDropdown || showStatusDropdown || showLabelDropdown) { document.addEventListener('mousedown', handleClickOutside); } return () => { document.removeEventListener('mousedown', handleClickOutside); }; - }, [showAuthorDropdown, showStatusDropdown]); + }, [showAuthorDropdown, showStatusDropdown, showLabelDropdown]); // Removed automatic stats fetching - was causing performance issues @@ -113,6 +128,45 @@ export default function PRListView() { const showRefreshingIndicator = loading && dataMatchesSelectedRepo && pendingRepoKey === selectedRepoKey; + // Fetch repository labels + useEffect(() => { + const fetchLabels = async () => { + if (!selectedRepo) { + setRepoLabels([]); + return; + } + try { + let authToken = token; + if (!authToken && typeof window !== "undefined" && window.electron) { + try { + authToken = await window.electron.auth.getToken(); + } catch {} + } + if (!authToken) return; + if (authToken === "dev-token") { + const labelMap = new Map(); + Array.from(pullRequests.values()).forEach((pr) => { + const baseOwner = pr.base?.repo?.owner?.login; + const baseName = pr.base?.repo?.name; + if (baseOwner === selectedRepo.owner && baseName === selectedRepo.name) { + (pr.labels || []).forEach((l: any) => { + if (l?.name && !labelMap.has(l.name)) labelMap.set(l.name, l.color || "999999"); + }); + } + }); + setRepoLabels(Array.from(labelMap.entries()).map(([name, color]) => ({ name, color }))); + } else { + const api = new GitHubAPI(authToken); + const labels = await api.getRepositoryLabels(selectedRepo.owner, selectedRepo.name); + setRepoLabels(labels); + } + } catch (e) { + console.error("Failed to fetch repository labels:", e); + } + }; + fetchLabels(); + }, [selectedRepo, token, pullRequests]); + // Function to fetch detailed PR data in the background const fetchDetailedPRsInBackground = useCallback( (prs: PullRequest[]) => { @@ -265,8 +319,67 @@ export default function PRListView() { [setPRListFilters], ); + // Label filter handlers + const toggleLabel = useCallback((labelName: string) => { + setPRListFilters(prev => { + const current = new Set(prev.selectedLabels); + if (current.has(labelName)) current.delete(labelName); else current.add(labelName); + return { ...prev, selectedLabels: Array.from(current) }; + }); + }, [setPRListFilters]); + + const clearAllLabels = useCallback(() => { + setPRListFilters(prev => ({ ...prev, selectedLabels: [], includeNoLabel: false })); + }, [setPRListFilters]); + + const setLabelModeValue = useCallback((mode: "OR" | "AND" | "NOT" | "ONLY") => { + setPRListFilters(prev => ({ ...prev, labelMode: mode })); + }, [setPRListFilters]); + + const toggleIncludeNoLabel = useCallback(() => { + setPRListFilters(prev => ({ ...prev, includeNoLabel: !prev.includeNoLabel })); + }, [setPRListFilters]); + + // URL sync: read initial params + useEffect(() => { + const params = new URLSearchParams(location.search); + const labelsParam = params.get("labels"); + const modeParam = params.get("labelMode"); + const noLabelParam = params.get("noLabel"); + if (labelsParam || modeParam || noLabelParam) { + setPRListFilters(prev => ({ + ...prev, + selectedLabels: labelsParam ? labelsParam.split(",").map(decodeURIComponent).filter(Boolean) : prev.selectedLabels, + labelMode: (modeParam as any) || prev.labelMode, + includeNoLabel: noLabelParam === "1", + })); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // URL sync: write params when filters change + useEffect(() => { + const params = new URLSearchParams(location.search); + if (prListFilters.selectedLabels.length > 0) { + params.set("labels", prListFilters.selectedLabels.map(encodeURIComponent).join(",")); + } else { + params.delete("labels"); + } + if (prListFilters.labelMode && prListFilters.labelMode !== "OR") { + params.set("labelMode", prListFilters.labelMode); + } else { + params.delete("labelMode"); + } + if (prListFilters.includeNoLabel) { + params.set("noLabel", "1"); + } else { + params.delete("noLabel"); + } + navigate({ pathname: location.pathname, search: params.toString() }, { replace: true }); + }, [prListFilters.selectedLabels, prListFilters.labelMode, prListFilters.includeNoLabel, navigate, location.pathname, location.search]); - // Simplified filtering logic - cleaner and more maintainable + + // Filtering logic with author, status, then labels const getFilteredPRs = useMemo(() => { if (!selectedRepo) { return []; @@ -279,24 +392,48 @@ export default function PRListView() { return baseOwner === selectedRepo.owner && baseName === selectedRepo.name; }); - // Step 2: Apply filters - const filteredPRs = repoFilteredPRs.filter((pr) => { - // Author filter + // Step 2: Apply author and status filters + const authorStatusFilteredPRs = repoFilteredPRs.filter((pr) => { const authorMatches = selectedAuthors.size === 0 || selectedAuthors.has("all") || selectedAuthors.has(pr.user.login); - // Status filter const prStatus = getPRStatus(pr); const statusMatches = selectedStatuses.size === 0 || selectedStatuses.has(prStatus); - // Both filters must pass return authorMatches && statusMatches; }); - // Step 3: Sort PRs - const sortedPRs = [...filteredPRs].sort((a, b) => { + // Step 3: Apply label filters + const labelFilteredPRs = authorStatusFilteredPRs.filter((pr) => { + const prLabelNames = new Set((pr.labels || []).map((l: any) => l?.name).filter(Boolean)); + const hasNoLabels = prLabelNames.size === 0; + + if (selectedLabels.size === 0 && !includeNoLabel) return true; + + const hasAnySelected = Array.from(selectedLabels).some((name) => prLabelNames.has(name)); + const hasAllSelected = Array.from(selectedLabels).every((name) => prLabelNames.has(name)); + const isExactMatch = prLabelNames.size === selectedLabels.size && hasAllSelected; + + switch (labelMode) { + case "OR": + return (selectedLabels.size > 0 ? hasAnySelected : false) || (includeNoLabel && hasNoLabels); + case "AND": + return (selectedLabels.size > 0 ? hasAllSelected : true) || (includeNoLabel && hasNoLabels); + case "NOT": + return !hasAnySelected && (!includeNoLabel ? true : hasNoLabels || true); + case "ONLY": + if (selectedLabels.size === 0 && includeNoLabel) return hasNoLabels; + if (selectedLabels.size === 0) return false; + return isExactMatch || (includeNoLabel && hasNoLabels); + default: + return true; + } + }); + + // Step 4: Sort PRs + const sortedPRs = [...labelFilteredPRs].sort((a, b) => { const aDate = sortBy === "updated" ? new Date(a.updated_at).getTime() : new Date(a.created_at).getTime(); @@ -312,11 +449,40 @@ export default function PRListView() { pullRequests, selectedAuthors, selectedStatuses, + selectedLabels, + labelMode, + includeNoLabel, sortBy, selectedRepo, getPRStatus, ]); + // Compute label counts from author+status filtered pool (before label filter) + const labelCounts = useMemo(() => { + const counts = new Map(); + if (!selectedRepo) return counts; + const repoFilteredPRs = Array.from(pullRequests.values()).filter((pr) => { + const baseOwner = pr.base?.repo?.owner?.login; + const baseName = pr.base?.repo?.name; + return baseOwner === selectedRepo.owner && baseName === selectedRepo.name; + }); + const pool = repoFilteredPRs.filter((pr) => { + const authorMatches = selectedAuthors.size === 0 || selectedAuthors.has("all") || selectedAuthors.has(pr.user.login); + const prStatus = getPRStatus(pr); + const statusMatches = selectedStatuses.size === 0 || selectedStatuses.has(prStatus); + return authorMatches && statusMatches; + }); + pool.forEach((pr) => { + const seen = new Set(); + (pr.labels || []).forEach((l: any) => { + if (!l?.name || seen.has(l.name)) return; + seen.add(l.name); + counts.set(l.name, (counts.get(l.name) || 0) + 1); + }); + }); + return counts; + }, [pullRequests, selectedRepo, selectedAuthors, selectedStatuses, getPRStatus]); + // Pre-compute PR metadata for grouping const prsWithMetadata = useMemo(() => { return getFilteredPRs.map((pr) => ({ @@ -880,9 +1046,199 @@ export default function PRListView() { )} + + {/* Label filter dropdown */} +
+ + + {showLabelDropdown && ( +
+
+ {/* Search */} +
+ + setLabelSearch(e.target.value)} + placeholder="Search labels..." + className={cn("flex-1 text-xs outline-none bg-transparent", + theme === "dark" ? "text-gray-200 placeholder-gray-400" : "text-gray-800 placeholder-gray-400" + )} + /> +
+ + {/* Quick Filters */} +
+
Quick Filters
+ {[{name:"bug", icon:"🐛"},{name:"feature", icon:"✨"},{name:"documentation", icon:"📝"},{name:"critical", icon:"🚨"},{name:"needs-review", icon:"👀"}].map((q) => { + const found = repoLabels.find(l => l.name.toLowerCase() === q.name.toLowerCase()); + const color = found?.color || "6b7280"; + const labelColors = getLabelColors(color, theme); + return ( + + ); + })} +
+ +
+ + {/* All Labels */} +
+
+
All Labels ({repoLabels.length}) +
+ +
+
+ {repoLabels + .filter(l => l.name.toLowerCase().includes(labelSearch.toLowerCase())) + .sort((a, b) => { + const ca = labelCounts.get(a.name) || 0; + const cb = labelCounts.get(b.name) || 0; + if (cb !== ca) return cb - ca; + return a.name.localeCompare(b.name); + }) + .map((label) => { + const labelColors = getLabelColors(label.color, theme); + const count = labelCounts.get(label.name) || 0; + return ( + + ); + })} +
+
+ +
+ + {/* No Label option */} + + + {/* Filter Logic */} +
+
Filter logic
+ {(["OR","AND","NOT","ONLY"] as const).map(m => ( + + ))} +
+
+
+ )} +
)}
+ + {/* Selected label chips */} + {(selectedLabels.size > 0 || includeNoLabel) && ( +
+ {Array.from(selectedLabels).map((name) => { + const repoLabel = repoLabels.find(l => l.name === name); + const color = repoLabel?.color || "6b7280"; + const colors = getLabelColors(color, theme); + return ( + + {name} + + + ); + })} + {includeNoLabel && ( + + No Label + + + )} + +
+ )} {/* PR List */} @@ -916,7 +1272,7 @@ export default function PRListView() { ) : (