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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andy
2026-01-15 20:57:14 +01:00
committed by GitHub
parent 2ff9ccabfe
commit f16749231d
15 changed files with 447 additions and 45 deletions
@@ -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<IPCResult<GitHubIssue[]>> => {
async (
_,
projectId: string,
state: 'open' | 'closed' | 'all' = 'open',
page: number = 1,
fetchAll: boolean = false
): Promise<IPCResult<PaginatedIssuesResult>> => {
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'
@@ -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<IPCResult<GitHubRepository[]>>;
getGitHubIssues: (projectId: string, state?: 'open' | 'closed' | 'all') => Promise<IPCResult<GitHubIssue[]>>;
getGitHubIssues: (
projectId: string,
state?: 'open' | 'closed' | 'all',
page?: number,
fetchAll?: boolean
) => Promise<IPCResult<PaginatedIssuesResult>>;
getGitHubIssue: (projectId: string, issueNumber: number) => Promise<IPCResult<GitHubIssue>>;
getIssueComments: (projectId: string, issueNumber: number) => Promise<IPCResult<any[]>>;
checkGitHubConnection: (projectId: string) => Promise<IPCResult<GitHubSyncStatus>>;
@@ -456,8 +465,13 @@ export const createGitHubAPI = (): GitHubAPI => ({
getGitHubRepositories: (projectId: string): Promise<IPCResult<GitHubRepository[]>> =>
invokeIpc(IPC_CHANNELS.GITHUB_GET_REPOSITORIES, projectId),
getGitHubIssues: (projectId: string, state?: 'open' | 'closed' | 'all'): Promise<IPCResult<GitHubIssue[]>> =>
invokeIpc(IPC_CHANNELS.GITHUB_GET_ISSUES, projectId, state),
getGitHubIssues: (
projectId: string,
state?: 'open' | 'closed' | 'all',
page?: number,
fetchAll?: boolean
): Promise<IPCResult<PaginatedIssuesResult>> =>
invokeIpc(IPC_CHANNELS.GITHUB_GET_ISSUES, projectId, state, page, fetchAll),
getGitHubIssue: (projectId: string, issueNumber: number): Promise<IPCResult<GitHubIssue>> =>
invokeIpc(IPC_CHANNELS.GITHUB_GET_ISSUE, projectId, issueNumber),
@@ -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}
/>
</div>
@@ -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<HTMLDivElement>(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 (
<div className="p-4 bg-destructive/10 border-b border-destructive/30">
<div className="flex items-center gap-2 text-sm text-destructive">
@@ -23,7 +58,7 @@ export function IssueList({
);
}
if (isLoading) {
if (isLoading && issues.length === 0) {
return (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
@@ -47,6 +82,33 @@ export function IssueList({
onInvestigate={() => onInvestigate(issue)}
/>
))}
{/* Load more trigger / Loading indicator */}
{onLoadMore && (
<div ref={loadMoreTriggerRef} className="py-4 flex flex-col items-center gap-2">
{/* Inline error for load-more failures (when issues are already loaded) */}
{error && issues.length > 0 && (
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
{error}
</div>
)}
{isLoadingMore ? (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">{t('issues.loadingMore', 'Loading more...')}</span>
</div>
) : hasMore ? (
<span className="text-xs text-muted-foreground opacity-50">
{t('issues.scrollForMore', 'Scroll for more')}
</span>
) : issues.length > 0 ? (
<span className="text-xs text-muted-foreground opacity-50">
{t('issues.allLoaded', 'All issues loaded')}
</span>
) : null}
</div>
)}
</div>
</ScrollArea>
);
@@ -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,
};
}
@@ -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
};
}
@@ -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 {
@@ -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 } }),
@@ -98,7 +98,7 @@ export const integrationMock = {
getGitHubIssues: async () => ({
success: true,
data: []
data: { issues: [], hasMore: false }
}),
getGitHubIssue: async () => ({
@@ -13,6 +13,8 @@
export {
useIssuesStore,
loadGitHubIssues,
loadMoreGitHubIssues,
loadAllGitHubIssues,
importGitHubIssues,
type IssueFilterState
} from './issues-store';
@@ -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<GitHubIssue>) => 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<IssuesState>((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<IssuesState>((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<IssuesState>((set, get) => ({
}));
// Action functions for use outside of React components
export async function loadGitHubIssues(projectId: string, state?: IssueFilterState): Promise<void> {
/**
* 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<void> {
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<void> {
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<void> {
return loadGitHubIssues(projectId, state, true);
}
export async function importGitHubIssues(
projectId: string,
issueNumbers: number[]
@@ -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"
}
}
@@ -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"
}
}
@@ -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;
+6 -1
View File
@@ -385,7 +385,12 @@ export interface ElectronAPI {
// GitHub integration operations
getGitHubRepositories: (projectId: string) => Promise<IPCResult<GitHubRepository[]>>;
getGitHubIssues: (projectId: string, state?: 'open' | 'closed' | 'all') => Promise<IPCResult<GitHubIssue[]>>;
getGitHubIssues: (
projectId: string,
state?: 'open' | 'closed' | 'all',
page?: number,
fetchAll?: boolean
) => Promise<IPCResult<{ issues: GitHubIssue[]; hasMore: boolean }>>;
getGitHubIssue: (projectId: string, issueNumber: number) => Promise<IPCResult<GitHubIssue>>;
checkGitHubConnection: (projectId: string) => Promise<IPCResult<GitHubSyncStatus>>;
investigateGitHubIssue: (projectId: string, issueNumber: number, selectedCommentIds?: number[]) => void;