import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useShallow } from 'zustand/react/shallow'; import { Icon } from '@/components/icon/Icon'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; import { useI18n } from '@/lib/i18n'; import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer'; import { normalizePath } from '@/lib/pathNormalization'; import { useUIStore } from '@/stores/useUIStore'; import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { findLinkedSessionsForEntity, linkedEntityCandidateIds } from '@/lib/linkedSessionMatches'; import type { ForgeChecksResult, ForgeCommitsResult, ForgeEntityRef, ForgeIssueDetail, ForgeProvider, ForgePullRequestContext, ForgeTimelineResult, } from '@/lib/forge/provider'; import type { ForgeComment, ForgeTimelineEvent } from '@/lib/forge/types'; import { ForgeMetadataChips } from './ForgeMetadataChips'; import { ForgeCommitsSection } from './ForgeCommitsSection'; import { ForgeFilesDiffSection } from './ForgeFilesDiffSection'; import { ForgeTimelineSection } from './ForgeTimelineSection'; import { ForgeChecksSection } from './ForgeChecksSection'; import { LinkedSessionsSection } from './LinkedSessionsSection'; import { ForgeCommentComposer, ForgeEntityActions, ForgeMetadataEditor, ForgeThreadReply, } from './actions'; interface ForgeEntityDetailViewProps { provider: ForgeProvider; directory: string; number: number; options?: { sourceRepo?: string | null; kind?: 'pull' | 'issue'; }; /** Optional CTA target for the not-connected notice. */ onOpenSettings?: () => void; } interface PullData { context: ForgePullRequestContext | null; commits: ForgeCommitsResult | null; timeline: ForgeTimelineResult | null; checks: ForgeChecksResult | null; } const markdownClassName = 'typography-markdown-body text-foreground break-words [&_a]:no-underline [&_a:hover]:no-underline'; const SectionTitle: React.FC<{ children: React.ReactNode }> = ({ children }) => (

