import React from 'react'; import { Icon } from '@/components/icon/Icon'; import { Button } from '@/components/ui/button'; import { ForgeEntityDetailView } from '@/components/views/forge'; import { ForgeCreateIssueDialog } from '@/components/views/forge/actions'; import { buildForgeProvider } from '@/lib/forge'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useUIStore } from '@/stores/useUIStore'; import type { GitHubIssueSummary, GitHubRepoSelector } from '@/lib/api/types'; import type { ForgeIssue } from '@/lib/forge'; import { useI18n } from '@/lib/i18n'; const issueLabelBadgeClass = 'inline-flex items-center rounded border border-border/60 bg-surface-elevated px-1.5 py-px typography-micro text-foreground'; /** * Open GitHub issues for the context panel's pull-request view. The list is * fetched lazily (the parent only mounts this component while the Issues tab * is active); selecting a row mounts the shared `ForgeEntityDetailView` for * the issue detail. Read-only by design — no create, update, or close actions. * * The parent does not gate on GitHub auth state, so the connection state is * derived from the API results themselves (`connected === false` renders a * not-connected state with a settings CTA). */ export const GitHubIssuesSection: React.FC<{ directory: string }> = ({ directory }) => { const { t } = useI18n(); const { github } = useRuntimeAPIs(); const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); const setSettingsPage = useUIStore((state) => state.setSettingsPage); // ---- Open issues list ---------------------------------------------------- const [issues, setIssues] = React.useState([]); const [listPage, setListPage] = React.useState(1); const [listHasMore, setListHasMore] = React.useState(false); const [listLoading, setListLoading] = React.useState(false); const [listLoadingMore, setListLoadingMore] = React.useState(false); const [listError, setListError] = React.useState(null); const [listNotConnected, setListNotConnected] = React.useState(false); const [retryToken, setRetryToken] = React.useState(0); // ---- Selected issue detail ------------------------------------------------ const [selectedNumber, setSelectedNumber] = React.useState(null); const [selectedSourceRepo, setSelectedSourceRepo] = React.useState< (GitHubRepoSelector & { source: string }) | null >(null); const [selectedUrl, setSelectedUrl] = React.useState(null); const [createOpen, setCreateOpen] = React.useState(false); const issueProvider = React.useMemo(() => (github ? buildForgeProvider('github', { github }) : null), [github]); const retry = React.useCallback(() => setRetryToken((value) => value + 1), []); const openGitHubSettings = React.useCallback(() => { setSettingsPage('git'); setSettingsDialogOpen(true); }, [setSettingsDialogOpen, setSettingsPage]); const handleIssueCreated = React.useCallback( (issue: ForgeIssue) => { // Open the freshly created issue's detail and refresh the list behind it. setSelectedNumber(issue.number); setSelectedUrl(issue.url ?? null); setRetryToken((value) => value + 1); }, [], ); // A different repository invalidates the previously loaded list and detail so // a stale repository's issues never leak into the new one. React.useEffect(() => { setIssues([]); setListPage(1); setListHasMore(false); setListLoading(false); setListLoadingMore(false); setListError(null); setListNotConnected(false); setSelectedNumber(null); setSelectedSourceRepo(null); setSelectedUrl(null); }, [directory]); React.useEffect(() => { if (!github?.issuesList) { return; } let cancelled = false; setListLoading(true); setListError(null); setListNotConnected(false); void github .issuesList(directory, { page: 1 }) .then((result) => { if (cancelled) { return; } if (result.connected === false) { setListNotConnected(true); return; } setIssues(result.issues ?? []); setListPage(result.page ?? 1); setListHasMore(Boolean(result.hasMore)); }) .catch((error) => { if (!cancelled) { setListError(error instanceof Error ? error.message : String(error)); } }) .finally(() => { if (!cancelled) { setListLoading(false); } }); return () => { cancelled = true; }; }, [directory, github, retryToken]); const loadMore = React.useCallback(async () => { if (!github?.issuesList || listLoadingMore || listLoading || !listHasMore) { return; } setListLoadingMore(true); try { const next = await github.issuesList(directory, { page: listPage + 1 }); if (next.connected === false) { setListNotConnected(true); return; } setIssues((previous) => [...previous, ...(next.issues ?? [])]); setListPage(next.page ?? listPage + 1); setListHasMore(Boolean(next.hasMore)); } catch (error) { setListError(error instanceof Error ? error.message : String(error)); } finally { setListLoadingMore(false); } }, [directory, github, listHasMore, listLoading, listLoadingMore, listPage]); // Selecting a row remembers the summary's sourceRepo and url too: the server // route resolves the repo from the directory, but cross-repo issues need the // explicit sourceRepo for the shared detail view to fetch the issue and its // comments from the right repository. const selectIssue = React.useCallback((item: GitHubIssueSummary) => { setSelectedNumber(item.number); setSelectedSourceRepo(item.sourceRepo ?? null); setSelectedUrl(item.url ?? null); }, []); const backToIssues = React.useCallback(() => { setSelectedNumber(null); setSelectedSourceRepo(null); setSelectedUrl(null); }, []); if (selectedNumber !== null) { return (
{selectedUrl ? ( ) : null}
{issueProvider ? ( ) : null}
); } return (
{t('gitView.pullRequest.issues.listSectionTitle')}
{issueProvider?.createIssue ? ( ) : null}
{!github?.issuesList ? (
{t('gitView.pullRequest.issues.empty')}
) : listNotConnected ? (
{t('gitView.pr.githubNotConnected')}
) : listLoading ? (
{t('session.githubIssuePicker.loading.issues')}
) : listError ? (
{t('gitView.pullRequest.issues.error.loadFailed')}
{listError}
) : issues.length === 0 ? (
{t('gitView.pullRequest.issues.empty')}
) : (
{issues.map((item) => ( ))} {listHasMore ? (
) : null}
)} {issueProvider?.createIssue ? ( ) : null}
); };