feat(ui): rich forge entity views — commits, files/diff, timeline, checks, metadata chips

- 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
This commit is contained in:
2026-08-16 16:29:24 +00:00
parent f02c33700b
commit 92f0eced34
44 changed files with 3996 additions and 684 deletions
@@ -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<GitLabMergeRequestContextResult | null>(null);
const [contextLoading, setContextLoading] = React.useState(false);
const [contextError, setContextError] = React.useState<string | null>(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 (
<ScrollableOverlay
@@ -733,55 +710,15 @@ export const GitLabMrView: React.FC = () => {
</div>
) : null}
{contextOpen ? (
{contextOpen && mrProvider ? (
<div className="flex min-w-0 flex-col gap-3 border-t border-border/40 pt-3">
{contextLoading ? (
<div className="flex items-center gap-2 typography-micro text-muted-foreground">
<Icon name="loader-4" className="size-4 animate-spin" />
{t('contextPanel.gitlabMr.loading')}
</div>
) : contextError ? (
<div className="typography-micro text-muted-foreground break-words">{contextError}</div>
) : (
<>
<div className="flex min-w-0 flex-col gap-1">
<div className="typography-micro font-semibold text-foreground">{t('gitView.pr.field.description')}</div>
{contextResult?.mr?.body?.trim() ? (
<SimpleMarkdownRenderer
content={contextResult.mr.body}
className="typography-markdown-body min-w-0 text-muted-foreground break-words"
enableFileReferences={false}
/>
) : (
<div className="typography-micro text-muted-foreground">{t('gitView.pr.noDescription')}</div>
)}
</div>
<div className="flex min-w-0 flex-col gap-2">
<div className="typography-micro font-semibold text-foreground">{t('gitView.pr.segment.comments')}</div>
{mrComments.length > 0 ? (
mrComments.map((comment) => (
<div key={comment.id} className="flex min-w-0 flex-col gap-1 rounded-lg bg-surface-elevated px-3 py-2">
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 typography-micro text-muted-foreground">
<span className="text-foreground whitespace-nowrap">
{comment.author?.name?.trim() || comment.author?.username || ''}
</span>
{comment.createdAt ? (
<span className="whitespace-nowrap">{formatTimestamp(comment.createdAt)}</span>
) : null}
</div>
<SimpleMarkdownRenderer
content={comment.body || ''}
className="typography-markdown-body text-foreground break-words"
enableFileReferences={false}
/>
</div>
))
) : (
<div className="typography-micro text-muted-foreground">{t('gitView.pr.comments.empty')}</div>
)}
</div>
</>
)}
<ForgeEntityDetailView
provider={mrProvider}
directory={currentDirectory}
number={branchMr.number}
options={{ kind: 'pull' }}
onOpenSettings={openGitLabSettings}
/>
</div>
) : null}
</div>