"use client"; import Link from "@/components/routing/app-link"; import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react"; import { IconBrandGithub, IconMenu2 } from "@tabler/icons-react"; import { Alert, AlertDescription } from "@kandev/ui/alert"; import { Button } from "@kandev/ui/button"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@kandev/ui/sheet"; import { PageTopbar } from "@/components/page-topbar"; import { useGitHubStatus } from "@/hooks/domains/github/use-github-status"; import { usePRKeyToTasks } from "@/hooks/domains/github/use-pr-key-to-tasks"; import { useIssueKeyToTasks } from "@/hooks/domains/github/use-issue-key-to-tasks"; import type { Repository, Workflow, WorkflowStep } from "@/lib/types/http"; import type { GitHubIssue, GitHubPR, TaskIssueLink, TaskPR } from "@/lib/types/github"; import { PRList } from "@/components/github/my-github/pr-list"; import { IssueList } from "@/components/github/my-github/issue-list"; import { PresetsSidebar, type SidebarSelection, } from "@/components/github/my-github/presets-sidebar"; import { PresetsScopeBar } from "@/components/github/my-github/presets-scope-bar"; import { PR_PRESETS, ISSUE_PRESETS, type PresetOption, } from "@/components/github/my-github/search-bar"; import { useGitHubSearch } from "@/components/github/my-github/use-github-search"; import { useDefaultQueryPresets, resolvePresetOptions, } from "@/components/github/my-github/use-default-query-presets"; import type { SavedPreset } from "@/components/github/my-github/use-saved-presets"; import { useSavedPresetActions } from "@/components/github/my-github/use-saved-preset-actions"; import { useKnownRepos, resetKnownReposStore } from "@/components/github/my-github/use-known-repos"; import { useCommittedQuery } from "@/components/github/my-github/use-committed-query"; import { ListToolbar } from "@/components/github/my-github/list-toolbar"; import { ResultsPagination } from "@/components/github/my-github/results-pagination"; import { SavePresetDialog } from "@/components/github/my-github/save-preset-dialog"; import { QuickTaskLauncher, type LaunchPayload, type TaskPreset, } from "@/components/github/my-github/quick-task-launcher"; import { resolvePRPresets, resolveIssuePresets, } from "@/components/github/my-github/action-presets"; import { useGitHubActionPresets } from "@/hooks/domains/github/use-github-action-presets"; import { useAllWorkflowSnapshots } from "@/hooks/domains/kanban/use-all-workflow-snapshots"; type GitHubPageClientProps = { workspaceId?: string; workflows: Workflow[]; steps: WorkflowStep[]; repositories: Repository[]; }; function PageHeader({ onOpenMobileSidebar }: { onOpenMobileSidebar?: () => void }) { return ( } actions={ onOpenMobileSidebar && ( ) } /> ); } function NotAuthenticatedNotice() { return ( GitHub is not connected. Configure GitHub authentication (gh CLI or a Personal Access Token) in{" "} Settings → GitHub {" "} to see your pull requests and issues. ); } function NoWorkspaceNotice() { return (
No workspace configured. Create a workspace first to start tasks from PRs/issues.
); } function resolveTitle( selection: SidebarSelection, saved: SavedPreset[], prPresets: PresetOption[], issuePresets: PresetOption[], ): string { if (selection.source === "saved") { return saved.find((p) => p.id === selection.id)?.label ?? "Saved query"; } const presets = selection.kind === "pr" ? prPresets : issuePresets; return ( presets.find((p) => p.value === selection.id)?.label ?? (selection.kind === "pr" ? "Pull requests" : "Issues") ); } function ResultsList({ selection, items, loading, error, prPresets, issuePresets, onStartTask, prKeyToTasks, issueKeyToTasks, }: { selection: SidebarSelection; items: Array; loading: boolean; error: string | null; prPresets: TaskPreset[]; issuePresets: TaskPreset[]; onStartTask: (payload: LaunchPayload) => void; prKeyToTasks: Map; issueKeyToTasks: Map; }) { if (selection.kind === "pr") { return ( ); } return ( ); } type GitHubPageState = ReturnType; function useRepoOptions( workspaceId: string | null, selection: SidebarSelection, committedQuery: string, items: Array, repoFilter: string, ): string[] { const pageRepos = useMemo( () => items .filter((it) => it.repo_owner && it.repo_name) .map((it) => `${it.repo_owner}/${it.repo_name}`), [items], ); // Reset the accumulator whenever the query context changes (preset, saved, // custom query). Repo filter is deliberately excluded so narrowing doesn't // reset — that's the whole point of the accumulator. const reposResetKey = `${workspaceId ?? "global"}:${selection.kind}:${selection.source}:${selection.id}:${committedQuery.trim()}`; const knownRepos = useKnownRepos(reposResetKey, pageRepos); return useMemo(() => { const set = new Set(knownRepos); if (repoFilter) set.add(repoFilter); return Array.from(set).sort(); }, [knownRepos, repoFilter]); } function useResolvedQueryPresets(workspaceId: string | null = null) { const { prPresets: storedPr, issuePresets: storedIssue } = useDefaultQueryPresets(workspaceId); const pr = useMemo(() => resolvePresetOptions(storedPr, PR_PRESETS), [storedPr]); const issue = useMemo(() => resolvePresetOptions(storedIssue, ISSUE_PRESETS), [storedIssue]); return { pr, issue }; } function useInitialSidebarSelection( workspaceId: string | null, resolvedPrPresets: PresetOption[], autoResetSearchRef: MutableRefObject, setQueryImmediate: (query: string) => void, setRepoFilter: (repo: string) => void, ) { const userSelectedRef = useRef(false); const [selection, setSelection] = useState(() => ({ kind: "pr", source: "preset", id: resolvedPrPresets[0]?.value ?? "", })); useEffect(() => { userSelectedRef.current = false; autoResetSearchRef.current = true; }, [workspaceId]); useEffect(() => { if (userSelectedRef.current || !autoResetSearchRef.current) return; const first = resolvedPrPresets[0]; setSelection({ kind: "pr", source: "preset", id: first?.value ?? "" }); setQueryImmediate(first?.filter ?? ""); setRepoFilter(""); }, [workspaceId, resolvedPrPresets, autoResetSearchRef, setQueryImmediate, setRepoFilter]); const setUserSelection = useCallback((next: SidebarSelection) => { userSelectedRef.current = true; setSelection(next); }, []); return { selection, setProgrammaticSelection: setSelection, setUserSelection }; } function useSidebarSelectionHandler({ savedPresets, resolvedPrPresets, resolvedIssuePresets, setQueryImmediate, setRepoFilter, setUserSelection, markSearchInteracted, }: { savedPresets: SavedPreset[]; resolvedPrPresets: PresetOption[]; resolvedIssuePresets: PresetOption[]; setQueryImmediate: (query: string) => void; setRepoFilter: (repo: string) => void; setUserSelection: (next: SidebarSelection) => void; markSearchInteracted: () => void; }) { return useCallback( (s: SidebarSelection) => { markSearchInteracted(); setUserSelection(s); if (s.source === "saved") { const found = savedPresets.find((p) => p.id === s.id); setQueryImmediate(found?.customQuery ?? ""); setRepoFilter(found?.repoFilter ?? ""); return; } const preset = (s.kind === "pr" ? resolvedPrPresets : resolvedIssuePresets).find( (p) => p.value === s.id, ); setQueryImmediate(preset?.filter ?? ""); setRepoFilter(""); }, [ savedPresets, setQueryImmediate, resolvedPrPresets, resolvedIssuePresets, setUserSelection, markSearchInteracted, ], ); } function useSearchInteractionControls( setCustomQueryRaw: (query: string) => void, setRepoFilterRaw: (repo: string) => void, ) { const autoResetSearchRef = useRef(true); const markSearchInteracted = useCallback(() => { autoResetSearchRef.current = false; }, []); const setCustomQuery = useCallback( (query: string) => { markSearchInteracted(); setCustomQueryRaw(query); }, [markSearchInteracted, setCustomQueryRaw], ); const setRepoFilter = useCallback( (repo: string) => { markSearchInteracted(); setRepoFilterRaw(repo); }, [markSearchInteracted, setRepoFilterRaw], ); return { autoResetSearchRef, markSearchInteracted, setCustomQuery, setRepoFilter }; } function useGitHubPageState(workspaceId: string | null) { const { pr: resolvedPrPresets, issue: resolvedIssuePresets } = useResolvedQueryPresets(workspaceId); const { draft: customQuery, committed: committedQuery, setDraft: setCustomQueryRaw, setImmediate: setQueryImmediate, commit: commitCustomQuery, } = useCommittedQuery(resolvedPrPresets[0]?.filter ?? ""); const [repoFilter, setRepoFilterRaw] = useState(""); const [saveDialogOpen, setSaveDialogOpen] = useState(false); const { autoResetSearchRef, markSearchInteracted, setCustomQuery, setRepoFilter } = useSearchInteractionControls(setCustomQueryRaw, setRepoFilterRaw); const { selection, setProgrammaticSelection, setUserSelection } = useInitialSidebarSelection( workspaceId, resolvedPrPresets, autoResetSearchRef, setQueryImmediate, setRepoFilterRaw, ); const { savedPresets, onConfirmSave, onDeleteSaved } = useSavedPresetActions({ workspaceId, selection, customQuery, resolvedPrPresets, resolvedIssuePresets, setProgrammaticSelection, setQueryImmediate, setRepoFilter: setRepoFilterRaw, }); const presets = selection.kind === "pr" ? resolvedPrPresets : resolvedIssuePresets; const search = useGitHubSearch({ kind: selection.kind, presets, preset: selection.source === "preset" ? selection.id : "", customQuery: committedQuery, repoFilter, workspaceId, }); const repoOptions = useRepoOptions( workspaceId, selection, committedQuery, search.items, repoFilter, ); const title = useMemo( () => resolveTitle(selection, savedPresets, resolvedPrPresets, resolvedIssuePresets), [selection, savedPresets, resolvedPrPresets, resolvedIssuePresets], ); const onSelect = useSidebarSelectionHandler({ savedPresets, resolvedPrPresets, resolvedIssuePresets, setQueryImmediate, setRepoFilter: setRepoFilterRaw, setUserSelection, markSearchInteracted, }); const canSaveCurrent = customQuery.trim().length > 0 || repoFilter.length > 0; const suggestedLabel = customQuery.trim() || (repoFilter ? `In ${repoFilter}` : "Saved query"); const onOpenSaveDialog = () => canSaveCurrent && setSaveDialogOpen(true); return { selection, customQuery, committedQuery, setCustomQuery, commitCustomQuery, repoFilter, setRepoFilter, savedPresets, search, repoOptions, title, onSelect, canSaveCurrent, suggestedLabel, saveDialogOpen, setSaveDialogOpen, onOpenSaveDialog, onConfirmSave, onDeleteSaved, resolvedPrPresets, resolvedIssuePresets, }; } function AuthenticatedLayout({ workspaceId, state, prPresets, issuePresets, onStartTask, }: { workspaceId: string | undefined; state: GitHubPageState; prPresets: TaskPreset[]; issuePresets: TaskPreset[]; onStartTask: (payload: LaunchPayload) => void; }) { const { selection, search, repoOptions, title } = state; const prKeyToTasks = usePRKeyToTasks(workspaceId ?? null); const issueKeyToTasks = useIssueKeyToTasks(workspaceId ?? null); useAllWorkflowSnapshots(workspaceId ?? null); return (
{!workspaceId && }
); } export function GitHubPageClient({ workspaceId, workflows, steps, repositories, }: GitHubPageClientProps) { const { status, loaded } = useGitHubStatus(); const [launchPayload, setLaunchPayload] = useState(null); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const state = useGitHubPageState(workspaceId ?? null); const { presets: storedPresets } = useGitHubActionPresets(workspaceId ?? null); const prPresets = useMemo(() => resolvePRPresets(storedPresets), [storedPresets]); const issuePresets = useMemo(() => resolveIssuePresets(storedPresets), [storedPresets]); // Drop the module-level repo accumulator on page unmount so a later visit // doesn't inherit a stale set from the previous navigation. useEffect(() => resetKnownReposStore, []); const onStartTask = useCallback((payload: LaunchPayload) => setLaunchPayload(payload), []); const onCloseLaunch = useCallback(() => setLaunchPayload(null), []); const authed = !!status?.authenticated; const onOpenMobileSidebar = useCallback(() => setMobileSidebarOpen(true), []); // Close the mobile sheet after any sidebar selection. KindToggle clicks also // route through onSelect — closing on every selection is acceptable UX since // the user always wants to see the list after picking a kind or preset. // No useCallback: `state` is a fresh object every render, so memoizing // these handlers would be deceptive — they'd still be new refs each pass. const onMobileSidebarSelect = (s: Parameters[0]) => { state.onSelect(s); setMobileSidebarOpen(false); }; const onMobileSaveCurrent = () => { setMobileSidebarOpen(false); state.onOpenSaveDialog(); }; return (
{!loaded &&
Checking GitHub status…
} {loaded && !authed && (
)} {loaded && authed && ( )} Filters
); }