import { useState, useEffect, useCallback, useRef } from "react"; import type { Task } from "@kb/core"; import { apiFetchGitHubIssues, apiImportGitHubIssue, fetchGitRemotes, type GitHubIssue, type GitRemote } from "../api"; import { Loader2, RefreshCw, ArrowLeft } from "lucide-react"; interface GitHubImportModalProps { isOpen: boolean; onClose: () => void; onImport: (task: Task) => void; tasks: Task[]; } // Mobile breakpoint in pixels const MOBILE_BREAKPOINT = 640; export function GitHubImportModal({ isOpen, onClose, onImport, tasks }: GitHubImportModalProps) { const [owner, setOwner] = useState(""); const [repo, setRepo] = useState(""); const [labels, setLabels] = useState(""); const [loading, setLoading] = useState(false); const [issues, setIssues] = useState([]); const [selectedIssueNumber, setSelectedIssueNumber] = useState(null); const [error, setError] = useState(null); const [importing, setImporting] = useState(false); // Git remotes state const [remotes, setRemotes] = useState([]); const [loadingRemotes, setLoadingRemotes] = useState(false); const [selectedRemoteName, setSelectedRemoteName] = useState(""); const mountedRef = useRef(false); // Mobile view state const [isMobile, setIsMobile] = useState(false); const [mobileView, setMobileView] = useState<'list' | 'preview'>('list'); // Track which owner/repo we've already auto-loaded to prevent duplicate loads const autoLoadedRef = useRef<{ owner: string; repo: string; labels: string } | null>(null); // Build set of already imported URLs from existing tasks const importedUrls = new Set(); for (const task of tasks) { const match = task.description.match(/Source: (https:\/\/github\.com\/[^/]+\/[^/]+\/issues\/\d+)/); if (match) { importedUrls.add(match[1]); } } // Reset state when modal opens and fetch remotes useEffect(() => { if (isOpen) { setOwner(""); setRepo(""); setLabels(""); setIssues([]); setSelectedIssueNumber(null); setError(null); setImporting(false); setRemotes([]); setLoadingRemotes(true); setSelectedRemoteName(""); autoLoadedRef.current = null; mountedRef.current = true; // Fetch git remotes fetchGitRemotes() .then((fetchedRemotes) => { if (!mountedRef.current) return; setRemotes(fetchedRemotes); setLoadingRemotes(false); if (fetchedRemotes.length === 1) { // Single remote: auto-select it const remote = fetchedRemotes[0]; setOwner(remote.owner); setRepo(remote.repo); setSelectedRemoteName(remote.name); } else if (fetchedRemotes.length > 1) { // Multiple remotes: don't auto-select, user must choose setOwner(""); setRepo(""); setSelectedRemoteName(""); } // If no remotes, owner/repo remain empty }) .catch(() => { if (mountedRef.current) { setLoadingRemotes(false); } }); return () => { mountedRef.current = false; }; } }, [isOpen]); // Handle remote selection change const handleRemoteChange = useCallback((remoteName: string) => { setSelectedRemoteName(remoteName); if (remoteName === "") { setOwner(""); setRepo(""); } else { const remote = remotes.find((r) => r.name === remoteName); if (remote) { setOwner(remote.owner); setRepo(remote.repo); } } }, [remotes]); // Handle load issues - defined BEFORE the auto-load useEffect const handleLoad = useCallback(async () => { if (!owner.trim() || !repo.trim()) { setError("Repository must be selected"); return; } setLoading(true); setError(null); setIssues([]); setSelectedIssueNumber(null); try { const labelArray = labels .split(",") .map((l) => l.trim()) .filter(Boolean); const fetchedIssues = await apiFetchGitHubIssues(owner.trim(), repo.trim(), 30, labelArray.length > 0 ? labelArray : undefined); setIssues(fetchedIssues); if (fetchedIssues.length === 0) { setError("No open issues found"); } } catch (err: any) { setError(err.message || "Failed to fetch issues"); } finally { setLoading(false); } }, [owner, repo, labels]); // Auto-load issues when owner and repo are set and valid useEffect(() => { if (!isOpen) return; if (!owner.trim() || !repo.trim()) return; if (loading || importing) return; // Check if we've already auto-loaded for this exact combination const currentKey = { owner: owner.trim(), repo: repo.trim(), labels: labels.trim() }; if ( autoLoadedRef.current?.owner === currentKey.owner && autoLoadedRef.current?.repo === currentKey.repo && autoLoadedRef.current?.labels === currentKey.labels ) { return; } // Mark as auto-loaded and trigger the load autoLoadedRef.current = currentKey; handleLoad(); }, [owner, repo, labels, isOpen, loading, importing, handleLoad]); // Handle escape key useEffect(() => { if (!isOpen) return; const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; document.addEventListener("keydown", handleKey); return () => document.removeEventListener("keydown", handleKey); }, [isOpen, onClose]); // Detect mobile viewport useEffect(() => { if (!isOpen) return; const checkMobile = () => { setIsMobile(window.innerWidth <= MOBILE_BREAKPOINT); }; // Check initially checkMobile(); // Listen for resize window.addEventListener("resize", checkMobile); return () => window.removeEventListener("resize", checkMobile); }, [isOpen]); // Handle issue selection - switch to preview view on mobile const handleIssueSelect = useCallback((issueNumber: number) => { setSelectedIssueNumber(issueNumber); if (isMobile) { setMobileView('preview'); } }, [isMobile]); // Handle back button - return to list view on mobile const handleBackToList = useCallback(() => { setMobileView('list'); // Optionally clear selection when going back // setSelectedIssueNumber(null); }, []); const handleImport = useCallback(async () => { if (selectedIssueNumber === null) return; setImporting(true); setError(null); try { const task = await apiImportGitHubIssue(owner.trim(), repo.trim(), selectedIssueNumber); onImport(task); onClose(); } catch (err: any) { if (err.message?.includes("already imported")) { setError(err.message); } else { setError(err.message || "Failed to import issue"); } } finally { setImporting(false); } }, [selectedIssueNumber, owner, repo, onImport, onClose]); const selectedIssue = issues.find((i) => i.number === selectedIssueNumber); if (!isOpen) return null; // Determine state flags const hasRemotes = remotes.length > 0; const singleRemote = remotes.length === 1; const multipleRemotes = remotes.length > 1; const importedIssueCount = issues.filter((issue) => importedUrls.has(issue.html_url)).length; const isEmptyState = error === "No open issues found"; const isResultsError = Boolean(error) && !isEmptyState && issues.length === 0 && !loading; const hasResultsContent = loading || issues.length > 0 || isEmptyState || isResultsError; const showInlineErrorBanner = Boolean(error) && issues.length > 0 && !isEmptyState; return (
e.target === e.currentTarget && onClose()}>

