"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 && (
)}
);
}