import * as React from 'react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { cn } from '@/lib/utils'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useUIStore } from '@/stores/useUIStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { validateWorktreeCreate } from '@/lib/worktrees/worktreeManager'; import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { Icon } from "@/components/icon/Icon"; import { useDebouncedValue } from '@/hooks/useDebouncedValue'; import type { GitHubIssue, GitHubIssueSummary, GitHubPullRequestSummary, } from '@/lib/api/types'; import type { ProjectRef } from '@/lib/worktrees/worktreeManager'; import { useI18n } from '@/lib/i18n'; type GitHubTab = 'issues' | 'prs'; interface GitHubIntegrationDialogProps { open: boolean; onOpenChange: (open: boolean) => void; onSelect: (result: { type: 'issue' | 'pr'; item: GitHubIssue | GitHubPullRequestSummary; includeDiff?: boolean; } | null) => void; } interface ValidationResult { isValid: boolean; error: string | null; } export function GitHubIntegrationDialog({ open, onOpenChange, onSelect, }: GitHubIntegrationDialogProps) { const { t } = useI18n(); const isMobile = useUIStore((state) => state.isMobile); const { github } = useRuntimeAPIs(); const githubAuthStatus = useGitHubAuthStore((state) => state.status); const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked); const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); const setSettingsPage = useUIStore((state) => state.setSettingsPage); const activeProject = useProjectsStore((state) => state.getActiveProject()); const projectDirectory = activeProject?.path ?? null; const projectRef: ProjectRef | null = React.useMemo(() => { if (projectDirectory && activeProject) { return { id: activeProject.id, path: projectDirectory }; } return null; }, [activeProject, projectDirectory]); // State const [activeTab, setActiveTab] = React.useState('issues'); const [searchQuery, setSearchQuery] = React.useState(''); const [issues, setIssues] = React.useState([]); const [prs, setPrs] = React.useState([]); const [loading, setLoading] = React.useState(false); const [loadingMore, setLoadingMore] = React.useState(false); const [error, setError] = React.useState(null); const [selectedIssue, setSelectedIssue] = React.useState(null); const [selectedPr, setSelectedPr] = React.useState(null); const [includeDiff, setIncludeDiff] = React.useState(false); const [validations, setValidations] = React.useState>(new Map()); const [page, setPage] = React.useState(1); const [hasMore, setHasMore] = React.useState(false); const debouncedSearchQuery = useDebouncedValue(searchQuery, 350); const loadData = React.useCallback(async (query?: string) => { if (!projectDirectory || !github) return; if (githubAuthChecked && githubAuthStatus?.connected === false) return; setLoading(true); setError(null); setPage(1); setHasMore(false); try { if (activeTab === 'issues' && github.issuesList) { const result = await github.issuesList(projectDirectory, { page: 1, query }); if (result.connected === false) { setError(t('session.githubIntegration.error.notConnected')); setIssues([]); } else { setIssues(result.issues ?? []); setPage(result.page ?? 1); setHasMore(Boolean(result.hasMore)); } } else if (activeTab === 'prs' && github.prsList) { const result = await github.prsList(projectDirectory, { page: 1, query }); if (result.connected === false) { setError(t('session.githubIntegration.error.notConnected')); setPrs([]); } else { setPrs(result.prs ?? []); setPage(result.page ?? 1); setHasMore(Boolean(result.hasMore)); } } } catch (err) { setError(err instanceof Error ? err.message : t('session.githubIntegration.error.loadDataFailed')); } finally { setLoading(false); } }, [projectDirectory, github, githubAuthChecked, githubAuthStatus, activeTab, t]); React.useEffect(() => { if (!open || !projectDirectory) return; if (githubAuthChecked && githubAuthStatus?.connected === false) return; if (!github) return; if (!debouncedSearchQuery.trim()) { void loadData(); return; } const controller = new AbortController(); setLoading(true); setError(null); setPage(1); setHasMore(false); const apiCall = activeTab === 'issues' && github.issuesList ? github.issuesList(projectDirectory, { page: 1, query: debouncedSearchQuery.trim() }) : activeTab === 'prs' && github.prsList ? github.prsList(projectDirectory, { page: 1, query: debouncedSearchQuery.trim() }) : null; if (!apiCall) { setLoading(false); return; } apiCall .then((result) => { if (controller.signal.aborted) return; if ('issues' in result) { if (result.connected === false) { setError(t('session.githubIntegration.error.notConnected')); setIssues([]); } else { setIssues(result.issues ?? []); setPage(result.page ?? 1); setHasMore(Boolean(result.hasMore)); } } else if ('prs' in result) { if (result.connected === false) { setError(t('session.githubIntegration.error.notConnected')); setPrs([]); } else { setPrs(result.prs ?? []); setPage(result.page ?? 1); setHasMore(Boolean(result.hasMore)); } } }) .catch((err) => { if (controller.signal.aborted) return; setError(err instanceof Error ? err.message : t('session.githubIntegration.error.loadDataFailed')); }) .finally(() => { if (!controller.signal.aborted) setLoading(false); }); return () => controller.abort(); }, [open, projectDirectory, github, githubAuthChecked, githubAuthStatus, activeTab, debouncedSearchQuery, loadData, t]); const loadMore = React.useCallback(async () => { if (!projectDirectory || !github) return; if (loading || loadingMore) return; if (!hasMore) return; setLoadingMore(true); try { const nextPage = page + 1; if (activeTab === 'issues' && github.issuesList) { const result = debouncedSearchQuery.trim() ? await github.issuesList(projectDirectory, { page: nextPage, query: debouncedSearchQuery.trim() }) : await github.issuesList(projectDirectory, { page: nextPage }); if (result.connected !== false) { setIssues(prev => [...prev, ...(result.issues ?? [])]); setPage(result.page ?? nextPage); setHasMore(Boolean(result.hasMore)); } } else if (activeTab === 'prs' && github.prsList) { const result = debouncedSearchQuery.trim() ? await github.prsList(projectDirectory, { page: nextPage, query: debouncedSearchQuery.trim() }) : await github.prsList(projectDirectory, { page: nextPage }); if (result.connected !== false) { setPrs(prev => [...prev, ...(result.prs ?? [])]); setPage(result.page ?? nextPage); setHasMore(Boolean(result.hasMore)); } } } catch { // Silently fail on load more errors } finally { setLoadingMore(false); } }, [projectDirectory, github, activeTab, page, hasMore, loading, loadingMore, debouncedSearchQuery]); // Reset state when dialog opens/closes React.useEffect(() => { if (!open) { setActiveTab('issues'); setSearchQuery(''); setIssues([]); setPrs([]); setSelectedIssue(null); setSelectedPr(null); setIncludeDiff(false); setError(null); setValidations(new Map()); setPage(1); setHasMore(false); return; } void loadData(); }, [open, loadData]); // Validate branches for worktree creation const validateBranch = React.useCallback(async (branchName: string) => { if (!projectRef || !branchName) return; // Check cache first if (validations.has(branchName)) return; try { const result = await validateWorktreeCreate(projectRef, { mode: 'new', branchName, worktreeName: branchName, }); const blockingError = result.errors.find((entry) => entry.code === 'branch_in_use'); setValidations(prev => new Map(prev).set(branchName, { isValid: !blockingError, error: blockingError ? t(blockingError.code === 'branch_exists' ? 'session.githubIntegration.validation.branchAlreadyExists' : 'session.githubIntegration.validation.branchAlreadyCheckedOut') : null, })); } catch { setValidations(prev => new Map(prev).set(branchName, { isValid: false, error: t('session.githubIntegration.validation.failed'), })); } }, [projectRef, validations, t]); // Validate PR branches when loaded React.useEffect(() => { if (!open || activeTab !== 'prs') return; prs.forEach(pr => { if (pr.head) { void validateBranch(pr.head); } }); }, [open, activeTab, prs, validateBranch]); // GitHub connection check const isGitHubConnected = githubAuthChecked && githubAuthStatus?.connected === true; const openGitHubSettings = () => { setSettingsPage('github'); setSettingsDialogOpen(true); }; // Handle selection const handleSelectIssue = (issue: GitHubIssueSummary) => { setSelectedIssue(issue as GitHubIssue); setSelectedPr(null); }; const handleSelectPr = (pr: GitHubPullRequestSummary) => { setSelectedPr(pr); setSelectedIssue(null); }; const handleConfirm = () => { if (selectedIssue) { onSelect({ type: 'issue', item: selectedIssue, }); } else if (selectedPr) { onSelect({ type: 'pr', item: selectedPr, includeDiff, }); } onOpenChange(false); }; const handleClear = () => { setSelectedIssue(null); setSelectedPr(null); setIncludeDiff(false); }; // Check if selection is valid const canConfirm = selectedIssue || (selectedPr && validations.get(selectedPr.head ?? '')?.isValid !== false); // Check if PR is blocked const isPrBlocked = (pr: GitHubPullRequestSummary): boolean => { if (!pr.head) return true; const validation = validations.get(pr.head); return validation?.isValid === false; }; // Content for the dialog (shared between mobile and desktop) const dialogContent = ( <> {!isGitHubConnected ? (

