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 }) => (
);
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')}
);
};