diff --git a/app/(main)/pots/components/PotSourcesPanel.tsx b/app/(main)/pots/components/PotSourcesPanel.tsx index 5a222771..6c8e3404 100644 --- a/app/(main)/pots/components/PotSourcesPanel.tsx +++ b/app/(main)/pots/components/PotSourcesPanel.tsx @@ -1,9 +1,11 @@ "use client"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { + FolderOpen, GitBranch, + Loader2, Pause, Play, Plus, @@ -11,6 +13,7 @@ import { Search, Trash2, Users, + X, } from "lucide-react"; function GithubIcon({ className }: { className?: string }) { @@ -38,6 +41,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, @@ -104,13 +108,18 @@ const SOURCE_TYPES: SourceTypeOption[] = [ }, ]; -function extractRepoIdentity(repo: GithubRepo): { +const GITHUB_REPO_PAGE_SIZE = 20; +const GITHUB_PILLS_MAX_HEIGHT_CLASS = "max-h-32"; + +type GithubRepoIdentity = { owner: string; repo: string; default_branch?: string; external_repo_id?: string; remote_url?: string; -} | null { +}; + +function extractRepoIdentity(repo: GithubRepo): GithubRepoIdentity | null { const fullName = repo.full_name; let owner = ""; let name = ""; @@ -133,6 +142,10 @@ function extractRepoIdentity(repo: GithubRepo): { }; } +function githubRepoSelectionKey(identity: GithubRepoIdentity): string { + return `gh:${identity.owner.toLowerCase()}/${identity.repo.toLowerCase()}`; +} + function describeSource(s: PotSource): { title: string; subtitle: string | null; url: string | null; avatarUrl: string | null } { if (s.source_kind === "repository" && s.provider === "github") { const owner = (s.scope.owner as string | undefined) || ""; @@ -199,7 +212,17 @@ export default function PotSourcesPanel({ potId, isOwner, onPrimaryRepoChanged } const [githubHasMore, setGithubHasMore] = useState(false); const [githubOffset, setGithubOffset] = useState(0); const [githubSearch, setGithubSearch] = useState(""); - const [attachingRepoKey, setAttachingRepoKey] = useState(null); + const githubSearchDebounceRef = useRef(null); + const githubRequestIdRef = useRef(0); + const [selectedGithubRepos, setSelectedGithubRepos] = useState< + Record + >({}); + const [addingGithubRepos, setAddingGithubRepos] = useState(false); + const [selectingAllGithubRepos, setSelectingAllGithubRepos] = useState(false); + const [githubPillsVisibleCount, setGithubPillsVisibleCount] = useState( + null, + ); + const githubPillsMeasureRef = useRef(null); // Linear picker state const [linearIntegrationId, setLinearIntegrationId] = useState(null); @@ -263,13 +286,193 @@ export default function PotSourcesPanel({ potId, isOwner, onPrimaryRepoChanged } return set; }, [sources]); + const selectedGithubCount = useMemo( + () => Object.keys(selectedGithubRepos).length, + [selectedGithubRepos], + ); + + const selectedGithubRepoPills = useMemo( + () => + Object.entries(selectedGithubRepos) + .map(([selectionKey, identity]) => ({ + selectionKey, + label: `${identity.owner}/${identity.repo}`, + })) + .sort((a, b) => a.label.localeCompare(b.label)), + [selectedGithubRepos], + ); + + const hiddenGithubPillCount = Math.max( + 0, + selectedGithubRepoPills.length - (githubPillsVisibleCount ?? selectedGithubRepoPills.length), + ); + + const visibleGithubRepoPills = selectedGithubRepoPills.slice( + 0, + githubPillsVisibleCount ?? selectedGithubRepoPills.length, + ); + + useLayoutEffect(() => { + if (selectedGithubRepoPills.length === 0) { + setGithubPillsVisibleCount(0); + return; + } + + const container = githubPillsMeasureRef.current; + if (!container) { + setGithubPillsVisibleCount(selectedGithubRepoPills.length); + return; + } + + const pillNodes = container.querySelectorAll("[data-github-pill]"); + const total = pillNodes.length; + if (total === 0) { + setGithubPillsVisibleCount(0); + return; + } + + const maxBottom = container.getBoundingClientRect().bottom; + let visible = total; + for (let i = 0; i < total; i += 1) { + const pillBottom = pillNodes[i].getBoundingClientRect().bottom; + if (pillBottom > maxBottom + 1) { + visible = i; + break; + } + } + + const hidden = total - visible; + if (hidden > 0) { + visible = Math.max(1, visible - 1); + } + + setGithubPillsVisibleCount(visible); + }, [selectedGithubRepoPills]); + + useEffect(() => { + if (selectedGithubRepoPills.length === 0) { + setGithubPillsVisibleCount(null); + } + }, [selectedGithubRepoPills.length]); + + const selectableGithubRepos = useMemo(() => { + const selectable: Record = {}; + githubRepos.forEach((repo) => { + const identity = extractRepoIdentity(repo); + if (!identity) return; + const selectionKey = githubRepoSelectionKey(identity); + if (attachedScopeKeys.has(selectionKey)) return; + selectable[selectionKey] = identity; + }); + return selectable; + }, [githubRepos, attachedScopeKeys]); + + const selectableGithubCount = Object.keys(selectableGithubRepos).length; + + const allLoadedGithubReposSelected = useMemo(() => { + if (selectableGithubCount === 0) return false; + return Object.keys(selectableGithubRepos).every( + (selectionKey) => selectedGithubRepos[selectionKey], + ); + }, [selectableGithubRepos, selectableGithubCount, selectedGithubRepos]); + + const canDeselectAllGithubRepos = + allLoadedGithubReposSelected && !githubHasMore && selectableGithubCount > 0; + + const displayedGithubRepos = useMemo( + () => + githubRepos.filter((repo) => { + const identity = extractRepoIdentity(repo); + if (!identity) return false; + return !attachedScopeKeys.has(githubRepoSelectionKey(identity)); + }), + [githubRepos, attachedScopeKeys], + ); + + const toggleSelectAllGithubRepos = async () => { + if (selectingAllGithubRepos || addingGithubRepos) return; + + if (canDeselectAllGithubRepos) { + setSelectedGithubRepos((prev) => { + const next = { ...prev }; + Object.keys(selectableGithubRepos).forEach((selectionKey) => { + delete next[selectionKey]; + }); + return next; + }); + return; + } + + if (selectableGithubCount === 0 && !githubHasMore) return; + + setSelectingAllGithubRepos(true); + try { + const trimmedSearch = githubSearch.trim().slice(0, 200); + let offset = 0; + let allRepos: GithubRepo[] = []; + let hasMore = true; + + while (hasMore) { + const page = await BranchAndRepositoryService.searchUserRepositories({ + search: trimmedSearch, + limit: GITHUB_REPO_PAGE_SIZE, + offset, + }); + allRepos = allRepos.concat(page.repositories); + hasMore = page.has_next_page; + offset += page.repositories.length; + if (page.repositories.length === 0) break; + } + + setGithubRepos(allRepos); + setGithubOffset(allRepos.length); + setGithubHasMore(false); + + const selectable: Record = {}; + allRepos.forEach((repo) => { + const identity = extractRepoIdentity(repo); + if (!identity) return; + const selectionKey = githubRepoSelectionKey(identity); + if (attachedScopeKeys.has(selectionKey)) return; + selectable[selectionKey] = identity; + }); + setSelectedGithubRepos(selectable); + } catch { + toast.error("Failed to load all repositories"); + } finally { + setSelectingAllGithubRepos(false); + } + }; + // ---- Picker open/close + per-type bootstrapping -------------------- + const clearGithubSearchDebounce = () => { + if (githubSearchDebounceRef.current) { + clearTimeout(githubSearchDebounceRef.current); + githubSearchDebounceRef.current = null; + } + }; + + const resetGithubPicker = () => { + clearGithubSearchDebounce(); + githubRequestIdRef.current += 1; + setGithubSearch(""); + setGithubRepos([]); + setGithubOffset(0); + setGithubHasMore(false); + setGithubLoading(false); + setSelectedGithubRepos({}); + setAddingGithubRepos(false); + setSelectingAllGithubRepos(false); + setGithubPillsVisibleCount(null); + }; + const openPicker = (typeId: SourceTypeId) => { setPickerType(typeId); setPickerOpen(true); if (typeId === "github_repository") { - void loadGithubPage(true); + resetGithubPicker(); + void loadGithubPage(true, ""); } else if (typeId === "linear_team") { // Auto-select the only Linear integration if there's exactly one. const id = @@ -285,52 +488,146 @@ export default function PotSourcesPanel({ potId, isOwner, onPrimaryRepoChanged } const closePicker = () => { setPickerOpen(false); setPickerType(null); - setGithubSearch(""); - setGithubRepos([]); - setGithubOffset(0); - setGithubHasMore(false); + resetGithubPicker(); setLinearTeams([]); setLinearIntegrationId(null); }; + const backToTypePicker = () => { + if (pickerType === "github_repository") { + resetGithubPicker(); + } + setPickerType(null); + }; + + useEffect(() => { + return () => { + if (githubSearchDebounceRef.current) { + clearTimeout(githubSearchDebounceRef.current); + } + githubRequestIdRef.current += 1; + }; + }, []); + // ---- GitHub picker ------------------------------------------------- - const loadGithubPage = async (reset: boolean) => { + const loadGithubPage = async (reset: boolean, searchTerm = githubSearch) => { + const requestId = githubRequestIdRef.current + 1; + githubRequestIdRef.current = requestId; + const trimmedSearch = searchTerm.trim().slice(0, 200); setGithubLoading(true); try { const offset = reset ? 0 : githubOffset; const page = await BranchAndRepositoryService.searchUserRepositories({ - search: githubSearch, - limit: 20, + search: trimmedSearch, + limit: GITHUB_REPO_PAGE_SIZE, offset, }); + if (githubRequestIdRef.current !== requestId) return; setGithubRepos((prev) => (reset ? page.repositories : [...prev, ...page.repositories])); setGithubHasMore(page.has_next_page); setGithubOffset(offset + page.repositories.length); } catch { - toast.error("Failed to fetch GitHub repositories"); + if (githubRequestIdRef.current === requestId) { + toast.error("Failed to fetch GitHub repositories"); + } } finally { - setGithubLoading(false); + if (githubRequestIdRef.current === requestId) { + setGithubLoading(false); + } } }; - const handleAttachGithub = async (repo: GithubRepo) => { + const searchGithubRepos = (value: string) => { + if (value.length > 200) return; + setGithubSearch(value); + clearGithubSearchDebounce(); + githubRequestIdRef.current += 1; + setGithubLoading(true); + githubSearchDebounceRef.current = setTimeout(() => { + void loadGithubPage(true, value.trim()); + }, 350); + }; + + const searchGithubReposNow = () => { + clearGithubSearchDebounce(); + void loadGithubPage(true, githubSearch.trim()); + }; + + const clearGithubSearch = () => { + clearGithubSearchDebounce(); + setGithubSearch(""); + void loadGithubPage(true, ""); + }; + + const toggleGithubRepoSelection = (repo: GithubRepo) => { const identity = extractRepoIdentity(repo); - if (!identity) { - toast.error("Could not read repository identity."); - return; - } - const key = `${identity.owner}/${identity.repo}`; - setAttachingRepoKey(key); + if (!identity) return; + const selectionKey = githubRepoSelectionKey(identity); + if (attachedScopeKeys.has(selectionKey)) return; + + setSelectedGithubRepos((prev) => { + if (prev[selectionKey]) { + const next = { ...prev }; + delete next[selectionKey]; + return next; + } + return { ...prev, [selectionKey]: identity }; + }); + }; + + const removeSelectedGithubRepo = (selectionKey: string) => { + setSelectedGithubRepos((prev) => { + if (!prev[selectionKey]) return prev; + const next = { ...prev }; + delete next[selectionKey]; + return next; + }); + }; + + const handleAddSelectedGithubRepos = async () => { + const identities = Object.values(selectedGithubRepos); + if (identities.length === 0) return; + + setAddingGithubRepos(true); try { - await PotService.addGithubRepositorySource(potId, identity); - toast.success(`Attached ${key}`); - await refresh(); - onPrimaryRepoChanged(); - } catch (e) { - toast.error(e instanceof Error ? e.message : "Failed to attach repository"); + const results = await Promise.allSettled( + identities.map((identity) => + PotService.addGithubRepositorySource(potId, identity), + ), + ); + const succeeded = results.filter((result) => result.status === "fulfilled").length; + const failed = results.length - succeeded; + + if (succeeded > 0) { + await refresh(); + onPrimaryRepoChanged(); + } + + if (failed === 0) { + toast.success( + succeeded === 1 + ? `Added ${identities[0].owner}/${identities[0].repo}` + : `Added ${succeeded} repositories`, + ); + setSelectedGithubRepos({}); + closePicker(); + } else if (succeeded > 0) { + toast.error(`Added ${succeeded} of ${results.length} repositories. ${failed} failed.`); + setSelectedGithubRepos((prev) => { + const next = { ...prev }; + results.forEach((result, index) => { + if (result.status === "fulfilled") { + delete next[githubRepoSelectionKey(identities[index])]; + } + }); + return next; + }); + } else { + toast.error("Failed to add selected repositories"); + } } finally { - setAttachingRepoKey(null); + setAddingGithubRepos(false); } }; @@ -628,76 +925,196 @@ export default function PotSourcesPanel({ potId, isOwner, onPrimaryRepoChanged } ) : pickerType === "github_repository" ? (
-
- - setGithubSearch(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") void loadGithubPage(true); - }} - /> - -
-
- {githubRepos.length === 0 && !githubLoading ? ( -

- No matching repositories. -

- ) : ( - githubRepos.map((repo, idx) => { - const identity = extractRepoIdentity(repo); - if (!identity) return null; - const key = `${identity.owner}/${identity.repo}`; - const alreadyAttached = attachedScopeKeys.has(`gh:${key.toLowerCase()}`); - return ( -
-
-

{key}

- {identity.default_branch ? ( -

- default: {identity.default_branch} -

- ) : null} -
- + ) : null} + {selectingAllGithubRepos ? ( + + Selecting... + + ) : !githubLoading && + (selectableGithubCount > 0 || githubHasMore) ? ( + + ) : null} +
+ {selectedGithubRepoPills.length > 0 ? ( +
+
+ {selectedGithubRepoPills.map(({ selectionKey, label }) => ( + - {alreadyAttached - ? "Attached" - : attachingRepoKey === key - ? "Adding…" - : "Attach"} - -
- ); - }) - )} - {githubLoading ? ( -

Loading…

+ {label} + + + ))} +
+
+ {visibleGithubRepoPills.map(({ selectionKey, label }) => ( + + {label} + + + ))} + {hiddenGithubPillCount > 0 ? ( + + + {hiddenGithubPillCount} more + + ) : null} +
+
) : null} -
- {githubHasMore ? ( -
- +
+ {githubLoading || selectingAllGithubRepos ? ( +
+ +

+ {selectingAllGithubRepos + ? "Loading all repositories..." + : githubSearch.trim() + ? "Searching repositories..." + : "Loading repositories..."} +

+
+ ) : displayedGithubRepos.length === 0 ? ( +
+ +

+ {githubRepos.length > 0 + ? "All matching repositories are already attached" + : githubSearch.trim() + ? `No repositories found matching '${githubSearch.trim()}'` + : "No parsed repositories found"} +

+

+ {githubRepos.length > 0 + ? "Try a different search term" + : githubSearch.trim() + ? "Try a different search term" + : "Parse a repository to get started"} +

+
+ ) : ( +
+ {displayedGithubRepos.map((repo, idx) => { + const identity = extractRepoIdentity(repo); + if (!identity) return null; + const key = `${identity.owner}/${identity.repo}`; + const selectionKey = githubRepoSelectionKey(identity); + const isSelected = Boolean(selectedGithubRepos[selectionKey]); + return ( +
{ + if (!addingGithubRepos) { + toggleGithubRepoSelection(repo); + } + }} + onKeyDown={(e) => { + if ( + !addingGithubRepos && + (e.key === "Enter" || e.key === " ") + ) { + e.preventDefault(); + toggleGithubRepoSelection(repo); + } + }} + className={`flex items-center gap-2.5 rounded-lg px-2.5 py-2 transition-colors cursor-pointer ${ + isSelected + ? "bg-zinc-50 border border-zinc-200" + : "hover:bg-zinc-50" + }`} + > + toggleGithubRepoSelection(repo)} + onClick={(e) => e.stopPropagation()} + className="h-3.5 w-3.5 shrink-0 flex items-center justify-center rounded-[3px] [&>span]:flex [&>span]:h-full [&>span]:w-full [&>span]:items-center [&>span]:justify-center [&_svg]:h-2.5 [&_svg]:w-2.5" + /> +
+

{key}

+ {identity.default_branch ? ( +

+ default: {identity.default_branch} +

+ ) : null} +
+
+ ); + })} +
+ )}
- ) : null} + {githubHasMore ? ( +
+ +
+ ) : null} +
) : pickerType === "linear_team" ? (
@@ -769,13 +1186,26 @@ export default function PotSourcesPanel({ potId, isOwner, onPrimaryRepoChanged } {pickerType !== null ? ( - ) : null} - + {pickerType === "github_repository" ? ( + + ) : ( + + )}