From f16749231d2c8830b3e2d7564aaeabcdbaacf297 Mon Sep 17 00:00:00 2001 From: Andy <119136210+AndyMik90@users.noreply.github.com> Date: Thu, 15 Jan 2026 20:57:14 +0100 Subject: [PATCH] fix(github-issues): add pagination and infinite scroll for issues tab (#1042) * fix(github-issues): add pagination and infinite scroll for issues tab GitHub's /issues API returns both issues and PRs mixed together, causing only 1 issue to show when the first 100 results were mostly PRs. Changes: - Add page-based pagination to issue handler with smart over-fetching - Load 50 issues per page, with infinite scroll for more - When user searches, load ALL issues to enable full-text search - Add IntersectionObserver for automatic load-more on scroll - Update store with isLoadingMore, hasMore, loadMoreGitHubIssues() - Add debug logging to issue handlers for troubleshooting Co-Authored-By: Claude Opus 4.5 * fix(github-issues): address PR review findings for pagination - Fix race condition in loadMoreGitHubIssues by capturing filter state and discarding stale results if filter changed during async operation - Fix redundant API call when search activates by removing isSearchActive from useEffect deps (handlers already manage search state changes) - Replace console.log with debugLog utility for cleaner production logs - Extract pagination magic numbers to named constants (ISSUES_PER_PAGE, GITHUB_API_PER_PAGE, MAX_PAGES_PAGINATED, MAX_PAGES_FETCH_ALL) - Add missing i18n keys for issues pagination (en/fr) - Improve hasMore calculation to prevent infinite loading when repo has mostly PRs and we can't find enough issues within fetch limit Co-Authored-By: Claude Opus 4.5 * fix(github-issues): address PR review findings for pagination - Fix load-more errors hiding all previously loaded issues by only showing blocking error when issues.length === 0, with inline error near load-more trigger for load-more failures - Reset search state when switching projects to prevent incorrect fetchAll mode for new project - Remove duplicate API calls on filter change by letting useEffect handle all loading when filterState changes - Consolidate PaginatedIssuesResult interface in shared types to eliminate duplication between issue-handlers.ts and github-api.ts - Clear selected issue when pagination is reset to prevent orphaned selections after search clear Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- .../ipc-handlers/github/issue-handlers.ts | 142 +++++++++++++++--- .../src/preload/api/modules/github-api.ts | 22 ++- .../src/renderer/components/GitHubIssues.tsx | 16 +- .../github-issues/components/IssueList.tsx | 68 ++++++++- .../github-issues/hooks/useGitHubIssues.ts | 61 +++++++- .../github-issues/hooks/useIssueFiltering.ts | 33 +++- .../components/github-issues/types/index.ts | 3 + .../frontend/src/renderer/lib/browser-mock.ts | 2 +- .../renderer/lib/mocks/integration-mock.ts | 2 +- .../src/renderer/stores/github/index.ts | 2 + .../renderer/stores/github/issues-store.ts | 116 +++++++++++++- .../src/shared/i18n/locales/en/common.json | 5 + .../src/shared/i18n/locales/fr/common.json | 5 + .../frontend/src/shared/types/integrations.ts | 8 + apps/frontend/src/shared/types/ipc.ts | 7 +- 15 files changed, 447 insertions(+), 45 deletions(-) diff --git a/apps/frontend/src/main/ipc-handlers/github/issue-handlers.ts b/apps/frontend/src/main/ipc-handlers/github/issue-handlers.ts index 2c8001c6..a3be6a1f 100644 --- a/apps/frontend/src/main/ipc-handlers/github/issue-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/github/issue-handlers.ts @@ -4,10 +4,17 @@ import { ipcMain } from 'electron'; import { IPC_CHANNELS } from '../../../shared/constants'; -import type { IPCResult, GitHubIssue } from '../../../shared/types'; +import type { IPCResult, GitHubIssue, PaginatedIssuesResult } from '../../../shared/types'; import { projectStore } from '../../project-store'; import { getGitHubConfig, githubFetch, normalizeRepoReference } from './utils'; import type { GitHubAPIIssue, GitHubAPIComment } from './types'; +import { debugLog } from '../../../shared/utils/debug-logger'; + +// Pagination constants +const ISSUES_PER_PAGE = 50; // Target number of issues per page (after filtering PRs) +const GITHUB_API_PER_PAGE = 100; // GitHub API's max items per request +const MAX_PAGES_PAGINATED = 5; // Max API pages to fetch in paginated mode +const MAX_PAGES_FETCH_ALL = 30; // Max API pages to fetch in fetchAll mode /** * Transform GitHub API issue to application format @@ -40,19 +47,35 @@ function transformIssue(issue: GitHubAPIIssue, repoFullName: string): GitHubIssu } /** - * Get list of issues from repository + * Get list of issues from repository with pagination support + * + * When page > 0: Returns paginated results (for infinite scroll) + * When page = 0 or fetchAll = true: Returns ALL issues (for search functionality) + * + * Note: GitHub's /issues endpoint returns both issues and PRs mixed together, + * so we need to over-fetch and filter to get enough actual issues per page. */ export function registerGetIssues(): void { ipcMain.handle( IPC_CHANNELS.GITHUB_GET_ISSUES, - async (_, projectId: string, state: 'open' | 'closed' | 'all' = 'open'): Promise> => { + async ( + _, + projectId: string, + state: 'open' | 'closed' | 'all' = 'open', + page: number = 1, + fetchAll: boolean = false + ): Promise> => { + debugLog('[GitHub Issues] getIssues handler called', { projectId, state, page, fetchAll }); + const project = projectStore.getProject(projectId); if (!project) { + debugLog('[GitHub Issues] Project not found:', projectId); return { success: false, error: 'Project not found' }; } const config = getGitHubConfig(project); if (!config) { + debugLog('[GitHub Issues] No GitHub config found for project'); return { success: false, error: 'No GitHub token or repository configured' }; } @@ -65,28 +88,113 @@ export function registerGetIssues(): void { }; } - const issues = await githubFetch( - config.token, - `/repos/${normalizedRepo}/issues?state=${state}&per_page=100&sort=updated` - ); + debugLog('[GitHub Issues] Fetching issues from:', normalizedRepo, 'state:', state); - // Ensure issues is an array - if (!Array.isArray(issues)) { - return { - success: false, - error: 'Unexpected response format from GitHub API' - }; + const maxPagesPerRequest = fetchAll ? MAX_PAGES_FETCH_ALL : MAX_PAGES_PAGINATED; + + if (fetchAll) { + // Fetch ALL issues (for search functionality) + const allIssues: GitHubAPIIssue[] = []; + let apiPage = 1; + + while (apiPage <= MAX_PAGES_FETCH_ALL) { + debugLog('[GitHub Issues] Fetching page', apiPage, '(fetchAll mode)'); + + const pageIssues = await githubFetch( + config.token, + `/repos/${normalizedRepo}/issues?state=${state}&per_page=${GITHUB_API_PER_PAGE}&sort=updated&page=${apiPage}` + ); + + if (!Array.isArray(pageIssues) || pageIssues.length === 0) { + break; + } + + allIssues.push(...pageIssues); + + if (pageIssues.length < GITHUB_API_PER_PAGE) { + break; + } + + apiPage++; + } + + const issuesOnly = allIssues.filter((issue: GitHubAPIIssue) => !issue.pull_request); + const result: GitHubIssue[] = issuesOnly.map((issue: GitHubAPIIssue) => + transformIssue(issue, normalizedRepo) + ); + + debugLog('[GitHub Issues] fetchAll complete:', result.length, 'issues'); + return { success: true, data: { issues: result, hasMore: false } }; } - // Filter out pull requests - const issuesOnly = issues.filter((issue: GitHubAPIIssue) => !issue.pull_request); + // Paginated fetching - collect enough actual issues for the requested page + // Since GitHub mixes PRs with issues, we need to fetch multiple API pages + // to accumulate enough actual issues + const targetStartIndex = (page - 1) * ISSUES_PER_PAGE; + const targetEndIndex = page * ISSUES_PER_PAGE; - const result: GitHubIssue[] = issuesOnly.map((issue: GitHubAPIIssue) => + const collectedIssues: GitHubAPIIssue[] = []; + let apiPage = 1; + let hasMoreFromAPI = true; + + // Keep fetching until we have enough issues or run out of API pages + while (collectedIssues.length < targetEndIndex && apiPage <= maxPagesPerRequest && hasMoreFromAPI) { + debugLog('[GitHub Issues] Fetching API page', apiPage, 'collected so far:', collectedIssues.length); + + const pageItems = await githubFetch( + config.token, + `/repos/${normalizedRepo}/issues?state=${state}&per_page=${GITHUB_API_PER_PAGE}&sort=updated&page=${apiPage}` + ); + + if (!Array.isArray(pageItems)) { + debugLog('[GitHub Issues] Unexpected response format:', typeof pageItems); + break; + } + + if (pageItems.length === 0) { + hasMoreFromAPI = false; + break; + } + + // Filter out PRs and add to collected issues + const issuesFromPage = pageItems.filter((issue: GitHubAPIIssue) => !issue.pull_request); + collectedIssues.push(...issuesFromPage); + + debugLog('[GitHub Issues] API page', apiPage, ':', pageItems.length, 'items,', issuesFromPage.length, 'actual issues'); + + if (pageItems.length < GITHUB_API_PER_PAGE) { + hasMoreFromAPI = false; + } + + apiPage++; + } + + // Extract the issues for the requested page + const pageIssues = collectedIssues.slice(targetStartIndex, targetEndIndex); + + // Improved hasMore calculation: + // - If we collected more than the target end index, there's definitely more + // - If we haven't exhausted the API (hasMoreFromAPI=true), there might be more + // - BUT if we returned 0 issues for this page (pageIssues.length === 0), + // we've likely hit a situation where the repo has mostly PRs and we can't + // find enough issues within the fetch limit - signal no more to avoid + // infinite "load more" attempts + let hasMore = hasMoreFromAPI || collectedIssues.length > targetEndIndex; + + // Edge case: If we returned empty results, don't claim there's more + // This prevents infinite loading when repo has mostly PRs + if (pageIssues.length === 0) { + hasMore = false; + } + + const result: GitHubIssue[] = pageIssues.map((issue: GitHubAPIIssue) => transformIssue(issue, normalizedRepo) ); - return { success: true, data: result }; + debugLog('[GitHub Issues] Returning page', page, ':', result.length, 'issues, hasMore:', hasMore); + return { success: true, data: { issues: result, hasMore } }; } catch (error) { + debugLog('[GitHub Issues] Error fetching issues:', error); return { success: false, error: error instanceof Error ? error.message : 'Failed to fetch issues' diff --git a/apps/frontend/src/preload/api/modules/github-api.ts b/apps/frontend/src/preload/api/modules/github-api.ts index 988a1754..6481f820 100644 --- a/apps/frontend/src/preload/api/modules/github-api.ts +++ b/apps/frontend/src/preload/api/modules/github-api.ts @@ -7,7 +7,8 @@ import type { GitHubInvestigationStatus, GitHubInvestigationResult, IPCResult, - VersionSuggestion + VersionSuggestion, + PaginatedIssuesResult } from '../../../shared/types'; import { createIpcListener, invokeIpc, sendIpc, IpcListenerCleanup } from './ipc-utils'; @@ -145,13 +146,21 @@ export interface WorkflowsAwaitingApprovalResult { error?: string; } +// Re-export PaginatedIssuesResult from shared types for API consumers +export type { PaginatedIssuesResult }; + /** * GitHub Integration API operations */ export interface GitHubAPI { // Operations getGitHubRepositories: (projectId: string) => Promise>; - getGitHubIssues: (projectId: string, state?: 'open' | 'closed' | 'all') => Promise>; + getGitHubIssues: ( + projectId: string, + state?: 'open' | 'closed' | 'all', + page?: number, + fetchAll?: boolean + ) => Promise>; getGitHubIssue: (projectId: string, issueNumber: number) => Promise>; getIssueComments: (projectId: string, issueNumber: number) => Promise>; checkGitHubConnection: (projectId: string) => Promise>; @@ -456,8 +465,13 @@ export const createGitHubAPI = (): GitHubAPI => ({ getGitHubRepositories: (projectId: string): Promise> => invokeIpc(IPC_CHANNELS.GITHUB_GET_REPOSITORIES, projectId), - getGitHubIssues: (projectId: string, state?: 'open' | 'closed' | 'all'): Promise> => - invokeIpc(IPC_CHANNELS.GITHUB_GET_ISSUES, projectId, state), + getGitHubIssues: ( + projectId: string, + state?: 'open' | 'closed' | 'all', + page?: number, + fetchAll?: boolean + ): Promise> => + invokeIpc(IPC_CHANNELS.GITHUB_GET_ISSUES, projectId, state, page, fetchAll), getGitHubIssue: (projectId: string, issueNumber: number): Promise> => invokeIpc(IPC_CHANNELS.GITHUB_GET_ISSUE, projectId, issueNumber), diff --git a/apps/frontend/src/renderer/components/GitHubIssues.tsx b/apps/frontend/src/renderer/components/GitHubIssues.tsx index cd43ac75..9fc5a0f3 100644 --- a/apps/frontend/src/renderer/components/GitHubIssues.tsx +++ b/apps/frontend/src/renderer/components/GitHubIssues.tsx @@ -31,15 +31,20 @@ export function GitHubIssues({ onOpenSettings, onNavigateToTask }: GitHubIssuesP issues, syncStatus, isLoading, + isLoadingMore, error, selectedIssueNumber, selectedIssue, filterState, + hasMore, selectIssue, getFilteredIssues, getOpenIssuesCount, handleRefresh, handleFilterChange, + handleLoadMore, + handleSearchStart, + handleSearchClear, } = useGitHubIssues(selectedProject?.id); const { @@ -49,7 +54,13 @@ export function GitHubIssues({ onOpenSettings, onNavigateToTask }: GitHubIssuesP resetInvestigationStatus, } = useGitHubInvestigation(selectedProject?.id); - const { searchQuery, setSearchQuery, filteredIssues } = useIssueFiltering(getFilteredIssues()); + const { searchQuery, setSearchQuery, filteredIssues, isSearchActive } = useIssueFiltering( + getFilteredIssues(), + { + onSearchStart: handleSearchStart, + onSearchClear: handleSearchClear, + } + ); const { config: autoFixConfig, @@ -158,9 +169,12 @@ export function GitHubIssues({ onOpenSettings, onNavigateToTask }: GitHubIssuesP issues={filteredIssues} selectedIssueNumber={selectedIssueNumber} isLoading={isLoading} + isLoadingMore={isLoadingMore} + hasMore={hasMore && !isSearchActive} error={error} onSelectIssue={selectIssue} onInvestigate={handleInvestigate} + onLoadMore={!isSearchActive ? handleLoadMore : undefined} /> diff --git a/apps/frontend/src/renderer/components/github-issues/components/IssueList.tsx b/apps/frontend/src/renderer/components/github-issues/components/IssueList.tsx index 234af60b..36370723 100644 --- a/apps/frontend/src/renderer/components/github-issues/components/IssueList.tsx +++ b/apps/frontend/src/renderer/components/github-issues/components/IssueList.tsx @@ -1,18 +1,53 @@ +import { useRef, useEffect, useCallback } from 'react'; import { Loader2, AlertCircle } from 'lucide-react'; import { ScrollArea } from '../../ui/scroll-area'; import { IssueListItem } from './IssueListItem'; import { EmptyState } from './EmptyStates'; import type { IssueListProps } from '../types'; +import { useTranslation } from 'react-i18next'; export function IssueList({ issues, selectedIssueNumber, isLoading, + isLoadingMore, + hasMore, error, onSelectIssue, - onInvestigate + onInvestigate, + onLoadMore }: IssueListProps) { - if (error) { + const { t } = useTranslation('common'); + const loadMoreTriggerRef = useRef(null); + + // Intersection Observer for infinite scroll + const handleIntersection = useCallback((entries: IntersectionObserverEntry[]) => { + const [entry] = entries; + if (entry.isIntersecting && hasMore && !isLoadingMore && !isLoading && onLoadMore) { + onLoadMore(); + } + }, [hasMore, isLoadingMore, isLoading, onLoadMore]); + + useEffect(() => { + const trigger = loadMoreTriggerRef.current; + if (!trigger || !onLoadMore) return; + + const observer = new IntersectionObserver(handleIntersection, { + root: null, + rootMargin: '100px', + threshold: 0 + }); + + observer.observe(trigger); + + return () => { + observer.disconnect(); + }; + }, [handleIntersection, onLoadMore]); + + // Only show blocking error view when no issues are loaded + // Load-more errors are shown inline near the load-more trigger + if (error && issues.length === 0) { return (
@@ -23,7 +58,7 @@ export function IssueList({ ); } - if (isLoading) { + if (isLoading && issues.length === 0) { return (
@@ -47,6 +82,33 @@ export function IssueList({ onInvestigate={() => onInvestigate(issue)} /> ))} + + {/* Load more trigger / Loading indicator */} + {onLoadMore && ( +
+ {/* Inline error for load-more failures (when issues are already loaded) */} + {error && issues.length > 0 && ( +
+ + {error} +
+ )} + {isLoadingMore ? ( +
+ + {t('issues.loadingMore', 'Loading more...')} +
+ ) : hasMore ? ( + + {t('issues.scrollForMore', 'Scroll for more')} + + ) : issues.length > 0 ? ( + + {t('issues.allLoaded', 'All issues loaded')} + + ) : null} +
+ )}
); diff --git a/apps/frontend/src/renderer/components/github-issues/hooks/useGitHubIssues.ts b/apps/frontend/src/renderer/components/github-issues/hooks/useGitHubIssues.ts index 18cd136c..7343ddd3 100644 --- a/apps/frontend/src/renderer/components/github-issues/hooks/useGitHubIssues.ts +++ b/apps/frontend/src/renderer/components/github-issues/hooks/useGitHubIssues.ts @@ -1,8 +1,10 @@ -import { useEffect, useCallback, useRef, useMemo } from "react"; +import { useEffect, useCallback, useRef, useMemo, useState } from "react"; import { useIssuesStore, useSyncStatusStore, loadGitHubIssues, + loadMoreGitHubIssues, + loadAllGitHubIssues, checkGitHubConnection, type IssueFilterState, } from "../../../stores/github"; @@ -12,9 +14,11 @@ export function useGitHubIssues(projectId: string | undefined) { const { issues, isLoading, + isLoadingMore, error, selectedIssueNumber, filterState, + hasMore, selectIssue, setFilterState, getFilteredIssues, @@ -26,6 +30,14 @@ export function useGitHubIssues(projectId: string | undefined) { // Track if we've checked connection for this mount const hasCheckedRef = useRef(false); + // Track if search is active (need to load all issues for search) + const [isSearchActive, setIsSearchActive] = useState(false); + + // Reset search state when projectId changes to prevent incorrect fetchAll mode + useEffect(() => { + setIsSearchActive(false); + }, [projectId]); + // Always check connection when component mounts or projectId changes useEffect(() => { if (projectId) { @@ -36,30 +48,58 @@ export function useGitHubIssues(projectId: string | undefined) { }, [projectId]); // Load issues when filter changes or after connection is established + // Note: isSearchActive is NOT in deps because handleSearchStart/handleSearchClear + // already handle loading issues when search state changes. Including it would cause + // duplicate API calls. useEffect(() => { if (projectId && syncStatus?.connected) { - loadGitHubIssues(projectId, filterState); + // If search is active, load all issues for complete search + loadGitHubIssues(projectId, filterState, isSearchActive); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [projectId, filterState, syncStatus?.connected]); const handleRefresh = useCallback(() => { if (projectId) { // Re-check connection and reload issues checkGitHubConnection(projectId); - loadGitHubIssues(projectId, filterState); + loadGitHubIssues(projectId, filterState, isSearchActive); } - }, [projectId, filterState]); + }, [projectId, filterState, isSearchActive]); const handleFilterChange = useCallback( (state: FilterState) => { + // Only update filter state - useEffect handles loading when filterState changes + // This prevents duplicate API calls setFilterState(state); - if (projectId) { - loadGitHubIssues(projectId, state); - } }, - [projectId, setFilterState] + [setFilterState] ); + const handleLoadMore = useCallback(() => { + if (projectId && !isSearchActive) { + loadMoreGitHubIssues(projectId, filterState); + } + }, [projectId, filterState, isSearchActive]); + + // When user starts searching, load all issues + const handleSearchStart = useCallback(() => { + if (!isSearchActive && projectId) { + setIsSearchActive(true); + // Load all issues for search + loadAllGitHubIssues(projectId, filterState); + } + }, [isSearchActive, projectId, filterState]); + + // When user clears search, reset to paginated mode + const handleSearchClear = useCallback(() => { + if (isSearchActive && projectId) { + setIsSearchActive(false); + // Reset to paginated loading + loadGitHubIssues(projectId, filterState, false); + } + }, [isSearchActive, projectId, filterState]); + // Compute selectedIssue from issues array const selectedIssue = useMemo(() => { return issues.find((i) => i.number === selectedIssueNumber) || null; @@ -69,14 +109,19 @@ export function useGitHubIssues(projectId: string | undefined) { issues, syncStatus, isLoading, + isLoadingMore, error, selectedIssueNumber, selectedIssue, filterState, + hasMore: !isSearchActive && hasMore, // No "load more" when search is active selectIssue, getFilteredIssues, getOpenIssuesCount, handleRefresh, handleFilterChange, + handleLoadMore, + handleSearchStart, + handleSearchClear, }; } diff --git a/apps/frontend/src/renderer/components/github-issues/hooks/useIssueFiltering.ts b/apps/frontend/src/renderer/components/github-issues/hooks/useIssueFiltering.ts index 899a95b3..1d680860 100644 --- a/apps/frontend/src/renderer/components/github-issues/hooks/useIssueFiltering.ts +++ b/apps/frontend/src/renderer/components/github-issues/hooks/useIssueFiltering.ts @@ -1,17 +1,42 @@ -import { useState, useMemo } from 'react'; +import { useState, useMemo, useCallback, useEffect } from 'react'; import type { GitHubIssue } from '../../../../shared/types'; import { filterIssuesBySearch } from '../utils'; -export function useIssueFiltering(issues: GitHubIssue[]) { +interface UseIssueFilteringOptions { + onSearchStart?: () => void; + onSearchClear?: () => void; +} + +export function useIssueFiltering( + issues: GitHubIssue[], + options: UseIssueFilteringOptions = {} +) { + const { onSearchStart, onSearchClear } = options; const [searchQuery, setSearchQuery] = useState(''); const filteredIssues = useMemo(() => { return filterIssuesBySearch(issues, searchQuery); }, [issues, searchQuery]); + // Notify when search becomes active or inactive + useEffect(() => { + if (searchQuery.length > 0) { + onSearchStart?.(); + } else { + onSearchClear?.(); + } + }, [searchQuery, onSearchStart, onSearchClear]); + + const handleSearchChange = useCallback((query: string) => { + setSearchQuery(query); + }, []); + + const isSearchActive = searchQuery.length > 0; + return { searchQuery, - setSearchQuery, - filteredIssues + setSearchQuery: handleSearchChange, + filteredIssues, + isSearchActive }; } diff --git a/apps/frontend/src/renderer/components/github-issues/types/index.ts b/apps/frontend/src/renderer/components/github-issues/types/index.ts index 9f57ebb3..8a86ae5d 100644 --- a/apps/frontend/src/renderer/components/github-issues/types/index.ts +++ b/apps/frontend/src/renderer/components/github-issues/types/index.ts @@ -70,9 +70,12 @@ export interface IssueListProps { issues: GitHubIssue[]; selectedIssueNumber: number | null; isLoading: boolean; + isLoadingMore?: boolean; + hasMore?: boolean; error: string | null; onSelectIssue: (issueNumber: number) => void; onInvestigate: (issue: GitHubIssue) => void; + onLoadMore?: () => void; } export interface EmptyStateProps { diff --git a/apps/frontend/src/renderer/lib/browser-mock.ts b/apps/frontend/src/renderer/lib/browser-mock.ts index 169aa3db..fb622bfa 100644 --- a/apps/frontend/src/renderer/lib/browser-mock.ts +++ b/apps/frontend/src/renderer/lib/browser-mock.ts @@ -164,7 +164,7 @@ const browserMockAPI: ElectronAPI = { // GitHub API github: { getGitHubRepositories: async () => ({ success: true, data: [] }), - getGitHubIssues: async () => ({ success: true, data: [] }), + getGitHubIssues: async () => ({ success: true, data: { issues: [], hasMore: false } }), getGitHubIssue: async () => ({ success: true, data: null as any }), getIssueComments: async () => ({ success: true, data: [] }), checkGitHubConnection: async () => ({ success: true, data: { connected: false, repoFullName: undefined, error: undefined } }), diff --git a/apps/frontend/src/renderer/lib/mocks/integration-mock.ts b/apps/frontend/src/renderer/lib/mocks/integration-mock.ts index 9596563f..3e5c0a9f 100644 --- a/apps/frontend/src/renderer/lib/mocks/integration-mock.ts +++ b/apps/frontend/src/renderer/lib/mocks/integration-mock.ts @@ -98,7 +98,7 @@ export const integrationMock = { getGitHubIssues: async () => ({ success: true, - data: [] + data: { issues: [], hasMore: false } }), getGitHubIssue: async () => ({ diff --git a/apps/frontend/src/renderer/stores/github/index.ts b/apps/frontend/src/renderer/stores/github/index.ts index bf707180..9c52e048 100644 --- a/apps/frontend/src/renderer/stores/github/index.ts +++ b/apps/frontend/src/renderer/stores/github/index.ts @@ -13,6 +13,8 @@ export { useIssuesStore, loadGitHubIssues, + loadMoreGitHubIssues, + loadAllGitHubIssues, importGitHubIssues, type IssueFilterState } from './issues-store'; diff --git a/apps/frontend/src/renderer/stores/github/issues-store.ts b/apps/frontend/src/renderer/stores/github/issues-store.ts index b6460cc9..8f5c101a 100644 --- a/apps/frontend/src/renderer/stores/github/issues-store.ts +++ b/apps/frontend/src/renderer/stores/github/issues-store.ts @@ -9,19 +9,29 @@ interface IssuesState { // UI State isLoading: boolean; + isLoadingMore: boolean; error: string | null; selectedIssueNumber: number | null; filterState: IssueFilterState; + // Pagination + currentPage: number; + hasMore: boolean; + // Actions setIssues: (issues: GitHubIssue[]) => void; + appendIssues: (issues: GitHubIssue[]) => void; addIssue: (issue: GitHubIssue) => void; updateIssue: (issueNumber: number, updates: Partial) => void; setLoading: (loading: boolean) => void; + setLoadingMore: (loading: boolean) => void; setError: (error: string | null) => void; selectIssue: (issueNumber: number | null) => void; setFilterState: (state: IssueFilterState) => void; + setHasMore: (hasMore: boolean) => void; + setCurrentPage: (page: number) => void; clearIssues: () => void; + resetPagination: () => void; // Selectors getSelectedIssue: () => GitHubIssue | null; @@ -33,13 +43,23 @@ export const useIssuesStore = create((set, get) => ({ // Initial state issues: [], isLoading: false, + isLoadingMore: false, error: null, selectedIssueNumber: null, filterState: 'open', + currentPage: 1, + hasMore: true, // Actions setIssues: (issues) => set({ issues, error: null }), + appendIssues: (newIssues) => set((state) => { + // Deduplicate by issue number + const existingNumbers = new Set(state.issues.map(i => i.number)); + const uniqueNewIssues = newIssues.filter(i => !existingNumbers.has(i.number)); + return { issues: [...state.issues, ...uniqueNewIssues] }; + }), + addIssue: (issue) => set((state) => ({ issues: [issue, ...state.issues.filter(i => i.number !== issue.number)] })), @@ -52,16 +72,32 @@ export const useIssuesStore = create((set, get) => ({ setLoading: (isLoading) => set({ isLoading }), - setError: (error) => set({ error, isLoading: false }), + setLoadingMore: (isLoadingMore) => set({ isLoadingMore }), + + setError: (error) => set({ error, isLoading: false, isLoadingMore: false }), selectIssue: (selectedIssueNumber) => set({ selectedIssueNumber }), setFilterState: (filterState) => set({ filterState }), + setHasMore: (hasMore) => set({ hasMore }), + + setCurrentPage: (currentPage) => set({ currentPage }), + clearIssues: () => set({ issues: [], selectedIssueNumber: null, - error: null + error: null, + currentPage: 1, + hasMore: true + }), + + resetPagination: () => set({ + currentPage: 1, + hasMore: true, + // Clear selection when resetting pagination to prevent orphaned selections + // (e.g., when clearing search, the selected issue may no longer be in the results) + selectedIssueNumber: null }), // Selectors @@ -83,15 +119,29 @@ export const useIssuesStore = create((set, get) => ({ })); // Action functions for use outside of React components -export async function loadGitHubIssues(projectId: string, state?: IssueFilterState): Promise { + +/** + * Load GitHub issues with pagination support + * @param projectId - The project ID + * @param state - Filter state (open/closed/all) + * @param fetchAll - If true, fetches all issues (for search). Default: false (paginated) + */ +export async function loadGitHubIssues( + projectId: string, + state?: IssueFilterState, + fetchAll: boolean = false +): Promise { const store = useIssuesStore.getState(); store.setLoading(true); store.setError(null); + store.resetPagination(); try { - const result = await window.electronAPI.getGitHubIssues(projectId, state); + const result = await window.electronAPI.getGitHubIssues(projectId, state, 1, fetchAll); if (result.success && result.data) { - store.setIssues(result.data); + store.setIssues(result.data.issues); + store.setHasMore(result.data.hasMore); + store.setCurrentPage(1); } else { store.setError(result.error || 'Failed to load GitHub issues'); } @@ -102,6 +152,62 @@ export async function loadGitHubIssues(projectId: string, state?: IssueFilterSta } } +/** + * Load more issues (for infinite scroll) + */ +export async function loadMoreGitHubIssues( + projectId: string, + state?: IssueFilterState +): Promise { + const store = useIssuesStore.getState(); + + // Don't load more if already loading or no more to load + if (store.isLoadingMore || store.isLoading || !store.hasMore) { + return; + } + + // Capture filter state at request start to detect if it changes during the async call + const originalFilterState = store.filterState; + const nextPage = store.currentPage + 1; + + store.setLoadingMore(true); + + try { + const result = await window.electronAPI.getGitHubIssues(projectId, state, nextPage, false); + + // Verify filter state hasn't changed during the async operation + // This prevents appending stale data from a different filter + const currentState = useIssuesStore.getState(); + if (currentState.filterState !== originalFilterState) { + // Filter changed while loading - discard results + return; + } + + if (result.success && result.data) { + store.appendIssues(result.data.issues); + store.setHasMore(result.data.hasMore); + store.setCurrentPage(nextPage); + } else { + store.setError(result.error || 'Failed to load more issues'); + } + } catch (error) { + store.setError(error instanceof Error ? error.message : 'Unknown error'); + } finally { + store.setLoadingMore(false); + } +} + +/** + * Load ALL issues (for search functionality) + * This fetches all pages so search can work across all issues + */ +export async function loadAllGitHubIssues( + projectId: string, + state?: IssueFilterState +): Promise { + return loadGitHubIssues(projectId, state, true); +} + export async function importGitHubIssues( projectId: string, issueNumbers: number[] diff --git a/apps/frontend/src/shared/i18n/locales/en/common.json b/apps/frontend/src/shared/i18n/locales/en/common.json index 907cd30c..c2608a43 100644 --- a/apps/frontend/src/shared/i18n/locales/en/common.json +++ b/apps/frontend/src/shared/i18n/locales/en/common.json @@ -369,5 +369,10 @@ "conversionFailedDescription": "Failed to convert idea to task", "conversionError": "Conversion error", "conversionErrorDescription": "An error occurred while converting the idea" + }, + "issues": { + "loadingMore": "Loading more...", + "scrollForMore": "Scroll for more", + "allLoaded": "All issues loaded" } } diff --git a/apps/frontend/src/shared/i18n/locales/fr/common.json b/apps/frontend/src/shared/i18n/locales/fr/common.json index f27975fa..78ab5609 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/common.json +++ b/apps/frontend/src/shared/i18n/locales/fr/common.json @@ -369,5 +369,10 @@ "conversionFailedDescription": "Impossible de convertir l'idée en tâche", "conversionError": "Erreur de conversion", "conversionErrorDescription": "Une erreur s'est produite lors de la conversion de l'idée" + }, + "issues": { + "loadingMore": "Chargement...", + "scrollForMore": "Défiler pour plus", + "allLoaded": "Toutes les issues chargées" } } diff --git a/apps/frontend/src/shared/types/integrations.ts b/apps/frontend/src/shared/types/integrations.ts index 5198cbee..741e388f 100644 --- a/apps/frontend/src/shared/types/integrations.ts +++ b/apps/frontend/src/shared/types/integrations.ts @@ -105,6 +105,14 @@ export interface GitHubIssue { repoFullName: string; } +/** + * Result type for paginated issue fetching + */ +export interface PaginatedIssuesResult { + issues: GitHubIssue[]; + hasMore: boolean; +} + export interface GitHubSyncStatus { connected: boolean; repoFullName?: string; diff --git a/apps/frontend/src/shared/types/ipc.ts b/apps/frontend/src/shared/types/ipc.ts index a607fad0..b1886f73 100644 --- a/apps/frontend/src/shared/types/ipc.ts +++ b/apps/frontend/src/shared/types/ipc.ts @@ -385,7 +385,12 @@ export interface ElectronAPI { // GitHub integration operations getGitHubRepositories: (projectId: string) => Promise>; - getGitHubIssues: (projectId: string, state?: 'open' | 'closed' | 'all') => Promise>; + getGitHubIssues: ( + projectId: string, + state?: 'open' | 'closed' | 'all', + page?: number, + fetchAll?: boolean + ) => Promise>; getGitHubIssue: (projectId: string, issueNumber: number) => Promise>; checkGitHubConnection: (projectId: string) => Promise>; investigateGitHubIssue: (projectId: string, issueNumber: number, selectedCommentIds?: number[]) => void;