{children}

); const LoadingBlock: React.FC<{ label: string }> = ({ label }) => (
{label}
); const ErrorBlock: React.FC<{ message: string }> = ({ message }) => (
{message}
); const NotConnectedBlock: React.FC<{ onOpenSettings?: () => void }> = ({ onOpenSettings }) => { const { t } = useI18n(); return (
{t('forge.notConnected')}
{onOpenSettings ? ( ) : null}
); }; /** * Self-loading detail view for a forge pull request or issue. Owns all data * fetching through the provider facade (context/issue plus commits, timeline, * and checks where the provider implements them) and renders the presentational * section components. Sections stay capability-gated: GitHub check runs ride on * the pull-request context, Gitea statuses come from `getChecks`, GitLab has no * checks surface. */ export const ForgeEntityDetailView: React.FC = ({ provider, directory, number, options, onOpenSettings }) => { const { t } = useI18n(); const isIssue = (options?.kind ?? 'pull') === 'issue'; const sourceRepo = options?.sourceRepo ?? null; const [pull, setPull] = useState(null); const [issueDetail, setIssueDetail] = useState(null); const [isLoading, setIsLoading] = useState(true); // Bumped after a successful write so the owning load effect re-runs; never // bumped on render, so writes are the only trigger. const [reloadToken, setReloadToken] = useState(0); // Comments posted through this view are appended locally so they appear // immediately; a later context refresh reconciles them with authoritative // server data (and the load effect clears the local list). const [localComments, setLocalComments] = useState([]); // Id of the thread root the user is replying to (renders ForgeThreadReply // under that thread card). const [replyingTo, setReplyingTo] = useState(null); // Sessions in the same project as this view, from the same authoritative // store the sidebar consumes. Derived client-side: no extra fetching. const allSessions = useGlobalSessionsStore(useShallow((state) => state.activeSessions)); const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); // The repo this entity lives on, resolved from the loaded context/issue. const repoRef = isIssue ? (issueDetail?.repo ?? null) : (pull?.context?.repo ?? null); const projectSessions = useMemo(() => { const base = normalizePath(directory); if (!base) return allSessions; return allSessions.filter((session) => { const sessionDirectory = resolveGlobalSessionDirectory(session); return sessionDirectory === base || (sessionDirectory !== null && sessionDirectory.startsWith(`${base}/`)); }); }, [allSessions, directory]); const linkedSessions = useMemo(() => { if (!repoRef) return []; const candidateIds = linkedEntityCandidateIds(repoRef, number); return findLinkedSessionsForEntity(projectSessions, provider.kind, candidateIds); }, [number, projectSessions, provider.kind, repoRef]); const openSession = useCallback((sessionId: string) => { useUIStore.getState().closeMainSurfaces(); setCurrentSession(sessionId); }, [setCurrentSession]); const reload = useCallback(() => { setReloadToken((value) => value + 1); }, []); const ref = useMemo(() => ({ kind: isIssue ? 'issue' : 'pull', number }), [isIssue, number]); const appendComment = useCallback((comment: ForgeComment) => { setLocalComments((previous) => [...previous, comment]); }, []); useEffect(() => { let cancelled = false; setPull(null); setIssueDetail(null); setLocalComments([]); setIsLoading(true); if (isIssue) { if (!provider.getIssue) { setIsLoading(false); return; } void provider .getIssue(directory, number, { sourceRepo }) .then((detail) => { if (cancelled) return; setIssueDetail(detail); setIsLoading(false); }) .catch(() => { if (cancelled) return; setIsLoading(false); }); return () => { cancelled = true; }; } const canCommits = typeof provider.getCommits === 'function'; const canTimeline = typeof provider.getTimeline === 'function'; const canChecks = provider.capabilities.checks === 'commit-statuses' && typeof provider.getChecks === 'function'; void (async () => { const context = provider.getPullRequestContext ? await provider.getPullRequestContext(directory, number, { includeDiff: true, sourceRepo }) : null; const [commits, timeline, checks] = await Promise.all([ canCommits ? provider.getCommits!(directory, number, { sourceRepo }) : Promise.resolve(null), canTimeline ? provider.getTimeline!(directory, number, { sourceRepo }) : Promise.resolve(null), canChecks ? provider.getChecks!(directory, number, { sourceRepo }) : Promise.resolve(null), ]); if (cancelled) return; setPull({ context, commits, timeline, checks }); setIsLoading(false); })().catch(() => { if (cancelled) return; setIsLoading(false); }); return () => { cancelled = true; }; }, [directory, isIssue, number, provider, reloadToken, sourceRepo]); const mergedComments = useMemo(() => { const derived = isIssue ? issueDetail?.comments ?? [] : [...(pull?.context?.issueComments ?? []), ...(pull?.context?.reviewComments ?? [])]; return [...localComments, ...derived]; }, [isIssue, issueDetail?.comments, localComments, pull?.context]); const timelineEvents = useMemo(() => pull?.timeline?.events ?? [], [pull?.timeline]); const checksForPull = useMemo<{ kind: 'check-runs' | 'commit-statuses'; summary: ForgeChecksResult['checks'] } | null>(() => { const context = pull?.context; if (!context) return null; const checksResult = pull?.checks; if (provider.capabilities.checks === 'check-runs') { return context.checks ? { kind: 'check-runs', summary: context.checks } : null; } if (provider.capabilities.checks === 'commit-statuses') { return checksResult ? { kind: 'commit-statuses', summary: checksResult.checks } : null; } return null; }, [pull?.context, provider.capabilities.checks, pull?.checks]); const canReply = typeof provider.replyToThread === 'function'; const handleReply = useCallback((comment: ForgeComment) => { setReplyingTo(comment.id); }, []); const renderThreadReply = useCallback( (comment: ForgeComment): React.ReactNode => { if (comment.id !== replyingTo) return null; return ( { appendComment(created); setReplyingTo(null); }} onCancel={() => setReplyingTo(null)} /> ); }, [appendComment, directory, provider, ref, replyingTo], ); if (isLoading) { return ; } if (isIssue) { if (!issueDetail || !issueDetail.connected) { return ; } const issue = issueDetail.issue; if (!issue) { return ; } const issueState = issue.state === 'closed' ? 'closed' : 'open'; const stateColor = `var(--pr-${issueState})`; return (

{issue.title}

#{issue.number} {t(`forge.state.${issueState}`)}
{issue.body ? ( ) : null}
{t('forge.section.timeline')}
); } if (!pull || !pull.context || !pull.context.connected) { return ; } const context = pull.context; const pr = context.pr; if (!pr) { return ; } const stateColor = `var(--pr-${pr.state})`; const stateIcon = pr.state === 'merged' ? 'git-merge' : pr.state === 'closed' ? 'git-close-pull-request' : 'git-pull-request'; return (

{pr.title}

#{pr.number} {t(`forge.state.${pr.state}` as never)} {pr.draft ? ( {t('forge.draft')} ) : null}
{checksForPull ? (
{t('forge.section.checks')}
) : null} {typeof provider.getCommits === 'function' ? (
{t('forge.section.commits')}
) : null}
{t('forge.section.files')}
{t('forge.section.timeline')}
); };