Import from GitHub

Choose a detected remote, load open issues, and import one into the board.

{/* Compact Toolbar */}
{/* Left: Remote selector */}
{loadingRemotes ? (
Detecting…
) : !hasRemotes ? ( No remotes ) : singleRemote ? (
{remotes[0].name} {remotes[0].owner}/{remotes[0].repo}
) : (
)}
{/* Center: Labels filter */}
setLabels(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleLoad()} disabled={loading || importing || !hasRemotes} aria-label="Filter issues by labels" />
{/* Right: Load button */}
{/* Warning/Error states below toolbar */} {!loadingRemotes && !hasRemotes && (
No GitHub remotes detected Add a GitHub remote to this repository, then reopen the modal.
git remote add origin https://github.com/owner/repo.git
)} {showInlineErrorBanner && (
{error}
)} {/* Two-pane workspace */}
{/* Left pane: Issue list */}

Issues

{issues.length > 0 && (
{issues.length} issue{issues.length === 1 ? "" : "s"} {importedIssueCount} imported
)}
{!hasResultsContent && (
Nothing loaded yet Select a repository and load issues to start reviewing import candidates.
)} {loading && (
Loading open issues… Fetching the latest issue list from GitHub.
)} {isResultsError && (
Could not load issues {error}
)} {isEmptyState && (
No open issues found Try a different label filter or choose another repository.
)} {issues.length > 0 && (
{issues.map((issue) => { const isImported = importedUrls.has(issue.html_url); return (
!isImported && handleIssueSelect(issue.number)} > handleIssueSelect(issue.number)} disabled={isImported} aria-label={`Select issue #${issue.number}`} />
#{issue.number} {issue.title}
{issue.labels.length > 0 && ( {issue.labels.map((l) => ( {l.name} ))} )}
{isImported && Imported}
); })}
)}
{/* Right pane: Preview */}
{isMobile && ( )}

Preview

{selectedIssue ? (
Issue #{selectedIssue.number}
{selectedIssue.title}
{selectedIssue.body ? selectedIssue.body.slice(0, 200) + (selectedIssue.body.length > 200 ? "…" : "") : "(no description)"}
) : (
No issue selected Choose an issue from the list to inspect its title and description.
)}
); }