"use client"; import Link from "@/components/routing/app-link"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { IconBrandGithub } from "@tabler/icons-react"; import { Alert, AlertDescription } from "@kandev/ui/alert"; import { PageShell } from "@/components/page-shell"; 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 type { SidebarSelection } from "@/components/github/my-github/presets-sidebar"; import { MobileViewsPicker } from "@/components/github/my-github/mobile-views-picker"; import { SavedQueryLoadStatus } from "@/components/github/my-github/saved-query-load-status"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; 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 { useSavedPresets, type SavedPreset } from "@/components/github/my-github/use-saved-presets"; import { useSavedPresetActions } from "@/components/github/my-github/use-saved-preset-actions"; import { useInitialSidebarSelection, useSidebarSelectionHandler, } from "@/components/github/my-github/use-sidebar-selection"; 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"; import { hasGitHubPersonalActor } from "@/lib/github-auth"; import { Trans, useTranslation } from "react-i18next"; type GitHubPageClientProps = { workspaceId?: string; workflows: Workflow[]; steps: WorkflowStep[]; repositories: Repository[]; }; function NotAuthenticatedNotice({ workspaceId, personalRequired, }: { workspaceId?: string; personalRequired: boolean; }) { const { t } = useTranslation(); const settingsHref = workspaceId ? `/settings/workspaces/${workspaceId}/integrations/github` : "/settings/integrations/github"; return ( {personalRequired ? t("github:connectYourPersonalGithubIdentityTo") : t("github:githubIsNotConnectedConfigureWorkspace")}{" "} to see your pull requests and issues. ); } function NoWorkspaceNotice() { const { t } = useTranslation(); return (
{t("github:noWorkspaceConfiguredCreateAWorkspace")}
); } function hasGitHubUserIdentity(status: ReturnType["status"]) { return hasGitHubPersonalActor(status); } function resolveTitle( selection: SidebarSelection, saved: SavedPreset[], prPresets: PresetOption[], issuePresets: PresetOption[], t: (key: string, values?: Record) => string, ): string { if (selection.source === "saved") { return saved.find((p) => p.id === selection.id)?.label ?? t("github:savedQueryFallback"); } const presets = selection.kind === "pr" ? prPresets : issuePresets; return ( presets.find((p) => p.value === selection.id)?.label ?? (selection.kind === "pr" ? t("github:titlePullRequests") : t("github:titleIssues")) ); } function ResultsList({ workspaceId, selection, items, loading, error, prPresets, issuePresets, onStartTask, prKeyToTasks, issueKeyToTasks, }: { workspaceId: string | null; 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 useSearchInteractionControls( setCustomQueryRaw: (query: string) => void, setRepoFilterRaw: (repo: string) => void, ) { const autoResetSearchRef = useRef(true); const shouldAutoResetSearch = useCallback(() => autoResetSearchRef.current, []); const resetSearchOnWorkspaceChange = useCallback(() => { autoResetSearchRef.current = 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 { autoResetSearch: { shouldAutoResetSearch, resetSearchOnWorkspaceChange }, markSearchInteracted, setCustomQuery, setRepoFilter, }; } function useSaveQueryDialog(canSaveCurrent: boolean) { const [saveDialogOpen, setSaveDialogOpen] = useState(false); const saveDialogFocusRef = useRef(null); const onOpenSaveDialog = () => { if (!canSaveCurrent) return; saveDialogFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; setSaveDialogOpen(true); }; return { saveDialogOpen, setSaveDialogOpen, saveDialogFocusRef, onOpenSaveDialog }; } function useGitHubPageState(workspaceId: string | null, enabled: boolean) { const { t } = useTranslation(); 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 savedPresetStore = useSavedPresets(workspaceId); const { autoResetSearch, markSearchInteracted, setCustomQuery, setRepoFilter } = useSearchInteractionControls(setCustomQueryRaw, setRepoFilterRaw); const { selection, setProgrammaticSelection, setUserSelection } = useInitialSidebarSelection({ workspaceId, ...autoResetSearch, resolvedPrPresets, setQueryImmediate, setRepoFilter: setRepoFilterRaw, savedPresets: savedPresetStore.presets, }); const { savedPresets, onConfirmSave, onDeleteSaved, onToggleSavedDefault, defaultMutationPendingId, } = useSavedPresetActions({ workspaceId, selection, customQuery, resolvedPrPresets, resolvedIssuePresets, setProgrammaticSelection, setQueryImmediate, setRepoFilter: setRepoFilterRaw, savedPresetStore, markSearchInteracted, }); const search = useGitHubSearch({ enabled, kind: selection.kind, presets: selection.kind === "pr" ? resolvedPrPresets : resolvedIssuePresets, preset: selection.source === "preset" ? selection.id : "", customQuery: committedQuery, repoFilter, workspaceId, }); const repos = useRepoOptions(workspaceId, selection, committedQuery, search.items, repoFilter); const title = resolveTitle(selection, savedPresets, resolvedPrPresets, resolvedIssuePresets, t); const onSelect = useSidebarSelectionHandler({ currentKind: selection.kind, savedPresets, resolvedPrPresets, resolvedIssuePresets, setQueryImmediate, setRepoFilter: setRepoFilterRaw, setUserSelection, markSearchInteracted, }); const canSaveCurrent = !savedPresetStore.loading && !savedPresetStore.error && (customQuery.trim().length > 0 || repoFilter.length > 0); // i18n-exempt: persisted as the saved query's name, so it must not depend on the creating locale. const suggestedLabel = customQuery.trim() || (repoFilter ? `In ${repoFilter}` : "Saved query"); const saveDialog = useSaveQueryDialog(canSaveCurrent); return { selection, customQuery, committedQuery, setCustomQuery, commitCustomQuery, repoFilter, setRepoFilter, savedPresets, savedQueryLoad: { loading: savedPresetStore.loading, error: savedPresetStore.error, retry: savedPresetStore.retry, }, search, repoOptions: repos, title, onSelect, canSaveCurrent, suggestedLabel, ...saveDialog, onConfirmSave, onDeleteSaved, onToggleSavedDefault, defaultMutationPendingId, 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 { isMobile } = useResponsiveBreakpoint(); const prKeyToTasks = usePRKeyToTasks(workspaceId ?? null); const issueKeyToTasks = useIssueKeyToTasks(workspaceId ?? null); useAllWorkflowSnapshots(workspaceId ?? null); return ( // Not a
: AppShell owns that landmark, one per page.
) : undefined } onDeleteSaved={state.onDeleteSaved} onToggleSavedDefault={state.onToggleSavedDefault} defaultMutationPendingId={state.defaultMutationPendingId} canSaveCurrent={state.canSaveCurrent} onSaveCurrent={state.onOpenSaveDialog} prPresets={state.resolvedPrPresets} issuePresets={state.resolvedIssuePresets} /> ) : undefined } count={search.total} loading={search.loading} lastFetchedAt={search.lastFetchedAt} customQuery={state.customQuery} committedQuery={state.committedQuery} onCustomQueryChange={state.setCustomQuery} onCommitCustomQuery={state.commitCustomQuery} repoFilter={state.repoFilter} onRepoFilterChange={state.setRepoFilter} repoOptions={repoOptions} onRefresh={search.refresh} /> {!workspaceId && }
); } export function GitHubPageClient({ workspaceId, workflows, steps, repositories, }: GitHubPageClientProps) { const { t } = useTranslation(); const { status, loaded } = useGitHubStatus(workspaceId ?? null); const authed = hasGitHubUserIdentity(status); const [launchPayload, setLaunchPayload] = useState(null); const state = useGitHubPageState(workspaceId ?? null, authed); 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), []); return ( } scroll="none" >
{!loaded && (
{t("github:checkingGithubStatus")}
)} {loaded && !authed && (
)} {loaded && authed && ( )}
); }