diff --git a/apps/ui/src/components/views/github-issues-view.tsx b/apps/ui/src/components/views/github-issues-view.tsx index e1e09cad..03275b02 100644 --- a/apps/ui/src/components/views/github-issues-view.tsx +++ b/apps/ui/src/components/views/github-issues-view.tsx @@ -1,20 +1,26 @@ // @ts-nocheck import { useState, useCallback, useMemo } from 'react'; import { createLogger } from '@automaker/utils/logger'; -import { CircleDot, RefreshCw } from 'lucide-react'; +import { CircleDot, RefreshCw, SearchX } from 'lucide-react'; import { getElectronAPI, GitHubIssue, IssueValidationResult } from '@/lib/electron'; import { useAppStore } from '@/store/app-store'; +import { Button } from '@/components/ui/button'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { LoadingState } from '@/components/ui/loading-state'; import { ErrorState } from '@/components/ui/error-state'; import { cn, pathsEqual } from '@/lib/utils'; import { toast } from 'sonner'; -import { useGithubIssues, useIssueValidation } from './github-issues-view/hooks'; +import { useGithubIssues, useIssueValidation, useIssuesFilter } from './github-issues-view/hooks'; import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-view/components'; import { ValidationDialog } from './github-issues-view/dialogs'; import { formatDate, getFeaturePriority } from './github-issues-view/utils'; import { useModelOverride } from '@/components/shared'; -import type { ValidateIssueOptions } from './github-issues-view/types'; +import type { + ValidateIssueOptions, + IssuesFilterState, + IssuesStateFilter, +} from './github-issues-view/types'; +import { DEFAULT_ISSUES_FILTER_STATE } from './github-issues-view/types'; const logger = createLogger('GitHubIssuesView'); @@ -26,6 +32,9 @@ export function GitHubIssuesView() { const [pendingRevalidateOptions, setPendingRevalidateOptions] = useState(null); + // Filter state + const [filterState, setFilterState] = useState(DEFAULT_ISSUES_FILTER_STATE); + const { currentProject, getCurrentWorktree, worktreesByProject } = useAppStore(); // Model override for validation @@ -44,6 +53,41 @@ export function GitHubIssuesView() { onShowValidationDialogChange: setShowValidationDialog, }); + // Combine all issues for filtering + const allIssues = useMemo(() => [...openIssues, ...closedIssues], [openIssues, closedIssues]); + + // Apply filter to issues - now returns matched issues directly for better performance + const filterResult = useIssuesFilter(allIssues, filterState, cachedValidations); + + // Separate filtered issues by state - this is O(n) but now only done once + // since filterResult.matchedIssues already contains the filtered issues + const { filteredOpenIssues, filteredClosedIssues } = useMemo(() => { + const open: typeof openIssues = []; + const closed: typeof closedIssues = []; + for (const issue of filterResult.matchedIssues) { + if (issue.state.toLowerCase() === 'open') { + open.push(issue); + } else { + closed.push(issue); + } + } + return { filteredOpenIssues: open, filteredClosedIssues: closed }; + }, [filterResult.matchedIssues]); + + // Filter state change handlers + const handleStateFilterChange = useCallback((stateFilter: IssuesStateFilter) => { + setFilterState((prev) => ({ ...prev, stateFilter })); + }, []); + + const handleLabelsChange = useCallback((selectedLabels: string[]) => { + setFilterState((prev) => ({ ...prev, selectedLabels })); + }, []); + + // Clear all filters to default state + const handleClearFilters = useCallback(() => { + setFilterState(DEFAULT_ISSUES_FILTER_STATE); + }, []); + // Get current branch from selected worktree const currentBranch = useMemo(() => { if (!currentProject?.path) return ''; @@ -130,7 +174,10 @@ export function GitHubIssuesView() { return ; } - const totalIssues = openIssues.length + closedIssues.length; + const totalIssues = filteredOpenIssues.length + filteredClosedIssues.length; + const totalUnfilteredIssues = openIssues.length + closedIssues.length; + const isFilteredEmpty = + totalIssues === 0 && totalUnfilteredIssues > 0 && filterResult.hasActiveFilter; return (
@@ -143,10 +190,21 @@ export function GitHubIssuesView() { > {/* Header */} {/* Issues List */} @@ -154,15 +212,35 @@ export function GitHubIssuesView() { {totalIssues === 0 ? (
- + {isFilteredEmpty ? ( + + ) : ( + + )}
-

No Issues

-

This repository has no issues yet.

+

+ {isFilteredEmpty ? 'No Matching Issues' : 'No Issues'} +

+

+ {isFilteredEmpty + ? 'No issues match your current filters.' + : 'This repository has no issues yet.'} +

+ {isFilteredEmpty && ( + + )}
) : (
{/* Open Issues */} - {openIssues.map((issue) => ( + {filteredOpenIssues.map((issue) => ( 0 && ( + {filteredClosedIssues.length > 0 && ( <>
- Closed Issues ({closedIssues.length}) + Closed Issues ({filteredClosedIssues.length})
- {closedIssues.map((issue) => ( + {filteredClosedIssues.map((issue) => ( void; + /** Callback when labels selection changes */ + onLabelsChange: (labels: string[]) => void; + /** Whether the controls are disabled (e.g., during loading) */ + disabled?: boolean; + /** Whether to use compact layout (stacked vertically) */ + compact?: boolean; + /** Additional class name for the container */ + className?: string; +} + +/** Human-readable labels for state filter options */ +const STATE_FILTER_LABELS: Record = { + open: 'Open', + closed: 'Closed', + all: 'All', +}; + +export function IssuesFilterControls({ + stateFilter, + selectedLabels, + availableLabels, + onStateFilterChange, + onLabelsChange, + disabled = false, + compact = false, + className, +}: IssuesFilterControlsProps) { + /** + * Handles toggling a label in the selection. + * If the label is already selected, it removes it; otherwise, it adds it. + */ + const handleLabelToggle = (label: string) => { + const isSelected = selectedLabels.includes(label); + if (isSelected) { + onLabelsChange(selectedLabels.filter((l) => l !== label)); + } else { + onLabelsChange([...selectedLabels, label]); + } + }; + + /** + * Clears all selected labels. + */ + const handleClearLabels = () => { + onLabelsChange([]); + }; + + const hasSelectedLabels = selectedLabels.length > 0; + const hasAvailableLabels = availableLabels.length > 0; + + return ( +
+ {/* Filter Controls Row */} +
+ {/* State Filter Select */} + + + {/* Labels Filter Dropdown */} + + + + + + + Filter by label + {hasSelectedLabels && ( + + )} + + + {availableLabels.map((label) => ( + handleLabelToggle(label)} + onSelect={(e) => e.preventDefault()} // Prevent dropdown from closing + > + {label} + + ))} + {!hasAvailableLabels && ( +
No labels available
+ )} +
+
+
+ + {/* Selected Labels Display - shown on separate row */} + {hasSelectedLabels && ( +
+ {selectedLabels + .slice(0, compact ? VISIBLE_LABELS_LIMIT_COMPACT : VISIBLE_LABELS_LIMIT) + .map((label) => ( + handleLabelToggle(label)} + > + {label} + + + ))} + {selectedLabels.length > + (compact ? VISIBLE_LABELS_LIMIT_COMPACT : VISIBLE_LABELS_LIMIT) && ( + + + + {selectedLabels.length - + (compact ? VISIBLE_LABELS_LIMIT_COMPACT : VISIBLE_LABELS_LIMIT)}{' '} + more + + )} +
+ )} +
+ ); +} diff --git a/apps/ui/src/components/views/github-issues-view/components/issues-list-header.tsx b/apps/ui/src/components/views/github-issues-view/components/issues-list-header.tsx index 5529b30c..1c58bbe4 100644 --- a/apps/ui/src/components/views/github-issues-view/components/issues-list-header.tsx +++ b/apps/ui/src/components/views/github-issues-view/components/issues-list-header.tsx @@ -1,38 +1,100 @@ import { CircleDot, RefreshCw } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; +import type { IssuesStateFilter } from '../types'; +import { IssuesFilterControls } from './issues-filter-controls'; interface IssuesListHeaderProps { openCount: number; closedCount: number; + /** Total open issues count (unfiltered) - used to show "X of Y" when filtered */ + totalOpenCount?: number; + /** Total closed issues count (unfiltered) - used to show "X of Y" when filtered */ + totalClosedCount?: number; + /** Whether any filter is currently active */ + hasActiveFilter?: boolean; refreshing: boolean; onRefresh: () => void; + /** Whether the list is in compact mode (e.g., when detail panel is open) */ + compact?: boolean; + /** Optional filter state and handlers - when provided, filter controls are rendered */ + filterProps?: { + stateFilter: IssuesStateFilter; + selectedLabels: string[]; + availableLabels: string[]; + onStateFilterChange: (filter: IssuesStateFilter) => void; + onLabelsChange: (labels: string[]) => void; + }; } export function IssuesListHeader({ openCount, closedCount, + totalOpenCount, + totalClosedCount, + hasActiveFilter = false, refreshing, onRefresh, + compact = false, + filterProps, }: IssuesListHeaderProps) { const totalIssues = openCount + closedCount; + // Format the counts subtitle based on filter state + const getCountsSubtitle = () => { + if (totalIssues === 0) { + return hasActiveFilter ? 'No matching issues' : 'No issues found'; + } + + // When filters are active and we have total counts, show "X of Y" format + if (hasActiveFilter && totalOpenCount !== undefined && totalClosedCount !== undefined) { + const openText = + openCount === totalOpenCount + ? `${openCount} open` + : `${openCount} of ${totalOpenCount} open`; + const closedText = + closedCount === totalClosedCount + ? `${closedCount} closed` + : `${closedCount} of ${totalClosedCount} closed`; + return `${openText}, ${closedText}`; + } + + // Default format when no filters active + return `${openCount} open, ${closedCount} closed`; + }; + return ( -
-
-
- -
-
-

Issues

-

- {totalIssues === 0 ? 'No issues found' : `${openCount} open, ${closedCount} closed`} -

+
+ {/* Top row: Title and refresh button */} +
+
+
+ +
+
+

Issues

+

{getCountsSubtitle()}

+
+
- + + {/* Filter controls row (optional) */} + {filterProps && ( +
+ +
+ )}
); } diff --git a/apps/ui/src/components/views/github-issues-view/hooks/index.ts b/apps/ui/src/components/views/github-issues-view/hooks/index.ts index 57b78868..a03332ad 100644 --- a/apps/ui/src/components/views/github-issues-view/hooks/index.ts +++ b/apps/ui/src/components/views/github-issues-view/hooks/index.ts @@ -1,3 +1,4 @@ export { useGithubIssues } from './use-github-issues'; export { useIssueValidation } from './use-issue-validation'; export { useIssueComments } from './use-issue-comments'; +export { useIssuesFilter } from './use-issues-filter'; diff --git a/apps/ui/src/components/views/github-issues-view/hooks/use-issues-filter.ts b/apps/ui/src/components/views/github-issues-view/hooks/use-issues-filter.ts new file mode 100644 index 00000000..987e890a --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view/hooks/use-issues-filter.ts @@ -0,0 +1,240 @@ +import { useMemo } from 'react'; +import type { GitHubIssue, StoredValidation } from '@/lib/electron'; +import type { IssuesFilterState, IssuesFilterResult, IssuesValidationStatus } from '../types'; +import { isValidationStale } from '../utils'; + +/** + * Determines the validation status of an issue based on its cached validation. + */ +function getValidationStatus( + issueNumber: number, + cachedValidations: Map +): IssuesValidationStatus | null { + const validation = cachedValidations.get(issueNumber); + if (!validation) { + return 'not_validated'; + } + if (isValidationStale(validation.validatedAt)) { + return 'stale'; + } + return 'validated'; +} + +/** + * Checks if a search query matches an issue's searchable content. + * Searches through title and body (case-insensitive). + */ +function matchesSearchQuery(issue: GitHubIssue, normalizedQuery: string): boolean { + if (!normalizedQuery) return true; + + const titleMatch = issue.title?.toLowerCase().includes(normalizedQuery); + const bodyMatch = issue.body?.toLowerCase().includes(normalizedQuery); + + return titleMatch || bodyMatch; +} + +/** + * Checks if an issue matches the state filter (open/closed/all). + * Note: GitHub CLI returns state in uppercase (OPEN/CLOSED), so we compare case-insensitively. + */ +function matchesStateFilter( + issue: GitHubIssue, + stateFilter: IssuesFilterState['stateFilter'] +): boolean { + if (stateFilter === 'all') return true; + return issue.state.toLowerCase() === stateFilter; +} + +/** + * Checks if an issue matches any of the selected labels. + * Returns true if no labels are selected (no filter) or if any selected label matches. + */ +function matchesLabels(issue: GitHubIssue, selectedLabels: string[]): boolean { + if (selectedLabels.length === 0) return true; + + const issueLabels = issue.labels.map((l) => l.name); + return selectedLabels.some((label) => issueLabels.includes(label)); +} + +/** + * Checks if an issue matches any of the selected assignees. + * Returns true if no assignees are selected (no filter) or if any selected assignee matches. + */ +function matchesAssignees(issue: GitHubIssue, selectedAssignees: string[]): boolean { + if (selectedAssignees.length === 0) return true; + + const issueAssignees = issue.assignees?.map((a) => a.login) ?? []; + return selectedAssignees.some((assignee) => issueAssignees.includes(assignee)); +} + +/** + * Checks if an issue matches any of the selected milestones. + * Returns true if no milestones are selected (no filter) or if any selected milestone matches. + * Note: GitHub issues may not have milestone data in the current schema, this is a placeholder. + */ +function matchesMilestones(issue: GitHubIssue, selectedMilestones: string[]): boolean { + if (selectedMilestones.length === 0) return true; + + // GitHub issues in the current schema don't have milestone field + // This is a placeholder for future milestone support + // For now, issues with no milestone won't match if a milestone filter is active + return false; +} + +/** + * Checks if an issue matches the validation status filter. + */ +function matchesValidationStatus( + issue: GitHubIssue, + validationStatusFilter: IssuesValidationStatus | null, + cachedValidations: Map +): boolean { + if (!validationStatusFilter) return true; + + const status = getValidationStatus(issue.number, cachedValidations); + return status === validationStatusFilter; +} + +/** + * Extracts all unique labels from a list of issues. + */ +function extractAvailableLabels(issues: GitHubIssue[]): string[] { + const labelsSet = new Set(); + for (const issue of issues) { + for (const label of issue.labels) { + labelsSet.add(label.name); + } + } + return Array.from(labelsSet).sort(); +} + +/** + * Extracts all unique assignees from a list of issues. + */ +function extractAvailableAssignees(issues: GitHubIssue[]): string[] { + const assigneesSet = new Set(); + for (const issue of issues) { + for (const assignee of issue.assignees ?? []) { + assigneesSet.add(assignee.login); + } + } + return Array.from(assigneesSet).sort(); +} + +/** + * Extracts all unique milestones from a list of issues. + * Note: Currently returns empty array as milestone is not in the GitHubIssue schema. + */ +function extractAvailableMilestones(_issues: GitHubIssue[]): string[] { + // GitHub issues in the current schema don't have milestone field + // This is a placeholder for future milestone support + return []; +} + +/** + * Determines if any filter is currently active. + */ +function hasActiveFilterCheck(filterState: IssuesFilterState): boolean { + const { + searchQuery, + stateFilter, + selectedLabels, + selectedAssignees, + selectedMilestones, + validationStatusFilter, + } = filterState; + + // Note: stateFilter 'open' is the default, so we consider it "not active" for UI purposes + // Only 'closed' or 'all' are considered active filters + const hasStateFilter = stateFilter !== 'open'; + const hasSearchQuery = searchQuery.trim().length > 0; + const hasLabelFilter = selectedLabels.length > 0; + const hasAssigneeFilter = selectedAssignees.length > 0; + const hasMilestoneFilter = selectedMilestones.length > 0; + const hasValidationFilter = validationStatusFilter !== null; + + return ( + hasSearchQuery || + hasStateFilter || + hasLabelFilter || + hasAssigneeFilter || + hasMilestoneFilter || + hasValidationFilter + ); +} + +/** + * Hook to filter GitHub issues based on the current filter state. + * + * This hook follows the same pattern as useGraphFilter but is tailored for GitHub issues. + * It computes matched issues and extracts available filter options from all issues. + * + * @param issues - Combined array of all issues (open + closed) to filter + * @param filterState - Current filter state including search, labels, assignees, etc. + * @param cachedValidations - Map of issue numbers to their cached validation results + * @returns Filter result containing matched issue numbers and available filter options + */ +export function useIssuesFilter( + issues: GitHubIssue[], + filterState: IssuesFilterState, + cachedValidations: Map = new Map() +): IssuesFilterResult { + const { + searchQuery, + stateFilter, + selectedLabels, + selectedAssignees, + selectedMilestones, + validationStatusFilter, + } = filterState; + + return useMemo(() => { + // Extract available options from all issues (for filter dropdown population) + const availableLabels = extractAvailableLabels(issues); + const availableAssignees = extractAvailableAssignees(issues); + const availableMilestones = extractAvailableMilestones(issues); + + // Check if any filter is active + const hasActiveFilter = hasActiveFilterCheck(filterState); + + // Normalize search query for case-insensitive matching + const normalizedQuery = searchQuery.toLowerCase().trim(); + + // Filter issues based on all criteria - return matched issues directly + // This eliminates the redundant O(n) filtering operation in the consuming component + const matchedIssues: GitHubIssue[] = []; + + for (const issue of issues) { + // All conditions must be true for a match + const matchesAllFilters = + matchesSearchQuery(issue, normalizedQuery) && + matchesStateFilter(issue, stateFilter) && + matchesLabels(issue, selectedLabels) && + matchesAssignees(issue, selectedAssignees) && + matchesMilestones(issue, selectedMilestones) && + matchesValidationStatus(issue, validationStatusFilter, cachedValidations); + + if (matchesAllFilters) { + matchedIssues.push(issue); + } + } + + return { + matchedIssues, + availableLabels, + availableAssignees, + availableMilestones, + hasActiveFilter, + matchedCount: matchedIssues.length, + }; + }, [ + issues, + searchQuery, + stateFilter, + selectedLabels, + selectedAssignees, + selectedMilestones, + validationStatusFilter, + cachedValidations, + ]); +} diff --git a/apps/ui/src/components/views/github-issues-view/types.ts b/apps/ui/src/components/views/github-issues-view/types.ts index 7ea799d9..a66e3a96 100644 --- a/apps/ui/src/components/views/github-issues-view/types.ts +++ b/apps/ui/src/components/views/github-issues-view/types.ts @@ -1,6 +1,111 @@ import type { GitHubIssue, StoredValidation, GitHubComment } from '@/lib/electron'; import type { ModelId, LinkedPRInfo, PhaseModelEntry } from '@automaker/types'; +// ============================================================================ +// Issues Filter State Types +// ============================================================================ + +/** + * Available sort columns for issues list + */ +export const ISSUES_SORT_COLUMNS = [ + 'title', + 'created_at', + 'updated_at', + 'comments', + 'number', +] as const; + +export type IssuesSortColumn = (typeof ISSUES_SORT_COLUMNS)[number]; + +/** + * Sort direction options + */ +export type IssuesSortDirection = 'asc' | 'desc'; + +/** + * Available issue state filter values + */ +export const ISSUES_STATE_FILTER_OPTIONS = ['open', 'closed', 'all'] as const; + +export type IssuesStateFilter = (typeof ISSUES_STATE_FILTER_OPTIONS)[number]; + +/** + * Validation status filter values for filtering issues by validation state + */ +export const ISSUES_VALIDATION_STATUS_OPTIONS = ['validated', 'not_validated', 'stale'] as const; + +export type IssuesValidationStatus = (typeof ISSUES_VALIDATION_STATUS_OPTIONS)[number]; + +/** + * Sort configuration for issues list + */ +export interface IssuesSortConfig { + column: IssuesSortColumn; + direction: IssuesSortDirection; +} + +/** + * Main filter state interface for the GitHub Issues view + * + * This interface defines all filterable/sortable state for the issues list. + * It follows the same pattern as GraphFilterState but is tailored for GitHub issues. + */ +export interface IssuesFilterState { + /** Search query for filtering by issue title or body */ + searchQuery: string; + /** Filter by issue state (open/closed/all) */ + stateFilter: IssuesStateFilter; + /** Filter by selected labels (matches any) */ + selectedLabels: string[]; + /** Filter by selected assignees (matches any) */ + selectedAssignees: string[]; + /** Filter by selected milestones (matches any) */ + selectedMilestones: string[]; + /** Filter by validation status */ + validationStatusFilter: IssuesValidationStatus | null; + /** Current sort configuration */ + sortConfig: IssuesSortConfig; +} + +/** + * Result of applying filters to the issues list + */ +export interface IssuesFilterResult { + /** Array of GitHubIssue objects that match the current filters */ + matchedIssues: GitHubIssue[]; + /** Available labels from all issues (for filter dropdown population) */ + availableLabels: string[]; + /** Available assignees from all issues (for filter dropdown population) */ + availableAssignees: string[]; + /** Available milestones from all issues (for filter dropdown population) */ + availableMilestones: string[]; + /** Whether any filter is currently active */ + hasActiveFilter: boolean; + /** Total count of matched issues */ + matchedCount: number; +} + +/** + * Default values for IssuesFilterState + */ +export const DEFAULT_ISSUES_FILTER_STATE: IssuesFilterState = { + searchQuery: '', + stateFilter: 'open', + selectedLabels: [], + selectedAssignees: [], + selectedMilestones: [], + validationStatusFilter: null, + sortConfig: { + column: 'updated_at', + direction: 'desc', + }, +}; + +// ============================================================================ +// Component Props Types +// ============================================================================ + export interface IssueRowProps { issue: GitHubIssue; isSelected: boolean;