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>
@@ -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<GiteaPullRequestContextResult | null>(null);
const [contextLoading, setContextLoading] = React.useState(false);
const [contextError, setContextError] = React.useState<string | null>(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 (
<ScrollableOverlay
@@ -709,55 +688,15 @@ export const GiteaPrView: React.FC = () => {
</div>
) : null}
{contextOpen ? (
{contextOpen && prProvider ? (
<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.giteaPr.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?.pr?.body?.trim() ? (
<SimpleMarkdownRenderer
content={contextResult.pr.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>
{prComments.length > 0 ? (
prComments.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?.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={prProvider}
directory={currentDirectory}
number={branchPr.number}
options={{ kind: 'pull' }}
onOpenSettings={openGiteaSettings}
/>
</div>
) : null}
</div>
@@ -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 (
<div className="rounded-md border border-border/40">
<button
type="button"
disabled={!hasDetails}
onClick={onToggle}
className="flex w-full items-center gap-2 px-2.5 py-2 text-left disabled:cursor-default"
aria-expanded={expanded}
>
{isPending ? (
<Icon name={stateIcon(state)} className="size-4 shrink-0 animate-spin text-[var(--status-warning)]" />
) : (
<Icon name={stateIcon(state)} className="size-4 shrink-0" style={{ color: stateColor(state) }} />
)}
<span className="min-w-0 flex-1 truncate typography-ui-label text-foreground">{name}</span>
{duration ? <span className="shrink-0 typography-micro tabular-nums text-muted-foreground">{duration}</span> : null}
{description ? (
<span className="hidden min-w-0 flex-1 truncate typography-micro text-muted-foreground sm:block sm:max-w-[40%]">
{description}
</span>
) : null}
<span className="shrink-0 typography-micro text-muted-foreground">{t(`forge.checks.state.${state}` as never)}</span>
{hasDetails ? (
<Icon
name={expanded ? 'arrow-down-s' : 'arrow-right-s'}
className="size-4 shrink-0 text-muted-foreground"
/>
) : null}
</button>
{expanded && hasDetails ? (
<div className="min-w-0 overflow-hidden border-t border-border/40 p-2.5">
{details?.title ? <div className="typography-micro text-foreground">{details.title}</div> : null}
{details?.summary ? (
<div className="typography-micro text-muted-foreground whitespace-pre-wrap break-words">{details.summary}</div>
) : null}
{details?.text ? (
<div className="max-h-48 overflow-y-auto whitespace-pre-wrap break-words rounded border border-border/40 bg-transparent px-2 py-2 typography-micro text-muted-foreground">
{details.text}
</div>
) : null}
{details?.annotations && details.annotations.length > 0 ? (
<div className="mt-1 space-y-1">
{details.annotations.map((annotation, idx) => (
<div
key={`${annotation.path ?? 'file'}:${annotation.startLine ?? idx}:${idx}`}
className="rounded border border-[var(--status-error-border)] bg-[var(--status-error-background)]/40 px-2 py-2"
>
<div className="typography-micro break-words text-[var(--status-error)]">
{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}`
: ''}
</div>
{annotation.message ? (
<div className="typography-micro mt-1 whitespace-pre-wrap break-words text-foreground">
{annotation.message}
</div>
) : null}
</div>
))}
</div>
) : null}
</div>
) : null}
</div>
);
};
const CommitStatusStrip: React.FC<{ summary: ForgeChecksSummary }> = ({ summary }) => {
const { t } = useI18n();
return (
<div className="flex flex-col gap-1.5">
<div className="typography-micro text-muted-foreground">{t('forge.checks.statusStrip')}</div>
<div className="flex flex-wrap gap-1.5">
{summary.checks.map((check, idx) => (
<span
key={`${check.name}:${idx}`}
className="inline-flex items-center gap-1.5 rounded-md border border-border/60 bg-surface-elevated px-2 py-0.5"
title={check.description ?? t(`forge.checks.state.${check.state}` as never)}
>
<span aria-hidden className="size-2 shrink-0 rounded-full" style={{ backgroundColor: stateColor(check.state) }} />
<span className="typography-micro text-foreground">{check.name}</span>
</span>
))}
</div>
</div>
);
};
/**
* 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<ForgeChecksSectionProps>(function ForgeChecksSection({ kind, summary, loading, error }) {
const { t } = useI18n();
const [expandedKeys, setExpandedKeys] = useState<Set<string>>(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 (
<div className="flex flex-col gap-2" data-testid="forge-checks-loading">
<Skeleton className="h-6 w-full" />
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-3/4" />
</div>
);
}
if (error) {
return (
<div className="flex items-center gap-2 rounded-md border border-[var(--status-error-border)] bg-[var(--status-error-background)]/40 px-3 py-2 typography-micro text-[var(--status-error)]">
<Icon name="error-warning" className="size-4 shrink-0" />
{error}
</div>
);
}
if (!summary || summary.checks.length === 0) {
return <p className="py-3 text-center typography-micro text-muted-foreground">{t('forge.checks.empty')}</p>;
}
if (kind === 'commit-statuses') {
return <CommitStatusStrip summary={summary} />;
}
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<div className="flex h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-muted/40">
{summary.success > 0 ? (
<div className="bg-[color:var(--status-success)]" style={{ width: `${(summary.success / summary.total) * 100}%` }} />
) : null}
{summary.failure > 0 ? (
<div className="bg-[color:var(--status-error)]" style={{ width: `${(summary.failure / summary.total) * 100}%` }} />
) : null}
{summary.pending > 0 ? (
<div className="bg-[color:var(--status-warning)]" style={{ width: `${(summary.pending / summary.total) * 100}%` }} />
) : null}
</div>
<span className="shrink-0 typography-micro tabular-nums text-muted-foreground">
{summary.success}/{summary.total} {t('gitView.pr.checks.label')}
</span>
</div>
<div className="flex flex-col gap-1.5">
{summary.checks.map((check, idx) => {
const key = `${check.name}:${idx}`;
return (
<CheckRunRow
key={key}
name={check.name}
state={check.state}
startedAt={check.startedAt}
completedAt={check.completedAt}
description={check.description}
details={check.details}
expanded={expandedKeys.has(key)}
onToggle={() => toggle(key)}
/>
);
})}
</div>
</div>
);
});
@@ -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<ForgeCommitsSectionProps>(function ForgeCommitsSection({ commits, loading, error }) {
const { t } = useI18n();
const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
const [expandedShas, setExpandedShas] = useState<Set<string>>(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 (
<div className="flex flex-col gap-2" data-testid="forge-commits-loading">
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-4/5" />
</div>
);
}
if (error) {
return (
<div className="flex items-center gap-2 rounded-md border border-[var(--status-error-border)] bg-[var(--status-error-background)]/40 px-3 py-2 typography-micro text-[var(--status-error)]">
<Icon name="error-warning" className="size-4 shrink-0" />
{error}
</div>
);
}
if (!commits || commits.length === 0) {
return <p className="py-3 text-center typography-micro text-muted-foreground">{t('forge.commits.empty')}</p>;
}
return (
<ul className="flex flex-col">
{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 (
<li key={commit.sha}>
<button
type="button"
onClick={() => toggle(commit.sha)}
className="flex w-full items-start gap-3 px-3 py-2 text-left transition-colors hover:bg-[var(--interactive-hover)]/40"
aria-expanded={isExpanded}
>
<div className="-my-2 shrink-0 self-stretch">
<GitGraphSegment laned={item} totalLanes={totalLanes} isExpanded={isExpanded} />
</div>
<div className="min-w-0 flex-1">
<p className="typography-ui-label font-medium text-foreground line-clamp-1">
{commit.summary ?? commit.message}
</p>
<div className="flex min-w-0 items-center gap-1 typography-meta text-muted-foreground">
{author ? <span className="min-w-0 truncate">{author}</span> : null}
{author && commit.committedAt ? <span className="shrink-0">·</span> : null}
{commit.committedAt ? (
<span className="min-w-0 truncate">{formatCommitDate(commit.committedAt, timeFormatPreference)}</span>
) : null}
<span className="shrink-0">·</span>
<code className="shrink-0 font-mono">{commit.shortSha}</code>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-5 shrink-0 px-1"
aria-label={t('gitView.history.copySha')}
onClick={(event) => {
event.stopPropagation();
void copyHash(commit.sha);
}}
>
<Icon name="file-copy" className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={8}>{t('gitView.history.copySha')}</TooltipContent>
</Tooltip>
</div>
</div>
</button>
{isExpanded ? (
<div className="border-t border-border/40 px-3 pb-2 pl-8">
<p className="typography-micro text-foreground whitespace-pre-wrap break-words">{commit.message}</p>
{commit.parents.length > 0 ? (
<p className="mt-1 flex flex-wrap items-center gap-1 typography-micro text-muted-foreground">
<span>{t('forge.commits.parents')}:</span>
{commit.parents.map((parent) => (
<code key={parent} className="font-mono">{parent.slice(0, 7)}</code>
))}
</p>
) : null}
</div>
) : null}
</li>
);
})}
</ul>
);
});
@@ -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 }) => (
<h4 className="typography-ui-label font-semibold text-foreground">{children}</h4>
);
const LoadingBlock: React.FC<{ label: string }> = ({ label }) => (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 py-1 typography-micro text-muted-foreground">
<Icon name="loader-4" className="size-4 animate-spin" />
{label}
</div>
<Skeleton className="h-6 w-2/3" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-16 w-full" />
</div>
);
const ErrorBlock: React.FC<{ message: string }> = ({ message }) => (
<div className="flex items-center gap-2 rounded-md border border-[var(--status-error-border)] bg-[var(--status-error-background)]/40 px-3 py-2 typography-micro text-[var(--status-error)]">
<Icon name="error-warning" className="size-4 shrink-0" />
{message}
</div>
);
const NotConnectedBlock: React.FC<{ onOpenSettings?: () => void }> = ({ onOpenSettings }) => {
const { t } = useI18n();
return (
<div className="flex flex-col gap-2 rounded-md border border-border/40 bg-surface-elevated px-4 py-3">
<div className="typography-ui-label text-foreground">{t('forge.notConnected')}</div>
{onOpenSettings ? (
<Button variant="outline" size="sm" className="w-fit" onClick={onOpenSettings}>
{t('gitView.pr.actions.openSettings')}
</Button>
) : null}
</div>
);
};
/**
* 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<ForgeEntityDetailViewProps> = ({ provider, directory, number, options, onOpenSettings }) => {
const { t } = useI18n();
const isIssue = (options?.kind ?? 'pull') === 'issue';
const sourceRepo = options?.sourceRepo ?? null;
const [pull, setPull] = useState<PullData | null>(null);
const [issueDetail, setIssueDetail] = useState<ForgeIssueDetail | null>(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<ForgeComment[]>(() => {
if (isIssue) return issueDetail?.comments ?? [];
const context = pull?.context;
return [...(context?.issueComments ?? []), ...(context?.reviewComments ?? [])];
}, [isIssue, issueDetail?.comments, pull?.context]);
const timelineEvents = useMemo<ForgeTimelineEvent[]>(() => 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 <LoadingBlock label={t('forge.loading')} />;
}
if (isIssue) {
if (!issueDetail || !issueDetail.connected) {
return <NotConnectedBlock onOpenSettings={onOpenSettings} />;
}
const issue = issueDetail.issue;
if (!issue) {
return <ErrorBlock message={t('forge.error')} />;
}
const issueState = issue.state === 'closed' ? 'closed' : 'open';
const stateColor = `var(--pr-${issueState})`;
return (
<div className="flex min-w-0 flex-col gap-4">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<Icon name="sticky-note" className="size-4 shrink-0" style={{ color: stateColor }} />
<h3 className="min-w-0 truncate typography-ui-header font-semibold text-foreground">{issue.title}</h3>
<span className="typography-meta text-muted-foreground">#{issue.number}</span>
<span className="typography-micro shrink-0" style={{ color: stateColor }}>
{t(`forge.state.${issueState}`)}
</span>
</div>
<ForgeMetadataChips kind="issue" issue={issue} />
{issue.body ? (
<SimpleMarkdownRenderer content={issue.body} className={markdownClassName} enableFileReferences={false} />
) : null}
<section aria-label={t('forge.section.timeline')}>
<SectionTitle>{t('forge.section.timeline')}</SectionTitle>
<ForgeTimelineSection
events={[]}
comments={issueDetail.comments ?? []}
error={issueDetail.commentsError ?? null}
/>
</section>
</div>
);
}
if (!pull || !pull.context || !pull.context.connected) {
return <NotConnectedBlock onOpenSettings={onOpenSettings} />;
}
const context = pull.context;
const pr = context.pr;
if (!pr) {
return <ErrorBlock message={t('forge.error')} />;
}
const stateColor = `var(--pr-${pr.state})`;
const stateIcon = pr.state === 'merged'
? 'git-merge'
: pr.state === 'closed'
? 'git-close-pull-request'
: 'git-pull-request';
return (
<div className="flex min-w-0 flex-col gap-4">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<Icon name={stateIcon} className="size-4 shrink-0" style={{ color: stateColor }} />
<h3 className="min-w-0 truncate typography-ui-header font-semibold text-foreground">{pr.title}</h3>
<span className="typography-meta text-muted-foreground">#{pr.number}</span>
<span className="typography-micro shrink-0" style={{ color: stateColor }}>
{t(`forge.state.${pr.state}` as never)}
</span>
{pr.draft ? (
<span className="inline-flex items-center rounded border border-border/60 bg-surface-elevated px-1.5 py-px typography-micro text-foreground">
{t('forge.draft')}
</span>
) : null}
</div>
<ForgeMetadataChips kind="pull" pr={pr} />
{checksForPull ? (
<section aria-label={t('forge.section.checks')}>
<SectionTitle>{t('forge.section.checks')}</SectionTitle>
<ForgeChecksSection
kind={checksForPull.kind}
summary={checksForPull.summary}
error={provider.capabilities.checks === 'commit-statuses' ? (pull.checks?.error ?? null) : null}
/>
</section>
) : null}
{typeof provider.getCommits === 'function' ? (
<section aria-label={t('forge.section.commits')}>
<SectionTitle>{t('forge.section.commits')}</SectionTitle>
<ForgeCommitsSection commits={pull.commits?.commits ?? null} error={pull.commits?.error ?? null} />
</section>
) : null}
<section aria-label={t('forge.section.files')}>
<SectionTitle>{t('forge.section.files')}</SectionTitle>
<ForgeFilesDiffSection files={context.files ?? null} diff={context.diff} />
</section>
<section aria-label={t('forge.section.timeline')}>
<SectionTitle>{t('forge.section.timeline')}</SectionTitle>
<ForgeTimelineSection
events={timelineEvents}
comments={mergedComments}
error={pull.timeline?.error ?? null}
/>
</section>
</div>
);
};
@@ -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<string, { code: string; color: string }> = {
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<ForgeFilesDiffSectionProps>(function ForgeFilesDiffSection({ files, diff, loading, error }) {
const { t } = useI18n();
const [openPaths, setOpenPaths] = useState<Set<string>>(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<string, FileDiffMetadata | null>();
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 (
<div className="flex flex-col gap-2" data-testid="forge-files-loading">
<Skeleton className="h-7 w-full" />
<Skeleton className="h-7 w-full" />
<Skeleton className="h-7 w-3/4" />
</div>
);
}
if (error) {
return (
<div className="flex items-center gap-2 rounded-md border border-[var(--status-error-border)] bg-[var(--status-error-background)]/40 px-3 py-2 typography-micro text-[var(--status-error)]">
<Icon name="error-warning" className="size-4 shrink-0" />
{error}
</div>
);
}
if (!files || files.length === 0) {
return <p className="py-3 text-center typography-micro text-muted-foreground">{t('forge.files.empty')}</p>;
}
return (
<ul className="flex flex-col">
{files.map((file) => {
const descriptor = descriptorFor(file.status);
const isOpen = openPaths.has(file.filename);
const fileDiff = fileDiffs.get(file.filename) ?? null;
return (
<li key={file.filename}>
<button
type="button"
onClick={() => toggle(file.filename)}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors hover:bg-[var(--interactive-hover)]/40"
aria-expanded={isOpen}
>
<span
className="w-4 shrink-0 text-center typography-micro font-semibold"
style={{ color: descriptor.color }}
aria-hidden
>
{descriptor.code}
</span>
<span className="min-w-0 flex-1 truncate typography-ui-label text-foreground" title={file.filename}>
{file.filename}
</span>
<span className="shrink-0 typography-micro tabular-nums">
<span style={{ color: 'var(--status-success)' }}>+{file.additions ?? 0}</span>
<span className="text-muted-foreground"> / </span>
<span style={{ color: 'var(--status-error)' }}>-{file.deletions ?? 0}</span>
</span>
<Icon
name={isOpen ? 'arrow-down-s' : 'arrow-right-s'}
className="size-3 shrink-0 text-muted-foreground"
/>
</button>
{isOpen ? (
<div className="mx-2 mb-1 max-h-[400px] overflow-y-auto rounded border border-border/40">
{fileDiff ? (
<PierreDiffViewer
original=""
modified=""
fileDiff={fileDiff}
language={getLanguageFromExtension(file.filename) || ''}
fileName={file.filename}
renderSideBySide={false}
layout="inline"
enableComments={false}
/>
) : (
<p className="px-3 py-2 typography-micro text-muted-foreground">{t('forge.files.noDiff')}</p>
)}
</div>
) : null}
</li>
);
})}
</ul>
);
});
@@ -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 <img src={user.avatarUrl} alt={user.login} className={`${avatarSize} object-cover`} />;
}
return (
<span className={`${avatarSize} flex items-center justify-center bg-interactive-hover text-[10px] font-medium text-foreground`}>
{initial}
</span>
);
};
/**
* 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<ForgeMetadataChipsProps>(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 (
<div
className="flex flex-wrap items-center gap-1.5"
role="group"
aria-label={t('forge.section.metadata')}
>
{labels.map((label) => {
const color = resolveLabelColor(label.color);
return (
<span key={label.name} className={chipClassName} title={label.description || label.name}>
<span
aria-hidden
className="size-2 rounded-full"
style={{ backgroundColor: color ?? 'var(--status-info)' }}
/>
{label.name}
</span>
);
})}
{assignees.map((assignee) => (
<span key={assignee.id} className={chipClassName} title={assignee.login}>
<Avatar user={assignee} />
{assignee.login}
</span>
))}
{entity.milestone ? (
<span className={chipClassName} title={entity.milestone.title}>
<Icon name="target" className="size-3 text-muted-foreground" />
{entity.milestone.title}
</span>
) : null}
{entity.author ? (
<span className={chipClassName} title={`${t('forge.author')}: ${entity.author.login}`}>
<Avatar user={entity.author} />
{entity.author.login}
</span>
) : null}
{entity.createdAt ? (
<span className={chipClassName} title={`${t('forge.created')}: ${formatDate(entity.createdAt)}`}>
<Icon name="calendar" className="size-3 text-muted-foreground" />
{formatDate(entity.createdAt)}
</span>
) : null}
{entity.updatedAt ? (
<span className={chipClassName} title={`${t('forge.updated')}: ${formatDate(entity.updatedAt)}`}>
<Icon name="refresh" className="size-3 text-muted-foreground" />
{formatDate(entity.updatedAt)}
</span>
) : null}
{kind === 'pull' && baseRef && headRef ? (
<span
className={chipClassName}
title={t('forge.baseToHead', { base: baseRef, head: headRef })}
>
<code className="font-mono">{baseRef}</code>
<Icon name="arrow-go-forward" className="size-3 text-muted-foreground" />
<code className="font-mono">{headRef}</code>
</span>
) : null}
</div>
);
});
@@ -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<ForgeTimelineEventType, IconName> = {
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<Record<ForgeTimelineEventType, string>> = {
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 (
<div className="absolute left-0 top-0 z-10 flex size-8 items-center justify-center overflow-hidden rounded-full border border-border/60 bg-surface-elevated text-xs text-muted-foreground">
{author?.avatarUrl ? (
<img src={author.avatarUrl} alt={label} className="h-full w-full object-cover" />
) : (
<span>{initial}</span>
)}
</div>
);
};
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 (
<span className="inline-flex items-center gap-1 rounded border border-border/60 bg-transparent px-1.5 py-px typography-micro text-muted-foreground" title={label}>
<Icon name="code" className="size-3 shrink-0" />
<code className="font-mono">{text}</code>
</span>
);
};
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<ForgeTimelineSectionProps>(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<string, ForgeComment[]>();
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<string, ForgeComment[]>();
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<string>();
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<TimelineItem[]>(() => {
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 (
<div className="flex flex-col gap-3" data-testid="forge-timeline-loading">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-8 w-3/4" />
</div>
);
}
if (error) {
return (
<div className="flex items-center gap-2 rounded-md border border-[var(--status-error-border)] bg-[var(--status-error-background)]/40 px-3 py-2 typography-micro text-[var(--status-error)]">
<Icon name="error-warning" className="size-4 shrink-0" />
{error}
</div>
);
}
if (items.length === 0) {
return <p className="py-3 text-center typography-micro text-muted-foreground">{t('forge.timeline.empty')}</p>;
}
return (
<div className="relative pl-3">
<div>
{items.map((item, idx) => {
const isLast = idx === items.length - 1;
if (item.kind === 'event') {
const { event } = item;
return (
<div key={`event-${event.id}`} className="relative pl-10 pb-4 last:pb-0">
{!isLast ? <div className="absolute left-4 top-8 bottom-0 w-px bg-border/60" /> : null}
<div className="absolute left-0 top-0 z-10 flex size-8 items-center justify-center rounded-full border border-border/60 bg-surface-elevated">
<Icon
name={EVENT_ICONS[event.type] ?? EVENT_ICONS.other}
className="size-4"
style={{ color: EVENT_COLORS[event.type] ?? 'var(--surface-muted-foreground)' }}
/>
</div>
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 pt-1 typography-micro text-muted-foreground">
<span className="font-medium text-foreground">{t(`forge.timeline.event.${event.type}` as never)}</span>
{event.author ? <span>{event.author.login}</span> : null}
{event.createdAt ? <span>{formatTime(event.createdAt)}</span> : null}
</div>
{event.body ? (
<p className="mt-1 whitespace-pre-wrap break-words typography-micro text-muted-foreground">{event.body}</p>
) : null}
</div>
);
}
const { thread } = item;
const root = thread[0];
return (
<div key={`thread-${root.id}`} className="relative pl-10 pb-5 last:pb-0">
{!isLast ? <div className="absolute left-4 top-[2.375rem] bottom-[0.375rem] w-px bg-border/60" /> : null}
<CommentAvatar author={root.author} />
<div className="rounded-lg bg-surface-elevated px-3 py-2">
<div className="flex flex-col gap-3">
{thread.map((comment, commentIdx) => (
<div
key={comment.id}
className={commentIdx > 0 ? 'border-t border-border/40 pt-3' : ''}
>
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 typography-micro text-muted-foreground">
<span className="font-medium text-foreground">
{comment.author?.name ?? comment.author?.login ?? 'Unknown'}
</span>
{comment.createdAt ? <span>{formatTime(comment.createdAt)}</span> : null}
<InlineContextChip
comment={comment}
label={comment.line
? t('forge.comment.inlineAt', { path: comment.path ?? '', line: String(comment.line) })
: (comment.path ?? '')}
/>
</div>
<SimpleMarkdownRenderer
content={comment.body}
className="typography-markdown-body text-foreground break-words [&_a]:no-underline [&_a:hover]:no-underline"
enableFileReferences={false}
/>
</div>
))}
</div>
</div>
</div>
);
})}
</div>
</div>
);
});
@@ -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';
@@ -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<GitHubIssue | null>(null);
const [comments, setComments] = React.useState<GitHubIssueComment[]>([]);
const [detailLoading, setDetailLoading] = React.useState(false);
const [detailError, setDetailError] = React.useState<string | null>(null);
const [selectedUrl, setSelectedUrl] = React.useState<string | null>(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 (
<div className="flex min-w-0 flex-col gap-3">
<div className="flex min-w-0 items-center gap-1">
<div className="flex min-w-0 items-center justify-between gap-2">
<Button variant="ghost" size="sm" className="h-7 gap-1.5 px-2" onClick={backToIssues}>
<Icon name="arrow-left" className="size-4" />
{t('gitView.pullRequest.issues.detail.back')}
</Button>
{selectedUrl ? (
<Button variant="outline" size="sm" asChild className="h-7 w-fit gap-1.5 px-2">
<a href={selectedUrl} target="_blank" rel="noopener noreferrer">
<Icon name="external-link" className="size-4" />
{t('gitView.pullRequest.issues.detail.openInGitHub')}
</a>
</Button>
) : null}
</div>
{detailLoading ? (
<div className="flex items-center gap-2 typography-micro text-muted-foreground">
<Icon name="loader-4" className="size-4 animate-spin" />
{t('session.githubIssuePicker.loading.issues')}
</div>
) : detailError ? (
<div className="flex flex-col gap-2">
<div className="typography-micro text-muted-foreground break-words">{detailError}</div>
<Button variant="outline" size="sm" onClick={backToIssues} className="w-fit">
{t('gitView.pullRequest.issues.detail.back')}
</Button>
</div>
) : issue ? (
<>
<div className="flex min-w-0 flex-col gap-1">
<div className="typography-ui-header font-semibold text-foreground break-words leading-snug">
<span className="text-muted-foreground">#{issue.number}</span> {issue.title}
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 typography-micro text-muted-foreground">
<span className="inline-flex items-center gap-1" style={{ color: issueStateColor(issue.state) }}>
<span className="size-1.5 rounded-full" style={{ backgroundColor: issueStateColor(issue.state) }} />
</span>
{issue.labels?.map((label) => (
<span key={label.name} className={issueLabelBadgeClass}>{label.name}</span>
))}
</div>
{issue.assignees && issue.assignees.length > 0 ? (
<div className="typography-micro text-muted-foreground">
{issue.assignees.map((assignee) => assignee.name?.trim() || assignee.login).join(', ')}
</div>
) : null}
<Button variant="outline" size="sm" asChild className="h-7 w-fit gap-1.5 px-2">
<a href={issue.url} target="_blank" rel="noopener noreferrer">
<Icon name="external-link" className="size-4" />
{t('gitView.pullRequest.issues.detail.openInGitHub')}
</a>
</Button>
</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>
{issue.body?.trim() ? (
<SimpleMarkdownRenderer
content={issue.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>
{comments.length > 0 ? (
comments.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?.login || ''}
</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.pullRequest.issues.detail.commentsEmpty')}</div>
)}
</div>
</>
{issueProvider ? (
<ForgeEntityDetailView
provider={issueProvider}
directory={directory}
number={selectedNumber}
options={{
kind: 'issue',
sourceRepo: selectedSourceRepo ? `${selectedSourceRepo.owner}/${selectedSourceRepo.repo}` : null,
}}
onOpenSettings={openGitHubSettings}
/>
) : null}
</div>
);
@@ -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<number | null>(null);
const [issue, setIssue] = React.useState<GitLabIssue | null>(null);
const [comments, setComments] = React.useState<GitLabIssueComment[]>([]);
const [detailLoading, setDetailLoading] = React.useState(false);
const [detailError, setDetailError] = React.useState<string | null>(null);
const [selectedUrl, setSelectedUrl] = React.useState<string | null>(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 (
<div className="flex min-w-0 flex-col gap-3">
<div className="flex min-w-0 items-center gap-1">
<div className="flex min-w-0 items-center justify-between gap-2">
<Button variant="ghost" size="sm" className="h-7 gap-1.5 px-2" onClick={backToIssues}>
<Icon name="arrow-left" className="size-4" />
{t('contextPanel.gitlabMr.issues.detail.back')}
</Button>
{selectedUrl ? (
<Button variant="outline" size="sm" asChild className="h-7 w-fit gap-1.5 px-2">
<a href={selectedUrl} target="_blank" rel="noopener noreferrer">
<Icon name="external-link" className="size-4" />
{t('contextPanel.gitlabMr.openInGitLab')}
</a>
</Button>
) : null}
</div>
{detailLoading ? (
<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>
) : detailError ? (
<div className="flex flex-col gap-2">
<div className="typography-micro text-muted-foreground break-words">{detailError}</div>
<Button variant="outline" size="sm" onClick={backToIssues} className="w-fit">
{t('contextPanel.gitlabMr.issues.detail.back')}
</Button>
</div>
) : issue ? (
<>
<div className="flex min-w-0 flex-col gap-1">
<div className="typography-ui-header font-semibold text-foreground break-words leading-snug">
<span className="text-muted-foreground">#{issue.number}</span> {issue.title}
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 typography-micro text-muted-foreground">
<span className="inline-flex items-center gap-1" style={{ color: issueStateColor(issue.state) }}>
<span className="size-1.5 rounded-full" style={{ backgroundColor: issueStateColor(issue.state) }} />
{issue.state === 'closed' ? t('contextPanel.gitlabMr.state.closed') : t('contextPanel.gitlabMr.state.opened')}
</span>
{issue.labels.map((label) => (
<span key={label} className={issueLabelBadgeClass}>{label}</span>
))}
</div>
{issue.assignees && issue.assignees.length > 0 ? (
<div className="typography-micro text-muted-foreground">
{issue.assignees.map((assignee) => assignee.name?.trim() || assignee.username).join(', ')}
</div>
) : null}
<Button variant="outline" size="sm" asChild className="h-7 w-fit gap-1.5 px-2">
<a href={issue.url} target="_blank" rel="noopener noreferrer">
<Icon name="external-link" className="size-4" />
{t('contextPanel.gitlabMr.openInGitLab')}
</a>
</Button>
</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>
{issue.body?.trim() ? (
<SimpleMarkdownRenderer
content={issue.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>
{comments.length > 0 ? (
comments.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>
</>
{issueProvider ? (
<ForgeEntityDetailView
provider={issueProvider}
directory={directory}
number={selectedNumber}
options={{ kind: 'issue' }}
onOpenSettings={openGitLabSettings}
/>
) : null}
</div>
);
@@ -333,7 +198,7 @@ export const GitLabIssuesSection: React.FC<{ directory: string }> = ({ directory
<div
key={item.number}
className="group flex cursor-pointer items-center gap-2 rounded py-1.5 transition-colors hover:bg-interactive-hover/30"
onClick={() => setSelectedNumber(item.number)}
onClick={() => selectIssue(item)}
>
<div className="min-w-0 flex-1">
<p className="typography-small truncate text-foreground">
@@ -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<number | null>(null);
const [issue, setIssue] = React.useState<GiteaIssue | null>(null);
const [comments, setComments] = React.useState<GiteaComment[]>([]);
const [detailLoading, setDetailLoading] = React.useState(false);
const [detailError, setDetailError] = React.useState<string | null>(null);
const [selectedUrl, setSelectedUrl] = React.useState<string | null>(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 (
<div className="flex min-w-0 flex-col gap-3">
<div className="flex min-w-0 items-center gap-1">
<div className="flex min-w-0 items-center justify-between gap-2">
<Button variant="ghost" size="sm" className="h-7 gap-1.5 px-2" onClick={backToIssues}>
<Icon name="arrow-left" className="size-4" />
{t('contextPanel.giteaPr.issues.detail.back')}
</Button>
{selectedUrl ? (
<Button variant="outline" size="sm" asChild className="h-7 w-fit gap-1.5 px-2">
<a href={selectedUrl} target="_blank" rel="noopener noreferrer">
<Icon name="external-link" className="size-4" />
{t('contextPanel.giteaPr.openInGitea')}
</a>
</Button>
) : null}
</div>
{detailLoading ? (
<div className="flex items-center gap-2 typography-micro text-muted-foreground">
<Icon name="loader-4" className="size-4 animate-spin" />
{t('contextPanel.giteaPr.loading')}
</div>
) : detailError ? (
<div className="flex flex-col gap-2">
<div className="typography-micro text-muted-foreground break-words">{detailError}</div>
<Button variant="outline" size="sm" onClick={backToIssues} className="w-fit">
{t('contextPanel.giteaPr.issues.detail.back')}
</Button>
</div>
) : issue ? (
<>
<div className="flex min-w-0 flex-col gap-1">
<div className="typography-ui-header font-semibold text-foreground break-words leading-snug">
<span className="text-muted-foreground">#{issue.number}</span> {issue.title}
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 typography-micro text-muted-foreground">
<span className="inline-flex items-center gap-1" style={{ color: issueStateColor(issue.state) }}>
<span className="size-1.5 rounded-full" style={{ backgroundColor: issueStateColor(issue.state) }} />
{issue.state === 'closed' ? t('contextPanel.giteaPr.state.closed') : t('contextPanel.giteaPr.state.opened')}
</span>
{issue.labels.map((label) => (
<span key={label} className={issueLabelBadgeClass}>{label}</span>
))}
</div>
<Button variant="outline" size="sm" asChild className="h-7 w-fit gap-1.5 px-2">
<a href={issue.url} target="_blank" rel="noopener noreferrer">
<Icon name="external-link" className="size-4" />
{t('contextPanel.giteaPr.openInGitea')}
</a>
</Button>
</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>
{issue.body?.trim() ? (
<SimpleMarkdownRenderer
content={issue.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>
{comments.length > 0 ? (
comments.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?.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>
</>
{issueProvider ? (
<ForgeEntityDetailView
provider={issueProvider}
directory={directory}
number={selectedNumber}
options={{ kind: 'issue' }}
onOpenSettings={openGiteaSettings}
/>
) : null}
</div>
);
@@ -336,7 +206,7 @@ export const GiteaIssuesSection: React.FC<{ directory: string }> = ({ directory
<div
key={item.number}
className="group flex cursor-pointer items-center gap-2 rounded py-1.5 transition-colors hover:bg-interactive-hover/30"
onClick={() => setSelectedNumber(item.number)}
onClick={() => selectIssue(item)}
>
<div className="min-w-0 flex-1">
<p className="typography-small truncate text-foreground">
@@ -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<ForgeCommit[] | null>(null);
const [commitsLoading, setCommitsLoading] = React.useState(false);
const [commitsError, setCommitsError] = React.useState<string | null>(null);
const [prFiles, setPrFiles] = React.useState<ForgeFileChange[] | null>(null);
const [prDiff, setPrDiff] = React.useState<string | null>(null);
const [filesLoading, setFilesLoading] = React.useState(false);
const [filesError, setFilesError] = React.useState<string | null>(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}
</div>
{forgePr ? <ForgeMetadataChips kind="pull" pr={forgePr} /> : null}
{isEditingPr ? (
<Textarea
value={editBody}
@@ -1998,6 +2105,14 @@ export const PullRequestSection: React.FC<{
)}
</div>
) : null}
{activeSegment === 'commits' ? (
<ForgeCommitsSection commits={commits} loading={commitsLoading} error={commitsError} />
) : null}
{activeSegment === 'files' ? (
<ForgeFilesDiffSection files={prFiles} diff={prDiff} loading={filesLoading} error={filesError} />
) : null}
</div>
) : (
<div className="flex flex-col gap-3">