{t('session.githubIntegration.connect.title')}

{t('session.githubIntegration.connect.description')}

) : ( <> {/* Search */}
setSearchQuery(e.target.value)} placeholder={activeTab === 'issues' ? t('session.githubIntegration.search.issuesPlaceholder') : t('session.githubIntegration.search.prsPlaceholder')} className="h-8 pl-9" />
{/* List Content */}
{/* Loading */} {loading && (
)} {/* Error */} {error && (
{error}
)} {/* Issues List */} {!loading && !error && activeTab === 'issues' && (
{issues.length > 0 ? ( issues.map(issue => ( )) ) : (
{t('session.githubIntegration.empty.noIssuesFound')}
)} {hasMore && !loadingMore && (
)} {loadingMore && (
)}
)} {/* PRs List */} {!loading && !error && activeTab === 'prs' && (
{prs.length > 0 ? ( prs.map(pr => { const blocked = isPrBlocked(pr); const validation = pr.head ? validations.get(pr.head) : undefined; return ( ); }) ) : (
{t('session.githubIntegration.empty.noPullRequestsFound')}
)} {hasMore && !loadingMore && (
)} {loadingMore && (
)}
)}
)} ); // Footer content const footerContent = (
{/* Left side: Selected Item / Checkbox */}
{/* Selected Issue/PR display - hidden on mobile (shown in header instead) */} {!isMobile && (selectedIssue || selectedPr) && (
{selectedIssue ? t('session.githubIntegration.selected.issueNumber', { number: selectedIssue.number }) : t('session.githubIntegration.selected.prNumber', { number: selectedPr?.number ?? '' })}
)} {/* Include Diff Checkbox - only show when PR tab is active and PR is selected */} {activeTab === 'prs' && selectedPr && ( )}
{/* Right side: Buttons */}
); return ( <> {isMobile ? ( onOpenChange(false)} footer={!isGitHubConnected ? undefined : footerContent} renderHeader={(closeButton) => (

{t('session.githubIntegration.title')}

{closeButton}
{/* Tabs - using SortableTabsStrip */}
}, { id: 'prs', label: t('session.githubIntegration.tabs.pullRequests'), icon: }, ]} activeId={activeTab} onSelect={(id) => { setActiveTab(id as GitHubTab); setSearchQuery(''); }} variant="active-pill" layoutMode="fit" />
{/* Selected Item Inline Display */} {(selectedIssue || selectedPr) && (
{selectedIssue ? t('session.githubIntegration.selected.issueNumber', { number: selectedIssue.number }) : t('session.githubIntegration.selected.prNumber', { number: selectedPr?.number ?? '' })}
)}
)} > {dialogContent}
) : (
{t('session.githubIntegration.title')} {/* Tabs - using SortableTabsStrip */}
}, { id: 'prs', label: t('session.githubIntegration.tabs.pullRequests'), icon: }, ]} activeId={activeTab} onSelect={(id) => { setActiveTab(id as GitHubTab); setSearchQuery(''); }} variant="active-pill" layoutMode="fit" />
{dialogContent} {/* Footer */} {footerContent}
)} ); }