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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user