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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user