From 92f0eced342e555e0dfa54eefc93220381a5b81b Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Fri, 14 Aug 2026 06:06:16 +0000 Subject: [PATCH] =?UTF-8?q?feat(ui):=20rich=20forge=20entity=20views=20?= =?UTF-8?q?=E2=80=94=20commits,=20files/diff,=20timeline,=20checks,=20meta?= =?UTF-8?q?data=20chips?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server: new read routes for PR/MR commits, timeline, reviews, commit-statuses across github/gitlab/gitea; enrich PR/issue summaries with labels/assignees/milestone/commentsCount - ui: forge facade gains getCommits/getTimeline/getChecks; shared ForgeEntityDetailView + section components; mounted into PR/MR views and issue sections --- .../ui/src/components/views/GitLabMrView.tsx | 99 +---- .../ui/src/components/views/GiteaPrView.tsx | 101 ++---- .../views/forge/ForgeChecksSection.tsx | 256 +++++++++++++ .../views/forge/ForgeCommitsSection.tsx | 181 ++++++++++ .../views/forge/ForgeEntityDetailView.tsx | 280 +++++++++++++++ .../views/forge/ForgeFilesDiffSection.tsx | 174 +++++++++ .../views/forge/ForgeMetadataChips.tsx | 148 ++++++++ .../views/forge/ForgeTimelineSection.tsx | 262 ++++++++++++++ .../ui/src/components/views/forge/index.ts | 13 + .../views/git/GitHubIssuesSection.tsx | 214 ++--------- .../views/git/GitLabIssuesSection.tsx | 203 ++--------- .../views/git/GiteaIssuesSection.tsx | 198 ++-------- .../views/git/PullRequestSection.tsx | 117 +++++- packages/ui/src/lib/api/types.ts | 118 ++++++ packages/ui/src/lib/forge/adapters.ts | 153 ++++++++ packages/ui/src/lib/forge/forge.test.ts | 340 +++++++++++++++++- packages/ui/src/lib/forge/index.ts | 14 + packages/ui/src/lib/forge/normalize.ts | 202 ++++++++++- packages/ui/src/lib/forge/provider.ts | 53 ++- packages/ui/src/lib/i18n/messages/de.ts | 49 +++ packages/ui/src/lib/i18n/messages/en.ts | 48 +++ packages/ui/src/lib/i18n/messages/es.ts | 49 +++ packages/ui/src/lib/i18n/messages/fr.ts | 49 +++ packages/ui/src/lib/i18n/messages/ja.ts | 49 +++ packages/ui/src/lib/i18n/messages/ko.ts | 49 +++ packages/ui/src/lib/i18n/messages/pl.ts | 49 +++ packages/ui/src/lib/i18n/messages/pt-BR.ts | 49 +++ packages/ui/src/lib/i18n/messages/uk.ts | 49 +++ packages/ui/src/lib/i18n/messages/zh-CN.ts | 49 +++ packages/ui/src/lib/i18n/messages/zh-TW.ts | 49 +++ .../web/server/lib/gitea/DOCUMENTATION.md | 8 +- packages/web/server/lib/gitea/client.js | 6 + packages/web/server/lib/gitea/routes.js | 217 +++++++++++ packages/web/server/lib/gitea/routes.test.js | 125 +++++++ .../web/server/lib/github/DOCUMENTATION.md | 6 + packages/web/server/lib/github/routes.js | 164 +++++++++ packages/web/server/lib/github/routes.test.js | 134 +++++++ .../web/server/lib/gitlab/DOCUMENTATION.md | 4 + packages/web/server/lib/gitlab/client.js | 4 + packages/web/server/lib/gitlab/routes.js | 161 +++++++++ packages/web/server/lib/gitlab/routes.test.js | 72 ++++ packages/web/src/api/gitea.ts | 51 +++ packages/web/src/api/github.ts | 30 ++ packages/web/src/api/gitlab.ts | 34 ++ 44 files changed, 3996 insertions(+), 684 deletions(-) create mode 100644 packages/ui/src/components/views/forge/ForgeChecksSection.tsx create mode 100644 packages/ui/src/components/views/forge/ForgeCommitsSection.tsx create mode 100644 packages/ui/src/components/views/forge/ForgeEntityDetailView.tsx create mode 100644 packages/ui/src/components/views/forge/ForgeFilesDiffSection.tsx create mode 100644 packages/ui/src/components/views/forge/ForgeMetadataChips.tsx create mode 100644 packages/ui/src/components/views/forge/ForgeTimelineSection.tsx create mode 100644 packages/ui/src/components/views/forge/index.ts create mode 100644 packages/web/server/lib/github/routes.test.js diff --git a/packages/ui/src/components/views/GitLabMrView.tsx b/packages/ui/src/components/views/GitLabMrView.tsx index 174e50e4..fd762188 100644 --- a/packages/ui/src/components/views/GitLabMrView.tsx +++ b/packages/ui/src/components/views/GitLabMrView.tsx @@ -4,16 +4,16 @@ import { Icon } from '@/components/icon/Icon'; import { Button } from '@/components/ui/button'; import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; -import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer'; import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip'; import { GitLabIssuesSection } from '@/components/views/git/GitLabIssuesSection'; +import { ForgeEntityDetailView } from '@/components/views/forge'; +import { buildForgeProvider } from '@/lib/forge'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useGitStatus, useGitStore } from '@/stores/useGitStore'; import { useGitLabAuthStore } from '@/stores/useGitLabAuthStore'; import { useUIStore } from '@/stores/useUIStore'; import { openExternalUrl } from '@/lib/url'; -import { formatDateTimeForPreference } from '@/lib/timeFormat'; import type { GitLabMergeRequestContextResult, GitLabMergeRequestSummary, GitLabRepoRef } from '@/lib/api/types'; import { useI18n } from '@/lib/i18n'; import { toast } from '@/components/ui'; @@ -59,7 +59,6 @@ export const GitLabMrView: React.FC = () => { const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); const setSettingsPage = useUIStore((state) => state.setSettingsPage); - const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); React.useEffect(() => { if (!currentDirectory || !git) { @@ -208,13 +207,11 @@ export const GitLabMrView: React.FC = () => { const [contextOpen, setContextOpen] = React.useState(false); const [contextResult, setContextResult] = React.useState(null); const [contextLoading, setContextLoading] = React.useState(false); - const [contextError, setContextError] = React.useState(null); // A different branch MR invalidates any previously loaded context. React.useEffect(() => { setContextOpen(false); setContextResult(null); - setContextError(null); }, [branchMr?.number]); // A different branch MR invalidates the update/merge transient state so the @@ -237,25 +234,23 @@ export const GitLabMrView: React.FC = () => { if (contextOpen) { setContextOpen(false); setContextResult(null); - setContextError(null); return; } setContextOpen(true); setContextLoading(true); - setContextError(null); try { const result = await gitlab.mrContext(currentDirectory, mr.number, { includeDiff: false }); - if (result.connected === false) { - setContextError(t('contextPanel.gitlabMr.error.notConnected')); - } else { - setContextResult(result); - } - } catch (error) { - setContextError(error instanceof Error ? error.message : String(error)); + setContextResult(result.connected === false ? null : result); + } catch { + setContextResult(null); } finally { setContextLoading(false); } - }, [contextOpen, currentDirectory, gitlab, t]); + }, [contextOpen, currentDirectory, gitlab]); + + // Shared rich view for the branch MR's detail (title/body/chips/commits/ + // files/timeline). Owns its own fetching through the forge facade. + const mrProvider = React.useMemo(() => (gitlab ? buildForgeProvider('gitlab', { gitlab }) : null), [gitlab]); // ---- Create / update / merge actions ----------------------------------- @@ -495,23 +490,6 @@ export const GitLabMrView: React.FC = () => { } }, [branchMr, currentDirectory, gitlab, mergeSquash, t]); - const formatTimestamp = React.useCallback((value?: string) => { - if (!value) { - return ''; - } - const timestamp = Date.parse(value); - if (!Number.isFinite(timestamp)) { - return value; - } - return formatDateTimeForPreference(timestamp, timeFormatPreference, { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: '2-digit', - }); - }, [timeFormatPreference]); - // ---- Render ------------------------------------------------------------ if (!currentDirectory) { @@ -553,7 +531,6 @@ export const GitLabMrView: React.FC = () => { : t('contextPanel.gitlabMr.state.opened') : ''; const branchMrAuthor = branchMr ? mrAuthorLabel(branchMr) : ''; - const mrComments = contextResult?.comments ?? []; return ( { ) : null} - {contextOpen ? ( + {contextOpen && mrProvider ? (
- {contextLoading ? ( -
- - {t('contextPanel.gitlabMr.loading')} -
- ) : contextError ? ( -
{contextError}
- ) : ( - <> -
-
{t('gitView.pr.field.description')}
- {contextResult?.mr?.body?.trim() ? ( - - ) : ( -
{t('gitView.pr.noDescription')}
- )} -
-
-
{t('gitView.pr.segment.comments')}
- {mrComments.length > 0 ? ( - mrComments.map((comment) => ( -
-
- - {comment.author?.name?.trim() || comment.author?.username || ''} - - {comment.createdAt ? ( - {formatTimestamp(comment.createdAt)} - ) : null} -
- -
- )) - ) : ( -
{t('gitView.pr.comments.empty')}
- )} -
- - )} +
) : null} diff --git a/packages/ui/src/components/views/GiteaPrView.tsx b/packages/ui/src/components/views/GiteaPrView.tsx index 2ae51946..2e863b3e 100644 --- a/packages/ui/src/components/views/GiteaPrView.tsx +++ b/packages/ui/src/components/views/GiteaPrView.tsx @@ -4,16 +4,16 @@ import { Icon } from '@/components/icon/Icon'; import { Button } from '@/components/ui/button'; import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; -import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer'; import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip'; import { GiteaIssuesSection } from '@/components/views/git/GiteaIssuesSection'; +import { ForgeEntityDetailView } from '@/components/views/forge'; +import { buildForgeProvider } from '@/lib/forge'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useGitStatus, useGitStore } from '@/stores/useGitStore'; import { useGiteaAuthStore } from '@/stores/useGiteaAuthStore'; import { useUIStore } from '@/stores/useUIStore'; import { openExternalUrl } from '@/lib/url'; -import { formatDateTimeForPreference } from '@/lib/timeFormat'; import type { GiteaPullRequestContextResult, GiteaPullRequestSummary } from '@/lib/api/types'; import { useI18n } from '@/lib/i18n'; import { toast } from '@/components/ui'; @@ -57,7 +57,6 @@ export const GiteaPrView: React.FC = () => { const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); const setSettingsPage = useUIStore((state) => state.setSettingsPage); - const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); React.useEffect(() => { if (!currentDirectory || !git) { @@ -206,13 +205,11 @@ export const GiteaPrView: React.FC = () => { const [contextOpen, setContextOpen] = React.useState(false); const [contextResult, setContextResult] = React.useState(null); const [contextLoading, setContextLoading] = React.useState(false); - const [contextError, setContextError] = React.useState(null); // A different branch PR invalidates any previously loaded context. React.useEffect(() => { setContextOpen(false); setContextResult(null); - setContextError(null); }, [branchPr?.number]); // A different branch PR invalidates the update/merge transient state so the @@ -234,25 +231,25 @@ export const GiteaPrView: React.FC = () => { if (contextOpen) { setContextOpen(false); setContextResult(null); - setContextError(null); return; } setContextOpen(true); setContextLoading(true); - setContextError(null); try { const result = await gitea.prContext(currentDirectory, pr.number, { includeDiff: false }); - if (result.connected === false) { - setContextError(t('contextPanel.giteaPr.error.notConnected')); - } else { - setContextResult(result); - } - } catch (error) { - setContextError(error instanceof Error ? error.message : String(error)); + setContextResult(result.connected === false ? null : result); + } catch { + setContextResult(null); } finally { setContextLoading(false); } - }, [contextOpen, currentDirectory, gitea, t]); + }, [contextOpen, currentDirectory, gitea]); + + // Shared rich view for the branch PR's detail (title/body/chips/commits/ + // files/timeline/status strip). Owns its own fetching through the forge + // facade; the commit-status capability renders the status strip in the + // checks section automatically. + const prProvider = React.useMemo(() => (gitea ? buildForgeProvider('gitea', { gitea }) : null), [gitea]); // ---- Create / update / merge actions ----------------------------------- @@ -492,23 +489,6 @@ export const GiteaPrView: React.FC = () => { } }, [branchPr, currentDirectory, gitea, t]); - const formatTimestamp = React.useCallback((value?: string) => { - if (!value) { - return ''; - } - const timestamp = Date.parse(value); - if (!Number.isFinite(timestamp)) { - return value; - } - return formatDateTimeForPreference(timestamp, timeFormatPreference, { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: '2-digit', - }); - }, [timeFormatPreference]); - // ---- Render ------------------------------------------------------------ if (!currentDirectory) { @@ -550,7 +530,6 @@ export const GiteaPrView: React.FC = () => { : t('contextPanel.giteaPr.state.opened') : ''; const branchPrAuthor = branchPr ? prAuthorLabel(branchPr) : ''; - const prComments = contextResult?.comments ?? []; return ( { ) : null} - {contextOpen ? ( + {contextOpen && prProvider ? (
- {contextLoading ? ( -
- - {t('contextPanel.giteaPr.loading')} -
- ) : contextError ? ( -
{contextError}
- ) : ( - <> -
-
{t('gitView.pr.field.description')}
- {contextResult?.pr?.body?.trim() ? ( - - ) : ( -
{t('gitView.pr.noDescription')}
- )} -
-
-
{t('gitView.pr.segment.comments')}
- {prComments.length > 0 ? ( - prComments.map((comment) => ( -
-
- - {comment.author?.username || ''} - - {comment.createdAt ? ( - {formatTimestamp(comment.createdAt)} - ) : null} -
- -
- )) - ) : ( -
{t('gitView.pr.comments.empty')}
- )} -
- - )} +
) : null} diff --git a/packages/ui/src/components/views/forge/ForgeChecksSection.tsx b/packages/ui/src/components/views/forge/ForgeChecksSection.tsx new file mode 100644 index 00000000..df10cb9c --- /dev/null +++ b/packages/ui/src/components/views/forge/ForgeChecksSection.tsx @@ -0,0 +1,256 @@ +import React, { useState } from 'react'; +import { Icon } from '@/components/icon/Icon'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useI18n } from '@/lib/i18n'; +import type { IconName } from '@/components/icon/icons'; +import type { ForgeCheckState, ForgeChecksCapability, ForgeChecksSummary } from '@/lib/forge/types'; + +interface ForgeChecksSectionProps { + kind: ForgeChecksCapability; + summary: ForgeChecksSummary | null; + loading?: boolean; + error?: string | null; +} + +const stateColor = (state: ForgeCheckState): string => { + switch (state) { + case 'success': + return 'var(--status-success)'; + case 'failure': + return 'var(--status-error)'; + case 'pending': + return 'var(--status-warning)'; + default: + return 'var(--surface-muted-foreground)'; + } +}; + +const stateIcon = (state: ForgeCheckState): IconName => { + switch (state) { + case 'success': + return 'checkbox-circle'; + case 'failure': + return 'close-circle'; + case 'pending': + return 'loader-4'; + case 'cancelled': + return 'close-circle'; + case 'skipped': + return 'subtract'; + default: + return 'question'; + } +}; + +const formatElapsed = (start?: string, end?: string): string | null => { + if (!start) return null; + const startTs = Date.parse(start); + if (!Number.isFinite(startTs)) return null; + const endTs = end ? Date.parse(end) : Date.now(); + if (!Number.isFinite(endTs) || endTs <= startTs) return null; + const totalMinutes = Math.floor((endTs - startTs) / 60_000); + if (totalMinutes < 1) return '<1m'; + if (totalMinutes < 60) return `${totalMinutes}m`; + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; +}; + +const CheckRunRow: React.FC<{ + name: string; + state: ForgeCheckState; + startedAt?: string; + completedAt?: string; + description?: string; + details?: ForgeChecksSummary['checks'][number]['details']; + expanded: boolean; + onToggle: () => void; +}> = ({ name, state, startedAt, completedAt, description, details, expanded, onToggle }) => { + const { t } = useI18n(); + const isPending = state === 'pending'; + const duration = formatElapsed(startedAt, isPending ? undefined : completedAt); + const hasDetails = Boolean( + details?.title || details?.summary || details?.text || (details?.annotations?.length ?? 0) > 0, + ); + + return ( +
+ + {expanded && hasDetails ? ( +
+ {details?.title ?
{details.title}
: null} + {details?.summary ? ( +
{details.summary}
+ ) : null} + {details?.text ? ( +
+ {details.text} +
+ ) : null} + {details?.annotations && details.annotations.length > 0 ? ( +
+ {details.annotations.map((annotation, idx) => ( +
+
+ {annotation.title || annotation.level || 'Issue'} + {annotation.path ? ` · ${annotation.path}` : ''} + {typeof annotation.startLine === 'number' ? `:${annotation.startLine}` : ''} + {typeof annotation.endLine === 'number' && annotation.endLine !== annotation.startLine + ? `-${annotation.endLine}` + : ''} +
+ {annotation.message ? ( +
+ {annotation.message} +
+ ) : null} +
+ ))} +
+ ) : null} +
+ ) : null} +
+ ); +}; + +const CommitStatusStrip: React.FC<{ summary: ForgeChecksSummary }> = ({ summary }) => { + const { t } = useI18n(); + return ( +
+
{t('forge.checks.statusStrip')}
+
+ {summary.checks.map((check, idx) => ( + + + {check.name} + + ))} +
+
+ ); +}; + +/** + * CI/status summary for a pull request, gated by the provider's checks + * capability. `'check-runs'` renders an aggregate bar plus expandable per-run + * rows (title/summary/text + annotations); `'commit-statuses'` renders a strip + * of status chips. Returns null for `'none'`. Pure presentation. + */ +export const ForgeChecksSection = React.memo(function ForgeChecksSection({ kind, summary, loading, error }) { + const { t } = useI18n(); + const [expandedKeys, setExpandedKeys] = useState>(new Set()); + + const toggle = React.useCallback((key: string) => { + setExpandedKeys((previous) => { + const next = new Set(previous); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }, []); + + if (kind === 'none') return null; + + if (loading) { + return ( +
+ + + +
+ ); + } + + if (error) { + return ( +
+ + {error} +
+ ); + } + + if (!summary || summary.checks.length === 0) { + return

{t('forge.checks.empty')}

; + } + + if (kind === 'commit-statuses') { + return ; + } + + return ( +
+
+
+ {summary.success > 0 ? ( +
+ ) : null} + {summary.failure > 0 ? ( +
+ ) : null} + {summary.pending > 0 ? ( +
+ ) : null} +
+ + {summary.success}/{summary.total} {t('gitView.pr.checks.label')} + +
+
+ {summary.checks.map((check, idx) => { + const key = `${check.name}:${idx}`; + return ( + toggle(key)} + /> + ); + })} +
+
+ ); +}); diff --git a/packages/ui/src/components/views/forge/ForgeCommitsSection.tsx b/packages/ui/src/components/views/forge/ForgeCommitsSection.tsx new file mode 100644 index 00000000..79e3fae1 --- /dev/null +++ b/packages/ui/src/components/views/forge/ForgeCommitsSection.tsx @@ -0,0 +1,181 @@ +import React, { useMemo, useState } from 'react'; +import { Icon } from '@/components/icon/Icon'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { toast } from '@/components/ui/toast'; +import { useI18n } from '@/lib/i18n'; +import { formatDateTimeForPreference } from '@/lib/timeFormat'; +import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore'; +import { copyTextToClipboard } from '@/lib/clipboard'; +import type { GitLogEntry } from '@/lib/api/types'; +import type { ForgeCommit } from '@/lib/forge/types'; +import { assignLanes } from '@/components/views/git/gitGraph'; +import { GitGraphSegment } from '@/components/views/git/GitGraphSegment'; + +interface ForgeCommitsSectionProps { + commits: ForgeCommit[] | null; + loading?: boolean; + error?: string | null; +} + +/** + * Map a normalized forge commit onto the `GitLogEntry`-shaped input + * `assignLanes` consumes. The commit list is authoritative; the synthesized + * fields are only used for lane geometry and display text. + */ +const toLaneEntry = (commit: ForgeCommit): GitLogEntry => ({ + hash: commit.sha, + date: commit.committedAt ?? '', + message: commit.summary ?? commit.message, + refs: '', + body: commit.message, + author_name: commit.author?.name ?? commit.author?.login ?? 'Unknown', + author_email: '', + filesChanged: 0, + insertions: 0, + deletions: 0, + parents: commit.parents, +}); + +const formatCommitDate = (value: string | undefined, timeFormatPreference: TimeFormatPreference): string => { + if (!value) return ''; + const ts = Date.parse(value); + if (!Number.isFinite(ts)) return value; + return formatDateTimeForPreference(ts, timeFormatPreference, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); +}; + +/** + * Commits on a pull request, rendered with the git-graph lane visual language + * (GitGraphSegment over `assignLanes` output). Each row expands to the full + * message and parent shas. Pure presentation. + */ +export const ForgeCommitsSection = React.memo(function ForgeCommitsSection({ commits, loading, error }) { + const { t } = useI18n(); + const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); + const [expandedShas, setExpandedShas] = useState>(new Set()); + + const bySha = useMemo(() => new Map((commits ?? []).map((commit) => [commit.sha, commit])), [commits]); + const laned = useMemo(() => assignLanes((commits ?? []).map(toLaneEntry)), [commits]); + const totalLanes = useMemo( + () => laned.reduce((max, item) => Math.max(max, item.lane), -1) + 1, + [laned], + ); + + const toggle = React.useCallback((sha: string) => { + setExpandedShas((previous) => { + const next = new Set(previous); + if (next.has(sha)) { + next.delete(sha); + } else { + next.add(sha); + } + return next; + }); + }, []); + + const copyHash = React.useCallback(async (sha: string) => { + const result = await copyTextToClipboard(sha); + if (result.ok) { + toast.success(t('forge.copied')); + } + }, [t]); + + if (loading) { + return ( +
+ + + +
+ ); + } + + if (error) { + return ( +
+ + {error} +
+ ); + } + + if (!commits || commits.length === 0) { + return

{t('forge.commits.empty')}

; + } + + return ( +
    + {laned.map((item) => { + const commit = bySha.get(item.commit.hash); + if (!commit) return null; + const isExpanded = expandedShas.has(commit.sha); + const author = commit.author?.name ?? commit.author?.login ?? null; + return ( +
  • + + + {t('gitView.history.copySha')} + +
+
+ + {isExpanded ? ( +
+

{commit.message}

+ {commit.parents.length > 0 ? ( +

+ {t('forge.commits.parents')}: + {commit.parents.map((parent) => ( + {parent.slice(0, 7)} + ))} +

+ ) : null} +
+ ) : null} + + ); + })} + + ); +}); diff --git a/packages/ui/src/components/views/forge/ForgeEntityDetailView.tsx b/packages/ui/src/components/views/forge/ForgeEntityDetailView.tsx new file mode 100644 index 00000000..6d02962c --- /dev/null +++ b/packages/ui/src/components/views/forge/ForgeEntityDetailView.tsx @@ -0,0 +1,280 @@ +import React, { useEffect, useMemo, useState } from 'react'; +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 type { + ForgeChecksResult, + ForgeCommitsResult, + 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'; + +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); + + useEffect(() => { + let cancelled = false; + setPull(null); + setIssueDetail(null); + 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, sourceRepo]); + + const mergedComments = useMemo(() => { + if (isIssue) return issueDetail?.comments ?? []; + const context = pull?.context; + return [...(context?.issueComments ?? []), ...(context?.reviewComments ?? [])]; + }, [isIssue, issueDetail?.comments, 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]); + + 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')} + +
+
+ ); +}; diff --git a/packages/ui/src/components/views/forge/ForgeFilesDiffSection.tsx b/packages/ui/src/components/views/forge/ForgeFilesDiffSection.tsx new file mode 100644 index 00000000..02fc70c1 --- /dev/null +++ b/packages/ui/src/components/views/forge/ForgeFilesDiffSection.tsx @@ -0,0 +1,174 @@ +import React, { useMemo, useState } from 'react'; +import { Icon } from '@/components/icon/Icon'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useI18n } from '@/lib/i18n'; +import { getLanguageFromExtension } from '@/lib/toolHelpers'; +import { fileDiffFromPatch } from '@/lib/diff/patchFileDiff'; +import type { FileDiffMetadata } from '@pierre/diffs'; +import type { ForgeFileChange } from '@/lib/forge/types'; +import { PierreDiffViewer } from '@/components/views/PierreDiffViewer'; + +interface ForgeFilesDiffSectionProps { + files: ForgeFileChange[] | null; + diff?: string | null; + loading?: boolean; + error?: string | null; +} + +const CHANGE_DESCRIPTORS: Record = { + added: { code: 'A', color: 'var(--status-success)' }, + removed: { code: 'D', color: 'var(--status-error)' }, + renamed: { code: 'R', color: 'var(--status-info)' }, + modified: { code: 'M', color: 'var(--status-warning)' }, +}; + +const DEFAULT_DESCRIPTOR = CHANGE_DESCRIPTORS.modified; + +const descriptorFor = (status?: string): { code: string; color: string } => { + if (!status) return DEFAULT_DESCRIPTOR; + const key = status.toLowerCase(); + return CHANGE_DESCRIPTORS[key] ?? DEFAULT_DESCRIPTOR; +}; + +/** + * Split a combined multi-file diff into per-file sections so a file without + * its own `patch` field can still show an inline diff. + */ +const splitDiffSections = (diff: string): string[] => { + if (!diff) return []; + const sections: string[] = []; + let current: string[] = []; + for (const line of diff.split('\n')) { + if (/^diff --(git|cc|combined) /.test(line)) { + if (current.length > 0) { + sections.push(current.join('\n')); + current = []; + } + } + current.push(line); + } + if (current.length > 0) { + sections.push(current.join('\n')); + } + return sections; +}; + +const diffSectionFor = (diff: string | null | undefined, filename: string): string | null => { + if (!diff) return null; + const needle = ` b/${filename}`; + const section = splitDiffSections(diff).find((sectionText) => sectionText.includes(needle)); + return section && section.trim() ? section : null; +}; + +/** + * File-change list for a pull request. Each row shows the change symbol + * (A/M/D/R), filename, and add/delete counts, and expands to an inline diff + * rendered by PierreDiffViewer (per-file `patch` when present, else the + * matching section sliced from the combined `diff`). Pure presentation. + */ +export const ForgeFilesDiffSection = React.memo(function ForgeFilesDiffSection({ files, diff, loading, error }) { + const { t } = useI18n(); + const [openPaths, setOpenPaths] = useState>(new Set()); + + const toggle = React.useCallback((path: string) => { + setOpenPaths((previous) => { + const next = new Set(previous); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + }, []); + + const fileDiffs = useMemo(() => { + const map = new Map(); + for (const file of files ?? []) { + const patch = file.patch?.trim() ? file.patch : diffSectionFor(diff, file.filename); + map.set(file.filename, patch && patch.trim() ? fileDiffFromPatch(file.filename, patch) : null); + } + return map; + }, [diff, files]); + + if (loading) { + return ( +
+ + + +
+ ); + } + + if (error) { + return ( +
+ + {error} +
+ ); + } + + if (!files || files.length === 0) { + return

{t('forge.files.empty')}

; + } + + return ( +
    + {files.map((file) => { + const descriptor = descriptorFor(file.status); + const isOpen = openPaths.has(file.filename); + const fileDiff = fileDiffs.get(file.filename) ?? null; + return ( +
  • + + {isOpen ? ( +
    + {fileDiff ? ( + + ) : ( +

    {t('forge.files.noDiff')}

    + )} +
    + ) : null} +
  • + ); + })} +
+ ); +}); diff --git a/packages/ui/src/components/views/forge/ForgeMetadataChips.tsx b/packages/ui/src/components/views/forge/ForgeMetadataChips.tsx new file mode 100644 index 00000000..32174a92 --- /dev/null +++ b/packages/ui/src/components/views/forge/ForgeMetadataChips.tsx @@ -0,0 +1,148 @@ +import React from 'react'; +import { Icon } from '@/components/icon/Icon'; +import { useI18n } from '@/lib/i18n'; +import { formatDateTimeForPreference } from '@/lib/timeFormat'; +import { useUIStore } from '@/stores/useUIStore'; +import type { ForgeIssue, ForgePullRequest, ForgeUser } from '@/lib/forge/types'; + +interface ForgeMetadataChipsProps { + kind: 'pull' | 'issue'; + pr?: ForgePullRequest | null; + issue?: ForgeIssue | null; +} + +const chipClassName = + 'inline-flex items-center gap-1.5 rounded-md border border-border/60 bg-surface-elevated px-2 py-0.5 typography-micro text-foreground'; + +const avatarSize = 'size-3.5 rounded-full'; + +/** GitHub label colors arrive without the `#` prefix; normalize both spellings. */ +const resolveLabelColor = (color?: string): string | null => { + if (!color) return null; + const value = color.trim(); + if (!value) return null; + return value.startsWith('#') ? value : `#${value}`; +}; + +const Avatar: React.FC<{ user: ForgeUser }> = ({ user }) => { + const initial = (user.login || user.name || '?').charAt(0).toUpperCase(); + if (user.avatarUrl) { + return {user.login}; + } + return ( + + {initial} + + ); +}; + +/** + * Metadata chips for a pull request or issue: labels, assignees, milestone, + * author, created/updated dates, and (for PRs) the base→head branch pair. + * Pure presentation — all data arrives via props. Renders nothing when every + * metadata group is absent. + */ +export const ForgeMetadataChips = React.memo(function ForgeMetadataChips({ kind, pr, issue }) { + const { t } = useI18n(); + const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); + + const entity = pr ?? issue; + if (!entity) return null; + + const labels = entity.labels ?? []; + const assignees = entity.assignees ?? []; + const pull = pr ?? null; + const baseRef = pull?.base?.ref; + const headRef = pull?.head?.ref; + const hasAny = + labels.length > 0 + || assignees.length > 0 + || Boolean(entity.milestone) + || Boolean(entity.author) + || Boolean(entity.createdAt) + || Boolean(entity.updatedAt) + || (kind === 'pull' && Boolean(baseRef && headRef)); + + if (!hasAny) return null; + + const formatDate = (value?: string): string => { + if (!value) return ''; + const ts = Date.parse(value); + if (!Number.isFinite(ts)) return value; + return formatDateTimeForPreference(ts, timeFormatPreference, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); + }; + + return ( +
+ {labels.map((label) => { + const color = resolveLabelColor(label.color); + return ( + + + {label.name} + + ); + })} + + {assignees.map((assignee) => ( + + + {assignee.login} + + ))} + + {entity.milestone ? ( + + + {entity.milestone.title} + + ) : null} + + {entity.author ? ( + + + {entity.author.login} + + ) : null} + + {entity.createdAt ? ( + + + {formatDate(entity.createdAt)} + + ) : null} + + {entity.updatedAt ? ( + + + {formatDate(entity.updatedAt)} + + ) : null} + + {kind === 'pull' && baseRef && headRef ? ( + + {baseRef} + + {headRef} + + ) : null} +
+ ); +}); diff --git a/packages/ui/src/components/views/forge/ForgeTimelineSection.tsx b/packages/ui/src/components/views/forge/ForgeTimelineSection.tsx new file mode 100644 index 00000000..32486503 --- /dev/null +++ b/packages/ui/src/components/views/forge/ForgeTimelineSection.tsx @@ -0,0 +1,262 @@ +import React, { useMemo } from 'react'; +import { Icon } from '@/components/icon/Icon'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useI18n } from '@/lib/i18n'; +import { formatDateTimeForPreference } from '@/lib/timeFormat'; +import { useUIStore } from '@/stores/useUIStore'; +import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer'; +import type { IconName } from '@/components/icon/icons'; +import type { ForgeComment, ForgeTimelineEvent, ForgeTimelineEventType, ForgeUser } from '@/lib/forge/types'; + +interface ForgeTimelineSectionProps { + events: ForgeTimelineEvent[]; + comments: ForgeComment[]; + loading?: boolean; + error?: string | null; +} + +const EVENT_ICONS: Record = { + opened: 'git-pull-request', + reopened: 'git-pull-request', + closed: 'git-close-pull-request', + merged: 'git-merge', + committed: 'git-commit', + reviewed: 'eye', + approved: 'checkbox-circle', + 'requested-changes': 'alert', + commented: 'chat-1', + referenced: 'external-link', + labeled: 'pushpin', + unlabeled: 'pushpin', + assigned: 'user', + unassigned: 'user', + milestoned: 'target', + demilestoned: 'target', + other: 'more', +}; + +const EVENT_COLORS: Partial> = { + approved: 'var(--status-success)', + 'requested-changes': 'var(--status-error)', + merged: 'var(--pr-merged)', + closed: 'var(--pr-closed)', +}; + +const toTimestamp = (value?: string): number => { + if (!value) return 0; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : 0; +}; + +const CommentAvatar: React.FC<{ author?: ForgeUser | null }> = ({ author }) => { + const label = author?.name ?? author?.login ?? '?'; + const initial = label.charAt(0).toUpperCase(); + return ( +
+ {author?.avatarUrl ? ( + {label} + ) : ( + {initial} + )} +
+ ); +}; + +const InlineContextChip: React.FC<{ comment: ForgeComment; label: string }> = ({ comment, label }) => { + if (!comment.path) return null; + const text = comment.line ? `${comment.path}:${comment.line}` : comment.path; + return ( + + + {text} + + ); +}; + +type TimelineItem = + | { kind: 'event'; event: ForgeTimelineEvent } + | { kind: 'thread'; thread: ForgeComment[] }; + +/** + * Chronologically merged activity timeline for a pull request or issue: event + * markers interleaved with comment threads. Inline review comments are grouped + * by `inReplyToId` chains or (path, line) buckets; a thread renders as one + * card with its comments stacked. Pure presentation. + */ +export const ForgeTimelineSection = React.memo(function ForgeTimelineSection({ events, comments, loading, error }) { + const { t } = useI18n(); + const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); + + const formatTime = React.useCallback((value?: string): string => { + if (!value) return ''; + const ts = Date.parse(value); + if (!Number.isFinite(ts)) return value; + return formatDateTimeForPreference(ts, timeFormatPreference, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); + }, [timeFormatPreference]); + + const threads = useMemo(() => { + const byId = new Map(comments.map((comment) => [comment.id, comment])); + const repliesByParent = new Map(); + for (const comment of comments) { + if (comment.inReplyToId && byId.has(comment.inReplyToId)) { + const list = repliesByParent.get(comment.inReplyToId) ?? []; + list.push(comment); + repliesByParent.set(comment.inReplyToId, list); + } + } + + const collect = (root: ForgeComment): ForgeComment[] => { + const members: ForgeComment[] = []; + const visit = (comment: ForgeComment): void => { + members.push(comment); + for (const reply of repliesByParent.get(comment.id) ?? []) { + visit(reply); + } + }; + visit(root); + return members.sort((a, b) => toTimestamp(a.createdAt) - toTimestamp(b.createdAt)); + }; + + const roots = comments.filter((comment) => !comment.inReplyToId || !byId.has(comment.inReplyToId)); + const buckets = new Map(); + const standalone: ForgeComment[] = []; + for (const root of roots) { + if (root.path) { + const key = root.line ? `${root.path}:${root.line}` : `path:${root.path}`; + const list = buckets.get(key) ?? []; + list.push(root); + buckets.set(key, list); + } else { + standalone.push(root); + } + } + + const seen = new Set(); + const dedupe = (members: ForgeComment[]): ForgeComment[] => + members.filter((member) => { + if (seen.has(member.id)) return false; + seen.add(member.id); + return true; + }); + + const result: ForgeComment[][] = []; + for (const root of buckets.values()) { + result.push(dedupe(root.flatMap(collect))); + } + for (const root of standalone) { + result.push(dedupe(collect(root))); + } + return result; + }, [comments]); + + const items = useMemo(() => { + const all: TimelineItem[] = [ + ...events.map((event) => ({ kind: 'event' as const, event })), + ...threads.map((thread) => ({ kind: 'thread' as const, thread })), + ]; + all.sort((a, b) => { + const aTs = a.kind === 'event' ? toTimestamp(a.event.createdAt) : toTimestamp(a.thread[0]?.createdAt); + const bTs = b.kind === 'event' ? toTimestamp(b.event.createdAt) : toTimestamp(b.thread[0]?.createdAt); + return aTs - bTs; + }); + return all; + }, [events, threads]); + + if (loading) { + return ( +
+ + + +
+ ); + } + + if (error) { + return ( +
+ + {error} +
+ ); + } + + if (items.length === 0) { + return

{t('forge.timeline.empty')}

; + } + + return ( +
+
+ {items.map((item, idx) => { + const isLast = idx === items.length - 1; + if (item.kind === 'event') { + const { event } = item; + return ( +
+ {!isLast ?
: null} +
+ +
+
+ {t(`forge.timeline.event.${event.type}` as never)} + {event.author ? {event.author.login} : null} + {event.createdAt ? {formatTime(event.createdAt)} : null} +
+ {event.body ? ( +

{event.body}

+ ) : null} +
+ ); + } + const { thread } = item; + const root = thread[0]; + return ( +
+ {!isLast ?
: null} + +
+
+ {thread.map((comment, commentIdx) => ( +
0 ? 'border-t border-border/40 pt-3' : ''} + > +
+ + {comment.author?.name ?? comment.author?.login ?? 'Unknown'} + + {comment.createdAt ? {formatTime(comment.createdAt)} : null} + +
+ +
+ ))} +
+
+
+ ); + })} +
+
+ ); +}); diff --git a/packages/ui/src/components/views/forge/index.ts b/packages/ui/src/components/views/forge/index.ts new file mode 100644 index 00000000..842ddd14 --- /dev/null +++ b/packages/ui/src/components/views/forge/index.ts @@ -0,0 +1,13 @@ +/** + * Shared rich-view sections for forge pull requests and issues. + * + * Every component in this directory is presentational — all data arrives via + * props. `ForgeEntityDetailView` is the one self-loading orchestrator that owns + * fetching through the `ForgeProvider` facade and composes the sections. + */ +export { ForgeMetadataChips } from './ForgeMetadataChips'; +export { ForgeCommitsSection } from './ForgeCommitsSection'; +export { ForgeFilesDiffSection } from './ForgeFilesDiffSection'; +export { ForgeTimelineSection } from './ForgeTimelineSection'; +export { ForgeChecksSection } from './ForgeChecksSection'; +export { ForgeEntityDetailView } from './ForgeEntityDetailView'; diff --git a/packages/ui/src/components/views/git/GitHubIssuesSection.tsx b/packages/ui/src/components/views/git/GitHubIssuesSection.tsx index 147da17d..e78435b0 100644 --- a/packages/ui/src/components/views/git/GitHubIssuesSection.tsx +++ b/packages/ui/src/components/views/git/GitHubIssuesSection.tsx @@ -1,34 +1,21 @@ import React from 'react'; import { Icon } from '@/components/icon/Icon'; import { Button } from '@/components/ui/button'; -import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer'; +import { ForgeEntityDetailView } from '@/components/views/forge'; +import { buildForgeProvider } from '@/lib/forge'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useUIStore } from '@/stores/useUIStore'; -import { formatDateTimeForPreference } from '@/lib/timeFormat'; -import type { - GitHubIssue, - GitHubIssueComment, - GitHubIssueSummary, - GitHubRepoSelector, -} from '@/lib/api/types'; +import type { GitHubIssueSummary, GitHubRepoSelector } from '@/lib/api/types'; import { useI18n } from '@/lib/i18n'; -const issueStateColor = (state: string): string => { - switch (state) { - case 'closed': - return 'var(--pr-closed)'; - default: - return 'var(--pr-open)'; - } -}; - const issueLabelBadgeClass = 'inline-flex items-center rounded border border-border/60 bg-surface-elevated px-1.5 py-px typography-micro text-foreground'; /** - * Open GitHub issues for the context panel's pull-request view. List and detail - * are fetched lazily: the parent only mounts this component while the Issues - * tab is active. Read-only by design — no create, update, or close actions. + * Open GitHub issues for the context panel's pull-request view. The list is + * fetched lazily (the parent only mounts this component while the Issues tab + * is active); selecting a row mounts the shared `ForgeEntityDetailView` for + * the issue detail. Read-only by design — no create, update, or close actions. * * The parent does not gate on GitHub auth state, so the connection state is * derived from the API results themselves (`connected === false` renders a @@ -39,7 +26,6 @@ export const GitHubIssuesSection: React.FC<{ directory: string }> = ({ directory const { github } = useRuntimeAPIs(); const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); const setSettingsPage = useUIStore((state) => state.setSettingsPage); - const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); // ---- Open issues list ---------------------------------------------------- @@ -58,10 +44,9 @@ export const GitHubIssuesSection: React.FC<{ directory: string }> = ({ directory const [selectedSourceRepo, setSelectedSourceRepo] = React.useState< (GitHubRepoSelector & { source: string }) | null >(null); - const [issue, setIssue] = React.useState(null); - const [comments, setComments] = React.useState([]); - const [detailLoading, setDetailLoading] = React.useState(false); - const [detailError, setDetailError] = React.useState(null); + const [selectedUrl, setSelectedUrl] = React.useState(null); + + const issueProvider = React.useMemo(() => (github ? buildForgeProvider('github', { github }) : null), [github]); const retry = React.useCallback(() => setRetryToken((value) => value + 1), []); @@ -82,10 +67,7 @@ export const GitHubIssuesSection: React.FC<{ directory: string }> = ({ directory setListNotConnected(false); setSelectedNumber(null); setSelectedSourceRepo(null); - setIssue(null); - setComments([]); - setDetailLoading(false); - setDetailError(null); + setSelectedUrl(null); }, [directory]); React.useEffect(() => { @@ -146,173 +128,51 @@ export const GitHubIssuesSection: React.FC<{ directory: string }> = ({ directory } }, [directory, github, listHasMore, listLoading, listLoadingMore, listPage]); - // Selecting a row remembers the summary's sourceRepo too: the server route - // resolves the repo from the directory, but cross-repo issues need the - // explicit sourceRepo to fetch the issue and its comments. + // Selecting a row remembers the summary's sourceRepo and url too: the server + // route resolves the repo from the directory, but cross-repo issues need the + // explicit sourceRepo for the shared detail view to fetch the issue and its + // comments from the right repository. const selectIssue = React.useCallback((item: GitHubIssueSummary) => { setSelectedNumber(item.number); setSelectedSourceRepo(item.sourceRepo ?? null); + setSelectedUrl(item.url ?? null); }, []); - // Fetch the issue and its comments in parallel whenever a row is selected. A - // cancelled flag keeps a stale selection from overwriting a newer one. - React.useEffect(() => { - if (selectedNumber === null || !github?.issueGet || !github.issueComments) { - return; - } - let cancelled = false; - setDetailLoading(true); - setDetailError(null); - setIssue(null); - setComments([]); - const sourceRepo = selectedSourceRepo ?? null; - void Promise.all([ - github.issueGet(directory, selectedNumber, { sourceRepo }), - github.issueComments(directory, selectedNumber, { sourceRepo }), - ]) - .then(([issueResult, commentsResult]) => { - if (cancelled) { - return; - } - if (issueResult.connected === false || commentsResult.connected === false) { - setDetailError(t('gitView.pr.githubNotConnected')); - return; - } - if (!issueResult.issue) { - setDetailError(t('session.githubIssuePicker.error.issueNotFound')); - return; - } - setIssue(issueResult.issue); - setComments(commentsResult.comments ?? []); - }) - .catch((error) => { - if (!cancelled) { - setDetailError(error instanceof Error ? error.message : String(error)); - } - }) - .finally(() => { - if (!cancelled) { - setDetailLoading(false); - } - }); - return () => { - cancelled = true; - }; - }, [directory, github, selectedNumber, selectedSourceRepo, t]); - const backToIssues = React.useCallback(() => { setSelectedNumber(null); setSelectedSourceRepo(null); - setIssue(null); - setComments([]); - setDetailLoading(false); - setDetailError(null); + setSelectedUrl(null); }, []); - const formatTimestamp = React.useCallback((value?: string) => { - if (!value) { - return ''; - } - const timestamp = Date.parse(value); - if (!Number.isFinite(timestamp)) { - return value; - } - return formatDateTimeForPreference(timestamp, timeFormatPreference, { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: '2-digit', - }); - }, [timeFormatPreference]); - if (selectedNumber !== null) { return (
-
+
+ {selectedUrl ? ( + + ) : null}
- {detailLoading ? ( -
- - {t('session.githubIssuePicker.loading.issues')} -
- ) : detailError ? ( -
-
{detailError}
- -
- ) : issue ? ( - <> -
-
- #{issue.number} {issue.title} -
-
- - - - {issue.labels?.map((label) => ( - {label.name} - ))} -
- {issue.assignees && issue.assignees.length > 0 ? ( -
- {issue.assignees.map((assignee) => assignee.name?.trim() || assignee.login).join(', ')} -
- ) : null} - -
- -
-
{t('gitView.pr.field.description')}
- {issue.body?.trim() ? ( - - ) : ( -
{t('gitView.pr.noDescription')}
- )} -
- -
-
{t('gitView.pr.segment.comments')}
- {comments.length > 0 ? ( - comments.map((comment) => ( -
-
- - {comment.author?.name?.trim() || comment.author?.login || ''} - - {comment.createdAt ? ( - {formatTimestamp(comment.createdAt)} - ) : null} -
- -
- )) - ) : ( -
{t('gitView.pullRequest.issues.detail.commentsEmpty')}
- )} -
- + {issueProvider ? ( + ) : null}
); diff --git a/packages/ui/src/components/views/git/GitLabIssuesSection.tsx b/packages/ui/src/components/views/git/GitLabIssuesSection.tsx index 0006cc39..ce99c12c 100644 --- a/packages/ui/src/components/views/git/GitLabIssuesSection.tsx +++ b/packages/ui/src/components/views/git/GitLabIssuesSection.tsx @@ -1,36 +1,27 @@ import React from 'react'; import { Icon } from '@/components/icon/Icon'; import { Button } from '@/components/ui/button'; -import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer'; +import { ForgeEntityDetailView } from '@/components/views/forge'; +import { buildForgeProvider } from '@/lib/forge'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useUIStore } from '@/stores/useUIStore'; -import { formatDateTimeForPreference } from '@/lib/timeFormat'; -import type { GitLabIssue, GitLabIssueComment, GitLabIssueSummary } from '@/lib/api/types'; +import type { GitLabIssueSummary } from '@/lib/api/types'; import { useI18n } from '@/lib/i18n'; -const issueStateColor = (state: string): string => { - switch (state) { - case 'closed': - return 'var(--pr-closed)'; - default: - return 'var(--pr-open)'; - } -}; - const issueLabelBadgeClass = 'inline-flex items-center rounded border border-border/60 bg-surface-elevated px-1.5 py-px typography-micro text-foreground'; /** - * Open GitLab issues for the context panel's MR view. List and detail are - * fetched lazily: the parent only mounts this component while the Issues tab - * is active. Read-only by design — no create, update, or close actions. + * Open GitLab issues for the context panel's MR view. The list is fetched + * lazily (the parent only mounts this component while the Issues tab is + * active); selecting a row mounts the shared `ForgeEntityDetailView` for the + * issue detail. Read-only by design — no create, update, or close actions. */ export const GitLabIssuesSection: React.FC<{ directory: string }> = ({ directory }) => { const { t } = useI18n(); const { gitlab } = useRuntimeAPIs(); const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); const setSettingsPage = useUIStore((state) => state.setSettingsPage); - const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); // ---- Open issues list ---------------------------------------------------- @@ -46,10 +37,9 @@ export const GitLabIssuesSection: React.FC<{ directory: string }> = ({ directory // ---- Selected issue detail ------------------------------------------------ const [selectedNumber, setSelectedNumber] = React.useState(null); - const [issue, setIssue] = React.useState(null); - const [comments, setComments] = React.useState([]); - const [detailLoading, setDetailLoading] = React.useState(false); - const [detailError, setDetailError] = React.useState(null); + const [selectedUrl, setSelectedUrl] = React.useState(null); + + const issueProvider = React.useMemo(() => (gitlab ? buildForgeProvider('gitlab', { gitlab }) : null), [gitlab]); const retry = React.useCallback(() => setRetryToken((value) => value + 1), []); @@ -69,10 +59,7 @@ export const GitLabIssuesSection: React.FC<{ directory: string }> = ({ directory setListError(null); setListNotConnected(false); setSelectedNumber(null); - setIssue(null); - setComments([]); - setDetailLoading(false); - setDetailError(null); + setSelectedUrl(null); }, [directory]); React.useEffect(() => { @@ -133,164 +120,42 @@ export const GitLabIssuesSection: React.FC<{ directory: string }> = ({ directory } }, [directory, gitlab, listHasMore, listLoading, listLoadingMore, listPage]); - // Fetch the issue and its comments in parallel whenever a row is selected. A - // cancelled flag keeps a stale selection from overwriting a newer one. - React.useEffect(() => { - if (selectedNumber === null || !gitlab?.issueGet || !gitlab.issueComments) { - return; - } - let cancelled = false; - setDetailLoading(true); - setDetailError(null); - setIssue(null); - setComments([]); - void Promise.all([ - gitlab.issueGet(directory, selectedNumber), - gitlab.issueComments(directory, selectedNumber), - ]) - .then(([issueResult, commentsResult]) => { - if (cancelled) { - return; - } - if (issueResult.connected === false || commentsResult.connected === false) { - setDetailError(t('contextPanel.gitlabMr.error.notConnected')); - return; - } - if (!issueResult.issue) { - setDetailError(t('session.gitlabIssuePicker.error.issueNotFound')); - return; - } - setIssue(issueResult.issue); - setComments(commentsResult.comments ?? []); - }) - .catch((error) => { - if (!cancelled) { - setDetailError(error instanceof Error ? error.message : String(error)); - } - }) - .finally(() => { - if (!cancelled) { - setDetailLoading(false); - } - }); - return () => { - cancelled = true; - }; - }, [directory, gitlab, selectedNumber, t]); + const selectIssue = React.useCallback((item: GitLabIssueSummary) => { + setSelectedNumber(item.number); + setSelectedUrl(item.url); + }, []); const backToIssues = React.useCallback(() => { setSelectedNumber(null); - setIssue(null); - setComments([]); - setDetailLoading(false); - setDetailError(null); + setSelectedUrl(null); }, []); - const formatTimestamp = React.useCallback((value?: string) => { - if (!value) { - return ''; - } - const timestamp = Date.parse(value); - if (!Number.isFinite(timestamp)) { - return value; - } - return formatDateTimeForPreference(timestamp, timeFormatPreference, { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: '2-digit', - }); - }, [timeFormatPreference]); - if (selectedNumber !== null) { return (
-
+
+ {selectedUrl ? ( + + ) : null}
- {detailLoading ? ( -
- - {t('contextPanel.gitlabMr.loading')} -
- ) : detailError ? ( -
-
{detailError}
- -
- ) : issue ? ( - <> -
-
- #{issue.number} {issue.title} -
-
- - - {issue.state === 'closed' ? t('contextPanel.gitlabMr.state.closed') : t('contextPanel.gitlabMr.state.opened')} - - {issue.labels.map((label) => ( - {label} - ))} -
- {issue.assignees && issue.assignees.length > 0 ? ( -
- {issue.assignees.map((assignee) => assignee.name?.trim() || assignee.username).join(', ')} -
- ) : null} - -
- -
-
{t('gitView.pr.field.description')}
- {issue.body?.trim() ? ( - - ) : ( -
{t('gitView.pr.noDescription')}
- )} -
- -
-
{t('gitView.pr.segment.comments')}
- {comments.length > 0 ? ( - comments.map((comment) => ( -
-
- - {comment.author?.name?.trim() || comment.author?.username || ''} - - {comment.createdAt ? ( - {formatTimestamp(comment.createdAt)} - ) : null} -
- -
- )) - ) : ( -
{t('gitView.pr.comments.empty')}
- )} -
- + {issueProvider ? ( + ) : null}
); @@ -333,7 +198,7 @@ export const GitLabIssuesSection: React.FC<{ directory: string }> = ({ directory
setSelectedNumber(item.number)} + onClick={() => selectIssue(item)} >

diff --git a/packages/ui/src/components/views/git/GiteaIssuesSection.tsx b/packages/ui/src/components/views/git/GiteaIssuesSection.tsx index 9d8a946c..f7176605 100644 --- a/packages/ui/src/components/views/git/GiteaIssuesSection.tsx +++ b/packages/ui/src/components/views/git/GiteaIssuesSection.tsx @@ -1,37 +1,28 @@ import React from 'react'; import { Icon } from '@/components/icon/Icon'; import { Button } from '@/components/ui/button'; -import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer'; +import { ForgeEntityDetailView } from '@/components/views/forge'; +import { buildForgeProvider } from '@/lib/forge'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useUIStore } from '@/stores/useUIStore'; import { useGiteaAuthStore } from '@/stores/useGiteaAuthStore'; -import { formatDateTimeForPreference } from '@/lib/timeFormat'; -import type { GiteaComment, GiteaIssue, GiteaIssueSummary } from '@/lib/api/types'; +import type { GiteaIssueSummary } from '@/lib/api/types'; import { useI18n } from '@/lib/i18n'; -const issueStateColor = (state: string): string => { - switch (state) { - case 'closed': - return 'var(--pr-closed)'; - default: - return 'var(--pr-open)'; - } -}; - const issueLabelBadgeClass = 'inline-flex items-center rounded border border-border/60 bg-surface-elevated px-1.5 py-px typography-micro text-foreground'; /** - * Open Gitea issues for the context panel's PR view. List and detail are - * fetched lazily: the parent only mounts this component while the Issues tab - * is active. Read-only by design — no create, update, or close actions. + * Open Gitea issues for the context panel's PR view. The list is fetched + * lazily (the parent only mounts this component while the Issues tab is + * active); selecting a row mounts the shared `ForgeEntityDetailView` for the + * issue detail. Read-only by design — no create, update, or close actions. */ export const GiteaIssuesSection: React.FC<{ directory: string }> = ({ directory }) => { const { t } = useI18n(); const { gitea } = useRuntimeAPIs(); const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); const setSettingsPage = useUIStore((state) => state.setSettingsPage); - const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); const giteaAuthStatus = useGiteaAuthStore((state) => state.status); const giteaAuthChecked = useGiteaAuthStore((state) => state.hasChecked); @@ -49,10 +40,9 @@ export const GiteaIssuesSection: React.FC<{ directory: string }> = ({ directory // ---- Selected issue detail ------------------------------------------------ const [selectedNumber, setSelectedNumber] = React.useState(null); - const [issue, setIssue] = React.useState(null); - const [comments, setComments] = React.useState([]); - const [detailLoading, setDetailLoading] = React.useState(false); - const [detailError, setDetailError] = React.useState(null); + const [selectedUrl, setSelectedUrl] = React.useState(null); + + const issueProvider = React.useMemo(() => (gitea ? buildForgeProvider('gitea', { gitea }) : null), [gitea]); const retry = React.useCallback(() => setRetryToken((value) => value + 1), []); @@ -77,10 +67,7 @@ export const GiteaIssuesSection: React.FC<{ directory: string }> = ({ directory setListError(null); setListNotConnected(false); setSelectedNumber(null); - setIssue(null); - setComments([]); - setDetailLoading(false); - setDetailError(null); + setSelectedUrl(null); }, [directory]); React.useEffect(() => { @@ -141,159 +128,42 @@ export const GiteaIssuesSection: React.FC<{ directory: string }> = ({ directory } }, [directory, gitea, listHasMore, listLoading, listLoadingMore, listPage]); - // Fetch the issue and its comments in parallel whenever a row is selected. A - // cancelled flag keeps a stale selection from overwriting a newer one. - React.useEffect(() => { - if (selectedNumber === null || !gitea?.issueGet || !gitea.issueComments) { - return; - } - let cancelled = false; - setDetailLoading(true); - setDetailError(null); - setIssue(null); - setComments([]); - void Promise.all([ - gitea.issueGet(directory, selectedNumber), - gitea.issueComments(directory, selectedNumber), - ]) - .then(([issueResult, commentsResult]) => { - if (cancelled) { - return; - } - if (issueResult.connected === false || commentsResult.connected === false) { - setDetailError(t('contextPanel.giteaPr.error.notConnected')); - return; - } - if (!issueResult.issue) { - setDetailError(t('session.giteaIssuePicker.error.issueNotFound')); - return; - } - setIssue(issueResult.issue); - setComments(commentsResult.comments ?? []); - }) - .catch((error) => { - if (!cancelled) { - setDetailError(error instanceof Error ? error.message : String(error)); - } - }) - .finally(() => { - if (!cancelled) { - setDetailLoading(false); - } - }); - return () => { - cancelled = true; - }; - }, [directory, gitea, selectedNumber, t]); + const selectIssue = React.useCallback((item: GiteaIssueSummary) => { + setSelectedNumber(item.number); + setSelectedUrl(item.url); + }, []); const backToIssues = React.useCallback(() => { setSelectedNumber(null); - setIssue(null); - setComments([]); - setDetailLoading(false); - setDetailError(null); + setSelectedUrl(null); }, []); - const formatTimestamp = React.useCallback((value?: string) => { - if (!value) { - return ''; - } - const timestamp = Date.parse(value); - if (!Number.isFinite(timestamp)) { - return value; - } - return formatDateTimeForPreference(timestamp, timeFormatPreference, { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: '2-digit', - }); - }, [timeFormatPreference]); - if (selectedNumber !== null) { return (

-
+
+ {selectedUrl ? ( + + ) : null}
- {detailLoading ? ( -
- - {t('contextPanel.giteaPr.loading')} -
- ) : detailError ? ( -
-
{detailError}
- -
- ) : issue ? ( - <> -
-
- #{issue.number} {issue.title} -
-
- - - {issue.state === 'closed' ? t('contextPanel.giteaPr.state.closed') : t('contextPanel.giteaPr.state.opened')} - - {issue.labels.map((label) => ( - {label} - ))} -
- -
- -
-
{t('gitView.pr.field.description')}
- {issue.body?.trim() ? ( - - ) : ( -
{t('gitView.pr.noDescription')}
- )} -
- -
-
{t('gitView.pr.segment.comments')}
- {comments.length > 0 ? ( - comments.map((comment) => ( -
-
- - {comment.author?.username || ''} - - {comment.createdAt ? ( - {formatTimestamp(comment.createdAt)} - ) : null} -
- -
- )) - ) : ( -
{t('gitView.pr.comments.empty')}
- )} -
- + {issueProvider ? ( + ) : null}
); @@ -336,7 +206,7 @@ export const GiteaIssuesSection: React.FC<{ directory: string }> = ({ directory
setSelectedNumber(item.number)} + onClick={() => selectIssue(item)} >

diff --git a/packages/ui/src/components/views/git/PullRequestSection.tsx b/packages/ui/src/components/views/git/PullRequestSection.tsx index 08546c36..313879a0 100644 --- a/packages/ui/src/components/views/git/PullRequestSection.tsx +++ b/packages/ui/src/components/views/git/PullRequestSection.tsx @@ -31,6 +31,9 @@ import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { getGitHubPrStatusKey, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore'; import { getPrContextKey, usePrContextStore } from '@/stores/usePrContextStore'; import { summarizeCheckRuns } from '@/lib/githubChecks'; +import { buildForgeProvider, mapGithubPr } from '@/lib/forge'; +import type { ForgeCommit, ForgeFileChange } from '@/lib/forge'; +import { ForgeCommitsSection, ForgeFilesDiffSection, ForgeMetadataChips } from '@/components/views/forge'; import type { GitHubPullRequest, GitHubCheckRun, @@ -41,7 +44,7 @@ import type { import { useI18n } from '@/lib/i18n'; type MergeMethod = 'merge' | 'squash' | 'rebase'; -type PrSegment = 'overview' | 'checks' | 'comments'; +type PrSegment = 'overview' | 'checks' | 'comments' | 'commits' | 'files'; const PR_CHECKS_AUTO_REFRESH_MS = 35_000; @@ -546,6 +549,100 @@ export const PullRequestSection: React.FC<{ const isHistoricalPr = pr?.state === 'merged' || pr?.state === 'closed'; const livePr = isHistoricalPr ? null : pr; + // Forge rich-view tabs (commits / files): the provider facade wraps the raw + // GitHub API with normalized result envelopes. `forgePr` is the status PR + // projected onto the forge vocabulary (labels/assignees/milestone come from + // the enriched summary the server already returns). + const forgeProvider = React.useMemo(() => (github ? buildForgeProvider('github', { github }) : null), [github]); + const forgePr = React.useMemo(() => (pr ? mapGithubPr(pr) : null), [pr]); + const prSourceRepo = React.useMemo(() => { + if (!status?.repo) { + return null; + } + return `${status.repo.owner}/${status.repo.repo}`; + }, [status?.repo]); + + const [commits, setCommits] = React.useState(null); + const [commitsLoading, setCommitsLoading] = React.useState(false); + const [commitsError, setCommitsError] = React.useState(null); + const [prFiles, setPrFiles] = React.useState(null); + const [prDiff, setPrDiff] = React.useState(null); + const [filesLoading, setFilesLoading] = React.useState(false); + const [filesError, setFilesError] = React.useState(null); + + // Key on the PR number, not the status object: periodic status refreshes + // create a new object identity for the same PR, which must not re-trigger a + // refetch (and a loading flicker) of an already loaded tab. + const prNumber = pr?.number ?? null; + + // Commits and files load lazily per segment and bypass the shared + // usePrContextStore flow that Overview/Checks/Comments rely on. Leaving the + // segment cancels the in-flight request so a stale result never overwrites + // a newer segment's data. + React.useEffect(() => { + if (activeSegment !== 'commits' || prNumber === null || !forgeProvider?.getCommits) { + return; + } + let cancelled = false; + setCommitsLoading(true); + setCommitsError(null); + void forgeProvider + .getCommits(directory, prNumber, { sourceRepo: prSourceRepo }) + .then((result) => { + if (cancelled) { + return; + } + setCommits(result.commits); + setCommitsError(result.error ?? null); + }) + .catch((e) => { + if (cancelled) { + return; + } + setCommitsError(e instanceof Error ? e.message : String(e)); + }) + .finally(() => { + if (!cancelled) { + setCommitsLoading(false); + } + }); + return () => { + cancelled = true; + }; + }, [activeSegment, directory, forgeProvider, prNumber, prSourceRepo]); + + React.useEffect(() => { + if (activeSegment !== 'files' || prNumber === null || !forgeProvider) { + return; + } + let cancelled = false; + setFilesLoading(true); + setFilesError(null); + void forgeProvider + .getPullRequestContext(directory, prNumber, { includeDiff: true, sourceRepo: prSourceRepo }) + .then((result) => { + if (cancelled) { + return; + } + setPrFiles(result.files ?? null); + setPrDiff(result.diff ?? null); + }) + .catch((e) => { + if (cancelled) { + return; + } + setFilesError(e instanceof Error ? e.message : String(e)); + }) + .finally(() => { + if (!cancelled) { + setFilesLoading(false); + } + }); + return () => { + cancelled = true; + }; + }, [activeSegment, directory, forgeProvider, prNumber, prSourceRepo]); + const prContextKey = livePr ? getPrContextKey(directory, livePr.number) : null; const prContextEntry = usePrContextStore((state) => (prContextKey ? state.entries[prContextKey] : undefined)); const ensurePrContext = usePrContextStore((state) => state.ensure); @@ -1670,6 +1767,14 @@ export const PullRequestSection: React.FC<{ ? `${t('gitView.pr.segment.comments')} ${(prContext.issueComments?.length ?? 0) + (prContext.reviewComments?.length ?? 0)}` : t('gitView.pr.segment.comments'), }, + { + id: 'commits', + label: t('forge.section.commits'), + }, + { + id: 'files', + label: t('forge.section.files'), + }, ]} activeId={activeSegment} onSelect={(segmentId) => setActiveSegment(segmentId as PrSegment)} @@ -1762,6 +1867,8 @@ export const PullRequestSection: React.FC<{ ) : null}

+ {forgePr ? : null} + {isEditingPr ? (