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:
@@ -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">
|
||||
|
||||
@@ -910,6 +910,10 @@ export type GitHubPullRequestSummary = GitHubPullRequest & {
|
||||
headLabel?: string;
|
||||
headRepo?: GitHubPullRequestHeadRepo | null;
|
||||
sourceRepo?: (GitHubRepoSelector & { source: string }) | null;
|
||||
labels?: GitHubIssueLabel[];
|
||||
assignees?: GitHubUserSummary[];
|
||||
milestone?: { title: string; state?: string } | null;
|
||||
commentsCount?: number;
|
||||
};
|
||||
|
||||
type GitHubPullRequestFile = {
|
||||
@@ -955,6 +959,38 @@ export type GitHubPullRequestContextResult = {
|
||||
checkRuns?: GitHubCheckRun[];
|
||||
};
|
||||
|
||||
export type GitHubPullRequestCommit = {
|
||||
sha: string;
|
||||
shortSha: string;
|
||||
message: string;
|
||||
summary?: string;
|
||||
author?: GitHubUserSummary | null;
|
||||
committer?: GitHubUserSummary | null;
|
||||
committedAt?: string;
|
||||
parents: string[];
|
||||
};
|
||||
|
||||
export type GitHubPullRequestCommitsResult = {
|
||||
connected: boolean;
|
||||
repo?: GitHubRepoRef | null;
|
||||
commits: GitHubPullRequestCommit[];
|
||||
};
|
||||
|
||||
export type GitHubTimelineEvent = {
|
||||
id: string;
|
||||
type: string;
|
||||
author?: GitHubUserSummary | null;
|
||||
createdAt?: string;
|
||||
body?: string | null;
|
||||
commitSha?: string | null;
|
||||
};
|
||||
|
||||
export type GitHubPullRequestTimelineResult = {
|
||||
connected: boolean;
|
||||
repo?: GitHubRepoRef | null;
|
||||
events: GitHubTimelineEvent[];
|
||||
};
|
||||
|
||||
export type GitHubPullRequestStatus = {
|
||||
connected: boolean;
|
||||
/** Server-side stamp of when the data was fetched from GitHub (ms epoch); survives server cache serves. */
|
||||
@@ -1027,6 +1063,9 @@ export type GitHubIssueSummary = {
|
||||
state: 'open' | 'closed';
|
||||
author?: GitHubUserSummary | null;
|
||||
labels?: GitHubIssueLabel[];
|
||||
assignees?: GitHubUserSummary[];
|
||||
milestone?: { title: string; state?: string } | null;
|
||||
commentsCount?: number;
|
||||
sourceRepo?: (GitHubRepoSelector & { source: string }) | null;
|
||||
};
|
||||
|
||||
@@ -1132,6 +1171,8 @@ export interface GitHubAPI {
|
||||
issuesList(directory: string, options?: { page?: number; query?: string }): Promise<GitHubIssuesListResult>;
|
||||
issueGet(directory: string, number: number, options?: { sourceRepo?: GitHubRepoSelector | null }): Promise<GitHubIssueGetResult>;
|
||||
issueComments(directory: string, number: number, options?: { sourceRepo?: GitHubRepoSelector | null }): Promise<GitHubIssueCommentsResult>;
|
||||
prCommits?(directory: string, number: number, options?: { sourceRepo?: GitHubRepoSelector | null }): Promise<GitHubPullRequestCommitsResult>;
|
||||
prTimeline?(directory: string, number: number, options?: { sourceRepo?: GitHubRepoSelector | null }): Promise<GitHubPullRequestTimelineResult>;
|
||||
repoUpstream(directory: string): Promise<GitHubRepoUpstreamResult>;
|
||||
repoBranches(owner: string, repo: string): Promise<string[]>;
|
||||
}
|
||||
@@ -1213,6 +1254,10 @@ export type GitLabMergeRequestSummary = {
|
||||
author: GitLabUserSummary;
|
||||
sourceBranch: string;
|
||||
targetBranch: string;
|
||||
labels?: string[];
|
||||
assignees?: GitLabUserSummary[];
|
||||
milestone?: { title: string; state?: string } | null;
|
||||
commentsCount?: number;
|
||||
};
|
||||
|
||||
export type GitLabMergeRequest = {
|
||||
@@ -1261,6 +1306,36 @@ export type GitLabBranchesResult = {
|
||||
defaultBranch?: string | null;
|
||||
};
|
||||
|
||||
export type GitLabMergeRequestCommit = {
|
||||
sha: string;
|
||||
shortSha: string;
|
||||
message: string;
|
||||
summary?: string;
|
||||
authorName?: string;
|
||||
committedAt?: string;
|
||||
parents: string[];
|
||||
};
|
||||
|
||||
export type GitLabMergeRequestCommitsResult = {
|
||||
connected: boolean;
|
||||
repo?: GitLabRepoRef | null;
|
||||
commits: GitLabMergeRequestCommit[];
|
||||
};
|
||||
|
||||
export type GitLabTimelineEvent = {
|
||||
id: string;
|
||||
type: string;
|
||||
body?: string | null;
|
||||
author?: GitLabUserSummary | null;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
export type GitLabMergeRequestTimelineResult = {
|
||||
connected: boolean;
|
||||
repo?: GitLabRepoRef | null;
|
||||
events: GitLabTimelineEvent[];
|
||||
};
|
||||
|
||||
export type GitLabMergeRequestCreateInput = {
|
||||
directory: string;
|
||||
title: string;
|
||||
@@ -1340,6 +1415,8 @@ export interface GitLabAPI {
|
||||
mrCreate(input: GitLabMergeRequestCreateInput): Promise<GitLabMergeRequest>;
|
||||
mrUpdate(input: GitLabMergeRequestUpdateInput): Promise<GitLabMergeRequest>;
|
||||
mrMerge(input: GitLabMergeRequestMergeInput): Promise<GitLabMergeRequestMergeResult>;
|
||||
mrCommits?(directory: string, number: number, options?: { namespace?: string; project?: string }): Promise<GitLabMergeRequestCommitsResult>;
|
||||
mrTimeline?(directory: string, number: number, options?: { namespace?: string; project?: string }): Promise<GitLabMergeRequestTimelineResult>;
|
||||
|
||||
repoBranches(namespace: string, project: string): Promise<GitLabBranchesResult>;
|
||||
}
|
||||
@@ -1378,6 +1455,9 @@ export type GiteaIssueSummary = {
|
||||
state: string;
|
||||
author: { username: string; id?: number };
|
||||
labels: string[];
|
||||
assignees?: GiteaUserSummary[];
|
||||
milestone?: { title: string; state?: string } | null;
|
||||
commentsCount?: number;
|
||||
};
|
||||
|
||||
export type GiteaIssue = GiteaIssueSummary & { body?: string; createdAt?: string; updatedAt?: string };
|
||||
@@ -1398,6 +1478,9 @@ export type GiteaPullRequestSummary = {
|
||||
draft?: boolean;
|
||||
author: { username: string; id?: number };
|
||||
labels: string[];
|
||||
assignees?: GiteaUserSummary[];
|
||||
milestone?: { title: string; state?: string } | null;
|
||||
commentsCount?: number;
|
||||
sourceBranch: string;
|
||||
targetBranch: string;
|
||||
};
|
||||
@@ -1421,6 +1504,38 @@ export type GiteaPullRequestMergeInput = { directory: string; number: number; me
|
||||
export type GiteaPullRequestMergeResult = { connected: boolean; merged: boolean; message?: string };
|
||||
export type GiteaBranchesResult = { branches: string[]; defaultBranch?: string | null };
|
||||
|
||||
export type GiteaPullRequestCommit = {
|
||||
sha: string;
|
||||
message: string;
|
||||
summary?: string;
|
||||
author?: GiteaUserSummary | null;
|
||||
committedAt?: string;
|
||||
parents: string[];
|
||||
};
|
||||
|
||||
export type GiteaPullRequestCommitsResult = { connected: boolean; repo?: { owner: string; repo: string; url?: string } | null; commits: GiteaPullRequestCommit[] };
|
||||
|
||||
export type GiteaCommitStatus = {
|
||||
state: 'success' | 'failure' | 'pending' | 'error' | 'warning' | 'unknown';
|
||||
name: string;
|
||||
description?: string | null;
|
||||
url?: string | null;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
export type GiteaPullRequestStatusesResult = { connected: boolean; repo?: { owner: string; repo: string; url?: string } | null; statuses: GiteaCommitStatus[] };
|
||||
|
||||
export type GiteaReview = {
|
||||
id: string;
|
||||
state: 'APPROVED' | 'REQUEST_CHANGES' | 'COMMENT' | 'PENDING' | 'DISMISSED' | string;
|
||||
author?: GiteaUserSummary | null;
|
||||
submittedAt?: string;
|
||||
body?: string | null;
|
||||
commitSha?: string | null;
|
||||
};
|
||||
|
||||
export type GiteaPullRequestReviewsResult = { connected: boolean; repo?: { owner: string; repo: string; url?: string } | null; reviews: GiteaReview[] };
|
||||
|
||||
export interface GiteaAPI {
|
||||
authStatus(): Promise<GiteaAuthStatus>;
|
||||
authConnect(input: { accessToken: string; baseUrl: string }): Promise<GiteaAuthStatus>;
|
||||
@@ -1441,6 +1556,9 @@ export interface GiteaAPI {
|
||||
prCreate(input: GiteaPullRequestCreateInput): Promise<GiteaPullRequest>;
|
||||
prUpdate(input: GiteaPullRequestUpdateInput): Promise<GiteaPullRequest>;
|
||||
prMerge(input: GiteaPullRequestMergeInput): Promise<GiteaPullRequestMergeResult>;
|
||||
prCommits?(directory: string, number: number, options?: { owner?: string; repo?: string }): Promise<GiteaPullRequestCommitsResult>;
|
||||
prStatuses?(directory: string, number: number, options?: { owner?: string; repo?: string }): Promise<GiteaPullRequestStatusesResult>;
|
||||
prReviews?(directory: string, number: number, options?: { owner?: string; repo?: string }): Promise<GiteaPullRequestReviewsResult>;
|
||||
|
||||
repoBranches(owner: string, repo: string): Promise<GiteaBranchesResult>;
|
||||
}
|
||||
|
||||
@@ -17,29 +17,39 @@ import type {
|
||||
GitLabAPI,
|
||||
} from '@/lib/api/types';
|
||||
import type {
|
||||
ForgeChecksResult,
|
||||
ForgeCommitsResult,
|
||||
ForgeIssueDetail,
|
||||
ForgeIssuesResult,
|
||||
ForgeProvider,
|
||||
ForgePullRequestContext,
|
||||
ForgePullRequestsResult,
|
||||
ForgeTimelineResult,
|
||||
} from './provider';
|
||||
import type { ForgeProviderCapabilities, ForgeProviderKind } from './types';
|
||||
import {
|
||||
mapGiteaCommits,
|
||||
mapGiteaComment,
|
||||
mapGiteaContext,
|
||||
mapGiteaIssue,
|
||||
mapGiteaPr,
|
||||
mapGiteaRepoRef,
|
||||
mapGiteaReviewsToEvents,
|
||||
mapGiteaStatuses,
|
||||
mapGithubCommits,
|
||||
mapGithubContext,
|
||||
mapGithubIssue,
|
||||
mapGithubIssueComment,
|
||||
mapGithubPr,
|
||||
mapGithubRepoRef,
|
||||
mapGithubTimelineEvents,
|
||||
mapGitlabCommits,
|
||||
mapGitlabContext,
|
||||
mapGitlabIssue,
|
||||
mapGitlabMr,
|
||||
mapGitlabNoteComment,
|
||||
mapGitlabRepoRef,
|
||||
mapGitlabTimelineEvents,
|
||||
} from './normalize';
|
||||
|
||||
const GITHUB_CAPABILITIES: ForgeProviderCapabilities = {
|
||||
@@ -119,6 +129,16 @@ const EMPTY_ISSUE_DETAIL: ForgeIssueDetail = {
|
||||
// result without leaking the underlying error message.
|
||||
const COMMENTS_ERROR = 'comments failed to load';
|
||||
|
||||
// Stable, detail-free marker for rich-view (commits/timeline/checks) fetch
|
||||
// failures; callers distinguish "not attempted" (no error) from "failed".
|
||||
const LOAD_ERROR = 'failed to load';
|
||||
|
||||
const EMPTY_COMMITS: ForgeCommitsResult = { connected: false, repo: null, commits: [] };
|
||||
|
||||
const EMPTY_TIMELINE: ForgeTimelineResult = { connected: false, repo: null, events: [] };
|
||||
|
||||
const EMPTY_CHECKS: ForgeChecksResult = { connected: false, repo: null, checks: null };
|
||||
|
||||
/**
|
||||
* Split a `"owner/repo"` selector into its parts, as used by the
|
||||
* `sourceRepo` option of the forge interface. Returns null for anything that
|
||||
@@ -223,6 +243,43 @@ export const createGithubForgeProvider = (api: GitHubAPI): ForgeProvider => ({
|
||||
return EMPTY_ISSUE_DETAIL;
|
||||
}
|
||||
},
|
||||
|
||||
async getCommits(directory, number, options) {
|
||||
if (!api.prCommits) return EMPTY_COMMITS;
|
||||
try {
|
||||
const result = await api.prCommits(directory, number, {
|
||||
sourceRepo: parseOwnerRepo(options?.sourceRepo),
|
||||
});
|
||||
return {
|
||||
connected: result.connected,
|
||||
repo: result.repo ? mapGithubRepoRef(result.repo) : null,
|
||||
commits: result.commits ? mapGithubCommits(result.commits) : [],
|
||||
};
|
||||
} catch {
|
||||
return { ...EMPTY_COMMITS, error: LOAD_ERROR };
|
||||
}
|
||||
},
|
||||
|
||||
async getTimeline(directory, number, options) {
|
||||
if (!api.prTimeline) return EMPTY_TIMELINE;
|
||||
try {
|
||||
const result = await api.prTimeline(directory, number, {
|
||||
sourceRepo: parseOwnerRepo(options?.sourceRepo),
|
||||
});
|
||||
return {
|
||||
connected: result.connected,
|
||||
repo: result.repo ? mapGithubRepoRef(result.repo) : null,
|
||||
events: result.events ? mapGithubTimelineEvents(result.events) : [],
|
||||
};
|
||||
} catch {
|
||||
return { ...EMPTY_TIMELINE, error: LOAD_ERROR };
|
||||
}
|
||||
},
|
||||
|
||||
// GitHub check runs ride on getPullRequestContext().checks.
|
||||
async getChecks() {
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
export const createGitlabForgeProvider = (api: GitLabAPI): ForgeProvider => ({
|
||||
@@ -323,6 +380,47 @@ export const createGitlabForgeProvider = (api: GitLabAPI): ForgeProvider => ({
|
||||
return EMPTY_ISSUE_DETAIL;
|
||||
}
|
||||
},
|
||||
|
||||
async getCommits(directory, number, options) {
|
||||
if (!api.mrCommits) return EMPTY_COMMITS;
|
||||
try {
|
||||
const selector = parseOwnerRepo(options?.sourceRepo);
|
||||
const result = await api.mrCommits(directory, number, {
|
||||
namespace: selector?.owner,
|
||||
project: selector?.repo,
|
||||
});
|
||||
return {
|
||||
connected: result.connected,
|
||||
repo: result.repo ? mapGitlabRepoRef(result.repo) : null,
|
||||
commits: result.commits ? mapGitlabCommits(result.commits) : [],
|
||||
};
|
||||
} catch {
|
||||
return { ...EMPTY_COMMITS, error: LOAD_ERROR };
|
||||
}
|
||||
},
|
||||
|
||||
async getTimeline(directory, number, options) {
|
||||
if (!api.mrTimeline) return EMPTY_TIMELINE;
|
||||
try {
|
||||
const selector = parseOwnerRepo(options?.sourceRepo);
|
||||
const result = await api.mrTimeline(directory, number, {
|
||||
namespace: selector?.owner,
|
||||
project: selector?.repo,
|
||||
});
|
||||
return {
|
||||
connected: result.connected,
|
||||
repo: result.repo ? mapGitlabRepoRef(result.repo) : null,
|
||||
events: result.events ? mapGitlabTimelineEvents(result.events) : [],
|
||||
};
|
||||
} catch {
|
||||
return { ...EMPTY_TIMELINE, error: LOAD_ERROR };
|
||||
}
|
||||
},
|
||||
|
||||
// GitLab exposes no checks surface.
|
||||
async getChecks() {
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
export const createGiteaForgeProvider = (api: GiteaAPI): ForgeProvider => ({
|
||||
@@ -428,6 +526,61 @@ export const createGiteaForgeProvider = (api: GiteaAPI): ForgeProvider => ({
|
||||
return EMPTY_ISSUE_DETAIL;
|
||||
}
|
||||
},
|
||||
|
||||
async getCommits(directory, number, options) {
|
||||
if (!api.prCommits) return EMPTY_COMMITS;
|
||||
try {
|
||||
const selector = parseOwnerRepo(options?.sourceRepo);
|
||||
const result = await api.prCommits(directory, number, {
|
||||
owner: selector?.owner,
|
||||
repo: selector?.repo,
|
||||
});
|
||||
return {
|
||||
connected: result.connected,
|
||||
repo: result.repo ? mapGiteaRepoRef(result.repo) : null,
|
||||
commits: result.commits ? mapGiteaCommits(result.commits) : [],
|
||||
};
|
||||
} catch {
|
||||
return { ...EMPTY_COMMITS, error: LOAD_ERROR };
|
||||
}
|
||||
},
|
||||
|
||||
// Gitea has no timeline endpoint; synthesize one from its reviews.
|
||||
async getTimeline(directory, number, options) {
|
||||
if (!api.prReviews) return EMPTY_TIMELINE;
|
||||
try {
|
||||
const selector = parseOwnerRepo(options?.sourceRepo);
|
||||
const result = await api.prReviews(directory, number, {
|
||||
owner: selector?.owner,
|
||||
repo: selector?.repo,
|
||||
});
|
||||
return {
|
||||
connected: result.connected,
|
||||
repo: result.repo ? mapGiteaRepoRef(result.repo) : null,
|
||||
events: result.reviews ? mapGiteaReviewsToEvents(result.reviews) : [],
|
||||
};
|
||||
} catch {
|
||||
return { ...EMPTY_TIMELINE, error: LOAD_ERROR };
|
||||
}
|
||||
},
|
||||
|
||||
async getChecks(directory, number, options) {
|
||||
if (!api.prStatuses) return EMPTY_CHECKS;
|
||||
try {
|
||||
const selector = parseOwnerRepo(options?.sourceRepo);
|
||||
const result = await api.prStatuses(directory, number, {
|
||||
owner: selector?.owner,
|
||||
repo: selector?.repo,
|
||||
});
|
||||
return {
|
||||
connected: result.connected,
|
||||
repo: result.repo ? mapGiteaRepoRef(result.repo) : null,
|
||||
checks: result.statuses ? mapGiteaStatuses(result.statuses) : null,
|
||||
};
|
||||
} catch {
|
||||
return { ...EMPTY_CHECKS, error: LOAD_ERROR };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
import type {
|
||||
GiteaAPI,
|
||||
GiteaComment,
|
||||
GiteaCommitStatus,
|
||||
GiteaIssue,
|
||||
GiteaPullRequest,
|
||||
GiteaUserSummary,
|
||||
@@ -18,23 +19,34 @@ import type {
|
||||
GitLabMergeRequest,
|
||||
GitLabUserSummary,
|
||||
} from '@/lib/api/types';
|
||||
import { buildForgeProvider, createGitlabForgeProvider } from '@/lib/forge/adapters';
|
||||
import { buildForgeProvider, createGiteaForgeProvider, createGithubForgeProvider, createGitlabForgeProvider } from '@/lib/forge/adapters';
|
||||
import {
|
||||
aggregateStatusState,
|
||||
firstLine,
|
||||
mapCheckRunState,
|
||||
mapGiteaCommits,
|
||||
mapGiteaComment,
|
||||
mapGiteaContext,
|
||||
mapGiteaIssue,
|
||||
mapGiteaPr,
|
||||
mapGiteaReviewsToEvents,
|
||||
mapGiteaStatuses,
|
||||
mapGithubCheckSummary,
|
||||
mapGithubCommits,
|
||||
mapGithubContext,
|
||||
mapGithubIssue,
|
||||
mapGithubIssueComment,
|
||||
mapGithubPr,
|
||||
mapGithubReviewComment,
|
||||
mapGiteaComment,
|
||||
mapGiteaContext,
|
||||
mapGiteaIssue,
|
||||
mapGiteaPr,
|
||||
mapGithubTimelineEvents,
|
||||
mapGitlabCommits,
|
||||
mapGitlabContext,
|
||||
mapGitlabIssue,
|
||||
mapGitlabMr,
|
||||
mapGitlabNoteComment,
|
||||
mapGitlabTimelineEvents,
|
||||
mapStatusState,
|
||||
normalizeEventType,
|
||||
stateOf,
|
||||
} from '@/lib/forge/normalize';
|
||||
|
||||
@@ -233,6 +245,21 @@ describe('github normalization', () => {
|
||||
expect(pr.url).toBe('https://github.com/acme/widget/pull/42');
|
||||
});
|
||||
|
||||
test('maps enriched GitHub PR metadata (labels/assignees/milestone/comments)', () => {
|
||||
const pr = mapGithubPr({
|
||||
...githubPr,
|
||||
labels: [{ name: 'bug', color: 'd73a4a' }],
|
||||
assignees: [githubUser()],
|
||||
milestone: { title: 'v2.0' },
|
||||
commentsCount: 3,
|
||||
});
|
||||
expect(pr.labels).toEqual([{ name: 'bug', color: 'd73a4a' }]);
|
||||
expect(pr.assignees).toHaveLength(1);
|
||||
expect(pr.assignees?.[0]?.id).toBe('octocat');
|
||||
expect(pr.milestone?.title).toBe('v2.0');
|
||||
expect(pr.commentsCount).toBe(3);
|
||||
});
|
||||
|
||||
test('maps a GitHub issue', () => {
|
||||
const issue = mapGithubIssue(githubIssue);
|
||||
expect(issue.number).toBe(7);
|
||||
@@ -470,6 +497,194 @@ describe('gitea normalization', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rich-view normalization (commits / timeline / checks)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('shared rich-view helpers', () => {
|
||||
test('firstLine extracts the first message line', () => {
|
||||
expect(firstLine('one line')).toBe('one line');
|
||||
expect(firstLine('first\nsecond')).toBe('first');
|
||||
expect(firstLine('')).toBe('');
|
||||
});
|
||||
|
||||
test('normalizeEventType maps provider types onto the vocabulary', () => {
|
||||
expect(normalizeEventType('opened')).toBe('opened');
|
||||
expect(normalizeEventType('merged')).toBe('merged');
|
||||
expect(normalizeEventType('labeled')).toBe('labeled');
|
||||
expect(normalizeEventType('cross-referenced')).toBe('referenced');
|
||||
expect(normalizeEventType('mystery-event')).toBe('other');
|
||||
});
|
||||
|
||||
test('mapStatusState collapses error/warning onto failure/pending', () => {
|
||||
expect(mapStatusState('success')).toBe('success');
|
||||
expect(mapStatusState('failure')).toBe('failure');
|
||||
expect(mapStatusState('error')).toBe('failure');
|
||||
expect(mapStatusState('pending')).toBe('pending');
|
||||
expect(mapStatusState('warning')).toBe('pending');
|
||||
expect(mapStatusState('unknown')).toBe('unknown');
|
||||
expect(mapStatusState('something-else')).toBe('unknown');
|
||||
});
|
||||
|
||||
test('aggregateStatusState is failure > pending > success', () => {
|
||||
expect(aggregateStatusState([])).toBe('success');
|
||||
expect(aggregateStatusState([{ state: 'success', name: 'a' }])).toBe('success');
|
||||
expect(aggregateStatusState([{ state: 'success', name: 'a' }, { state: 'pending', name: 'b' }])).toBe('pending');
|
||||
expect(aggregateStatusState([{ state: 'pending', name: 'a' }, { state: 'error', name: 'b' }])).toBe('failure');
|
||||
expect(aggregateStatusState([{ state: 'failure', name: 'a' }])).toBe('failure');
|
||||
});
|
||||
});
|
||||
|
||||
describe('commit normalization', () => {
|
||||
test('maps GitHub commits using shortSha and first-line summaries', () => {
|
||||
const commits = mapGithubCommits([{
|
||||
sha: 'abc123',
|
||||
shortSha: 'abc1234',
|
||||
message: 'Summary line\n\nFull body',
|
||||
author: githubUser(),
|
||||
committedAt: '2026-01-02T03:04:05Z',
|
||||
parents: ['parent-1'],
|
||||
}]);
|
||||
expect(commits[0]).toEqual({
|
||||
sha: 'abc123',
|
||||
shortSha: 'abc1234',
|
||||
message: 'Summary line\n\nFull body',
|
||||
summary: 'Summary line',
|
||||
author: { id: 'octocat', login: 'octocat', name: 'Octo Cat', avatarUrl: 'https://avatars.example/octocat' },
|
||||
committedAt: '2026-01-02T03:04:05Z',
|
||||
parents: ['parent-1'],
|
||||
});
|
||||
});
|
||||
|
||||
test('GitHub commits fall back to the first message line when summary is absent', () => {
|
||||
const [commit] = mapGithubCommits([{
|
||||
sha: 'abc123',
|
||||
shortSha: 'abc1234',
|
||||
message: 'Title line\n\nBody',
|
||||
parents: [],
|
||||
}]);
|
||||
expect(commit.summary).toBe('Title line');
|
||||
expect(commit.author).toBeFalsy();
|
||||
expect(commit.parents).toEqual([]);
|
||||
});
|
||||
|
||||
test('maps GitLab commits with an author synthesized from authorName', () => {
|
||||
const commits = mapGitlabCommits([{
|
||||
sha: 'def456',
|
||||
shortSha: 'def4567',
|
||||
message: 'MR commit',
|
||||
authorName: 'GL User',
|
||||
committedAt: '2026-02-01T00:00:00Z',
|
||||
parents: [],
|
||||
}]);
|
||||
expect(commits[0].shortSha).toBe('def4567');
|
||||
expect(commits[0].author).toEqual({ id: 'GL User', login: 'GL User', name: 'GL User' });
|
||||
});
|
||||
|
||||
test('maps Gitea commits, deriving shortSha from the full sha', () => {
|
||||
const commits = mapGiteaCommits([{
|
||||
sha: '0123456789abcdef0123456789abcdef01234567',
|
||||
message: 'gitea commit',
|
||||
author: giteaUser(),
|
||||
committedAt: '2026-03-01T00:00:00Z',
|
||||
parents: [],
|
||||
}]);
|
||||
expect(commits[0].shortSha).toBe('0123456');
|
||||
expect(commits[0].author?.login).toBe('guser');
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeline normalization', () => {
|
||||
test('maps GitHub timeline events with source provenance', () => {
|
||||
const events = mapGithubTimelineEvents([
|
||||
{ id: '1', type: 'opened', author: githubUser(), createdAt: '2026-01-02T03:04:05Z' },
|
||||
{ id: '2', type: 'cross-referenced' },
|
||||
{ id: '3', type: 'mystery-type', body: 'x' },
|
||||
]);
|
||||
expect(events[0].type).toBe('opened');
|
||||
expect(events[0].id).toBe('1');
|
||||
expect(events[0].author?.login).toBe('octocat');
|
||||
expect(events[0].source).toBe('github-timeline');
|
||||
expect(events[1].type).toBe('referenced');
|
||||
expect(events[2].type).toBe('other');
|
||||
});
|
||||
|
||||
test('maps GitLab timeline events as system notes', () => {
|
||||
const events = mapGitlabTimelineEvents([
|
||||
{ id: '9', type: 'approved', author: gitlabUser(), createdAt: '2026-02-01T00:00:00Z' },
|
||||
]);
|
||||
expect(events[0].type).toBe('approved');
|
||||
expect(events[0].author?.login).toBe('gluser');
|
||||
expect(events[0].source).toBe('gitlab-system-note');
|
||||
});
|
||||
|
||||
test('synthesizes Gitea timeline events from reviews, skipping PENDING', () => {
|
||||
const events = mapGiteaReviewsToEvents([
|
||||
{ id: '1', state: 'APPROVED', author: giteaUser(), submittedAt: '2026-03-01T00:00:00Z', body: 'LGTM', commitSha: 'abc123' },
|
||||
{ id: '2', state: 'REQUEST_CHANGES', author: giteaUser() },
|
||||
{ id: '3', state: 'COMMENT' },
|
||||
{ id: '4', state: 'PENDING' },
|
||||
{ id: '5', state: 'DISMISSED' },
|
||||
]);
|
||||
expect(events).toHaveLength(4);
|
||||
expect(events[0]).toEqual({
|
||||
id: '1',
|
||||
type: 'approved',
|
||||
author: { id: '3', login: 'guser', name: 'G User', avatarUrl: 'https://avatars.example/guser', url: 'https://gitea.example/guser' },
|
||||
createdAt: '2026-03-01T00:00:00Z',
|
||||
body: 'LGTM',
|
||||
commitSha: 'abc123',
|
||||
source: 'gitea-review',
|
||||
});
|
||||
expect(events[1].type).toBe('requested-changes');
|
||||
expect(events[2].type).toBe('commented');
|
||||
expect(events[3].type).toBe('other');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gitea commit-status normalization', () => {
|
||||
const statuses: GiteaCommitStatus[] = [
|
||||
{ state: 'success', name: 'ci' },
|
||||
{ state: 'failure', name: 'lint' },
|
||||
{ state: 'pending', name: 'test' },
|
||||
{ state: 'error', name: 'build' },
|
||||
{ state: 'warning', name: 'docs' },
|
||||
{ state: 'unknown', name: 'mystery' },
|
||||
];
|
||||
|
||||
test('maps statuses onto a checks summary with aggregated state', () => {
|
||||
const summary = mapGiteaStatuses(statuses);
|
||||
expect(summary.state).toBe('failure');
|
||||
expect(summary.total).toBe(6);
|
||||
expect(summary.success).toBe(1);
|
||||
expect(summary.failure).toBe(2);
|
||||
expect(summary.pending).toBe(2);
|
||||
expect(summary.checks[0]).toEqual({
|
||||
kind: 'commit-status',
|
||||
name: 'ci',
|
||||
state: 'success',
|
||||
url: undefined,
|
||||
description: undefined,
|
||||
startedAt: undefined,
|
||||
completedAt: undefined,
|
||||
});
|
||||
expect(summary.checks[1].state).toBe('failure');
|
||||
expect(summary.checks[3].state).toBe('failure');
|
||||
expect(summary.checks[4].state).toBe('pending');
|
||||
expect(summary.checks[5].state).toBe('unknown');
|
||||
});
|
||||
|
||||
test('carries url and description onto the normalized checks', () => {
|
||||
const [check] = mapGiteaStatuses([
|
||||
{ state: 'pending', name: 'deploy', url: 'https://gitea.example/status/1', description: 'Deploying…', createdAt: '2026-03-01T00:00:00Z' },
|
||||
]).checks;
|
||||
expect(check.url).toBe('https://gitea.example/status/1');
|
||||
expect(check.description).toBe('Deploying…');
|
||||
expect(check.startedAt).toBe('2026-03-01T00:00:00Z');
|
||||
expect(check.completedAt).toBe('2026-03-01T00:00:00Z');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Adapter factory
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -613,6 +828,121 @@ describe('adapters gracefully degrade', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('rich-view adapter wiring', () => {
|
||||
test('github getCommits maps prCommits and passes the sourceRepo selector', async () => {
|
||||
const api = {
|
||||
prCommits: async (_directory: string, _number: number, options?: { sourceRepo?: { owner: string; repo: string } | null }) => {
|
||||
expect(options?.sourceRepo).toEqual({ owner: 'upstream', repo: 'widget' });
|
||||
return {
|
||||
connected: true,
|
||||
repo: { owner: 'acme', repo: 'widget', url: 'https://github.com/acme/widget' },
|
||||
commits: [{ sha: 'abc123', shortSha: 'abc1234', message: 'm', parents: [] }],
|
||||
};
|
||||
},
|
||||
} as unknown as GitHubAPI;
|
||||
const provider = createGithubForgeProvider(api);
|
||||
const result = await provider.getCommits!('/repo', 1, { sourceRepo: 'upstream/widget' });
|
||||
expect(result.connected).toBe(true);
|
||||
expect(result.repo?.owner).toBe('acme');
|
||||
expect(result.commits[0]?.shortSha).toBe('abc1234');
|
||||
expect(result.error).toBeFalsy();
|
||||
});
|
||||
|
||||
test('github getTimeline maps prTimeline; getChecks returns null', async () => {
|
||||
const api = {
|
||||
prTimeline: async () => ({
|
||||
connected: true,
|
||||
events: [{ id: '1', type: 'opened', author: githubUser() }],
|
||||
}),
|
||||
} as unknown as GitHubAPI;
|
||||
const provider = createGithubForgeProvider(api);
|
||||
const timeline = await provider.getTimeline!('/repo', 1);
|
||||
expect(timeline.events[0]?.source).toBe('github-timeline');
|
||||
expect(await provider.getChecks!('/repo', 1)).toBeNull();
|
||||
});
|
||||
|
||||
test('gitlab getTimeline maps mrTimeline as system-note events', async () => {
|
||||
const api = {
|
||||
mrTimeline: async () => ({
|
||||
connected: true,
|
||||
events: [{ id: '1', type: 'approved' }],
|
||||
}),
|
||||
} as unknown as GitLabAPI;
|
||||
const provider = createGitlabForgeProvider(api);
|
||||
const timeline = await provider.getTimeline!('/repo', 1);
|
||||
expect(timeline.events[0]?.source).toBe('gitlab-system-note');
|
||||
expect(await provider.getChecks!('/repo', 1)).toBeNull();
|
||||
});
|
||||
|
||||
test('gitea getTimeline synthesizes events from prReviews', async () => {
|
||||
const api = {
|
||||
prReviews: async () => ({
|
||||
connected: true,
|
||||
reviews: [{ id: '1', state: 'APPROVED', author: giteaUser() }],
|
||||
}),
|
||||
} as unknown as GiteaAPI;
|
||||
const provider = createGiteaForgeProvider(api);
|
||||
const timeline = await provider.getTimeline!('/repo', 1);
|
||||
expect(timeline.events[0]?.type).toBe('approved');
|
||||
expect(timeline.events[0]?.source).toBe('gitea-review');
|
||||
});
|
||||
|
||||
test('gitea getChecks maps prStatuses onto a commit-status summary', async () => {
|
||||
const api = {
|
||||
prStatuses: async () => ({
|
||||
connected: true,
|
||||
statuses: [{ state: 'pending', name: 'ci' }],
|
||||
}),
|
||||
} as unknown as GiteaAPI;
|
||||
const provider = createGiteaForgeProvider(api);
|
||||
const result = await provider.getChecks!('/repo', 1);
|
||||
expect(result?.connected).toBe(true);
|
||||
expect(result?.checks?.state).toBe('pending');
|
||||
expect(result?.checks?.checks[0]?.kind).toBe('commit-status');
|
||||
expect(result?.error).toBeFalsy();
|
||||
});
|
||||
|
||||
test('absent runtime methods degrade to disconnected envelopes', async () => {
|
||||
const github = createGithubForgeProvider({} as unknown as GitHubAPI);
|
||||
expect(await github.getCommits!('/repo', 1)).toEqual({ connected: false, repo: null, commits: [] });
|
||||
expect(await github.getTimeline!('/repo', 1)).toEqual({ connected: false, repo: null, events: [] });
|
||||
expect(await github.getChecks!('/repo', 1)).toBeNull();
|
||||
|
||||
const gitea = createGiteaForgeProvider({} as unknown as GiteaAPI);
|
||||
expect(await gitea.getCommits!('/repo', 1)).toEqual({ connected: false, repo: null, commits: [] });
|
||||
expect(await gitea.getTimeline!('/repo', 1)).toEqual({ connected: false, repo: null, events: [] });
|
||||
expect(await gitea.getChecks!('/repo', 1)).toEqual({ connected: false, repo: null, checks: null });
|
||||
});
|
||||
|
||||
test('wire failures set a stable error instead of throwing', async () => {
|
||||
const api = {
|
||||
prCommits: async () => { throw new Error('boom'); },
|
||||
prTimeline: async () => { throw new Error('boom'); },
|
||||
} as unknown as GitHubAPI;
|
||||
const provider = createGithubForgeProvider(api);
|
||||
const commits = await provider.getCommits!('/repo', 1);
|
||||
expect(commits.connected).toBe(false);
|
||||
expect(commits.commits).toEqual([]);
|
||||
expect(commits.error).toBe('failed to load');
|
||||
|
||||
const timeline = await provider.getTimeline!('/repo', 1);
|
||||
expect(timeline.connected).toBe(false);
|
||||
expect(timeline.events).toEqual([]);
|
||||
expect(timeline.error).toBe('failed to load');
|
||||
});
|
||||
|
||||
test('gitea getChecks wire failure sets a stable error', async () => {
|
||||
const api = {
|
||||
prStatuses: async () => { throw new Error('boom'); },
|
||||
} as unknown as GiteaAPI;
|
||||
const provider = createGiteaForgeProvider(api);
|
||||
const result = await provider.getChecks!('/repo', 1);
|
||||
expect(result?.connected).toBe(false);
|
||||
expect(result?.checks).toBeNull();
|
||||
expect(result?.error).toBe('failed to load');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPullRequestForBranch', () => {
|
||||
test('GitLab prefers the opened MR over a merged one for the branch', async () => {
|
||||
const api = {
|
||||
|
||||
@@ -38,12 +38,17 @@ export type {
|
||||
ForgePullRequestContext,
|
||||
ForgeIssuesResult,
|
||||
ForgeIssueDetail,
|
||||
ForgeCommitsResult,
|
||||
ForgeTimelineResult,
|
||||
ForgeChecksResult,
|
||||
ForgeProvider,
|
||||
} from './provider';
|
||||
|
||||
export {
|
||||
stateOf,
|
||||
mapCheckRunState,
|
||||
firstLine,
|
||||
normalizeEventType,
|
||||
mapGithubUser,
|
||||
mapGithubPr,
|
||||
mapGithubIssue,
|
||||
@@ -52,18 +57,27 @@ export {
|
||||
mapGithubCheckSummary,
|
||||
mapGithubContext,
|
||||
mapGithubRepoRef,
|
||||
mapGithubCommits,
|
||||
mapGithubTimelineEvents,
|
||||
mapGitlabUser,
|
||||
mapGitlabMr,
|
||||
mapGitlabIssue,
|
||||
mapGitlabNoteComment,
|
||||
mapGitlabContext,
|
||||
mapGitlabRepoRef,
|
||||
mapGitlabCommits,
|
||||
mapGitlabTimelineEvents,
|
||||
mapGiteaUser,
|
||||
mapGiteaPr,
|
||||
mapGiteaIssue,
|
||||
mapGiteaComment,
|
||||
mapGiteaContext,
|
||||
mapGiteaRepoRef,
|
||||
mapGiteaCommits,
|
||||
mapGiteaStatuses,
|
||||
mapGiteaReviewsToEvents,
|
||||
mapStatusState,
|
||||
aggregateStatusState,
|
||||
} from './normalize';
|
||||
|
||||
export {
|
||||
|
||||
@@ -12,25 +12,32 @@
|
||||
|
||||
import type {
|
||||
GiteaComment,
|
||||
GiteaCommitStatus,
|
||||
GiteaIssue,
|
||||
GiteaIssueSummary,
|
||||
GiteaPullRequest,
|
||||
GiteaPullRequestCommit,
|
||||
GiteaPullRequestContextResult,
|
||||
GiteaReview,
|
||||
GiteaUserSummary,
|
||||
GitHubCheckRun,
|
||||
GitHubChecksSummary,
|
||||
GitHubIssue,
|
||||
GitHubIssueComment,
|
||||
GitHubIssueSummary,
|
||||
GitHubPullRequestCommit,
|
||||
GitHubPullRequestContextResult,
|
||||
GitHubPullRequestSummary,
|
||||
GitHubTimelineEvent,
|
||||
GitHubUserSummary,
|
||||
GitLabIssue,
|
||||
GitLabIssueComment,
|
||||
GitLabIssueSummary,
|
||||
GitLabMergeRequest,
|
||||
GitLabMergeRequestCommit,
|
||||
GitLabMergeRequestContextResult,
|
||||
GitLabRepoRef,
|
||||
GitLabTimelineEvent,
|
||||
GitLabUserSummary,
|
||||
} from '@/lib/api/types';
|
||||
import type { ForgePullRequestContext } from './provider';
|
||||
@@ -39,11 +46,14 @@ import type {
|
||||
ForgeCheckState,
|
||||
ForgeChecksSummary,
|
||||
ForgeComment,
|
||||
ForgeCommit,
|
||||
ForgeEntityState,
|
||||
ForgeFileChange,
|
||||
ForgeIssue,
|
||||
ForgePullRequest,
|
||||
ForgeRepoRef,
|
||||
ForgeTimelineEvent,
|
||||
ForgeTimelineEventType,
|
||||
ForgeUser,
|
||||
} from './types';
|
||||
|
||||
@@ -123,9 +133,13 @@ export const mapGithubPr = (pr: GitHubPullRequestSummary): ForgePullRequest => (
|
||||
headSha: pr.headSha,
|
||||
mergeable: pr.mergeable ?? null,
|
||||
mergeableState: pr.mergeableState ?? null,
|
||||
labels: [],
|
||||
assignees: [],
|
||||
milestone: null,
|
||||
// The enriched summary fields are optional (older route responses may omit
|
||||
// them), so the projections degrade to empty instead of leaking undefined
|
||||
// into consumers.
|
||||
labels: (pr.labels ?? []).map((label) => ({ name: label.name, color: label.color })),
|
||||
assignees: (pr.assignees ?? []).map(mapGithubUser),
|
||||
milestone: pr.milestone ? { title: pr.milestone.title } : null,
|
||||
commentsCount: pr.commentsCount,
|
||||
url: pr.url,
|
||||
});
|
||||
|
||||
@@ -377,6 +391,188 @@ export const mapGiteaContext = (result: GiteaPullRequestContextResult): ForgePul
|
||||
checks: null,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commits
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** First line of a commit message, used as the row summary when absent. */
|
||||
export const firstLine = (message: string): string => message.split('\n')[0] ?? message;
|
||||
|
||||
const mapCommit = (
|
||||
c: { sha: string; shortSha?: string; message: string; summary?: string; committedAt?: string; parents?: string[] },
|
||||
author: ForgeUser | undefined,
|
||||
): ForgeCommit => ({
|
||||
sha: c.sha,
|
||||
shortSha: c.shortSha ?? c.sha.slice(0, 7),
|
||||
message: c.message,
|
||||
summary: c.summary ?? firstLine(c.message),
|
||||
author,
|
||||
committedAt: c.committedAt,
|
||||
parents: c.parents ?? [],
|
||||
});
|
||||
|
||||
export const mapGithubCommits = (commits: GitHubPullRequestCommit[]): ForgeCommit[] =>
|
||||
commits.map((c) => mapCommit(c, c.author ? mapGithubUser(c.author) : undefined));
|
||||
|
||||
export const mapGitlabCommits = (commits: GitLabMergeRequestCommit[]): ForgeCommit[] =>
|
||||
commits.map((c) => {
|
||||
const author = c.authorName
|
||||
? { id: c.authorName, login: c.authorName, name: c.authorName }
|
||||
: undefined;
|
||||
return mapCommit(c, author);
|
||||
});
|
||||
|
||||
export const mapGiteaCommits = (commits: GiteaPullRequestCommit[]): ForgeCommit[] =>
|
||||
commits.map((c) => mapCommit(c, c.author ? mapGiteaUser(c.author) : undefined));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Timeline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Map a provider timeline event type onto the normalized vocabulary.
|
||||
* Provider types are lowercase; anything unrecognized collapses to 'other' so
|
||||
* an unknown marker never crashes the label lookup.
|
||||
*/
|
||||
export const normalizeEventType = (raw: string): ForgeTimelineEventType => {
|
||||
switch (raw) {
|
||||
case 'cross-referenced':
|
||||
return 'referenced';
|
||||
case 'committed':
|
||||
case 'opened':
|
||||
case 'reopened':
|
||||
case 'closed':
|
||||
case 'merged':
|
||||
case 'reviewed':
|
||||
case 'approved':
|
||||
case 'requested-changes':
|
||||
case 'commented':
|
||||
case 'referenced':
|
||||
case 'labeled':
|
||||
case 'unlabeled':
|
||||
case 'assigned':
|
||||
case 'unassigned':
|
||||
case 'milestoned':
|
||||
case 'demilestoned':
|
||||
return raw;
|
||||
default:
|
||||
return 'other';
|
||||
}
|
||||
};
|
||||
|
||||
export const mapGithubTimelineEvents = (events: GitHubTimelineEvent[]): ForgeTimelineEvent[] =>
|
||||
events.map((event) => ({
|
||||
id: String(event.id),
|
||||
type: normalizeEventType(event.type),
|
||||
author: event.author ? mapGithubUser(event.author) : undefined,
|
||||
createdAt: event.createdAt,
|
||||
body: event.body ?? undefined,
|
||||
commitSha: event.commitSha ?? undefined,
|
||||
source: 'github-timeline',
|
||||
}));
|
||||
|
||||
export const mapGitlabTimelineEvents = (events: GitLabTimelineEvent[]): ForgeTimelineEvent[] =>
|
||||
events.map((event) => ({
|
||||
id: String(event.id),
|
||||
type: normalizeEventType(event.type),
|
||||
author: event.author ? mapGitlabUser(event.author) : undefined,
|
||||
createdAt: event.createdAt,
|
||||
body: event.body ?? undefined,
|
||||
source: 'gitlab-system-note',
|
||||
}));
|
||||
|
||||
/**
|
||||
* Gitea has no timeline endpoint; its pull-request reviews are the closest
|
||||
* activity signal, so the adapter synthesizes timeline events from them.
|
||||
*/
|
||||
export const mapGiteaReviewsToEvents = (reviews: GiteaReview[]): ForgeTimelineEvent[] =>
|
||||
reviews.flatMap((review) => {
|
||||
const type = normalizeReviewState(review.state);
|
||||
if (!type) return [];
|
||||
return [{
|
||||
id: String(review.id),
|
||||
type,
|
||||
author: review.author ? mapGiteaUser(review.author) : undefined,
|
||||
createdAt: review.submittedAt,
|
||||
body: review.body ?? undefined,
|
||||
commitSha: review.commitSha ?? undefined,
|
||||
source: 'gitea-review',
|
||||
}];
|
||||
});
|
||||
|
||||
const normalizeReviewState = (state: string): ForgeTimelineEventType | null => {
|
||||
switch (state) {
|
||||
case 'APPROVED':
|
||||
return 'approved';
|
||||
case 'REQUEST_CHANGES':
|
||||
return 'requested-changes';
|
||||
case 'COMMENT':
|
||||
return 'commented';
|
||||
case 'DISMISSED':
|
||||
return 'other';
|
||||
default:
|
||||
// PENDING and anything unknown produce no event.
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Checks (commit statuses)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Map a flat commit-status state onto the normalized check state. 'error'
|
||||
* counts as a failure, 'warning' as still running (pending), and anything
|
||||
* unrecognized collapses to 'unknown'.
|
||||
*/
|
||||
export const mapStatusState = (state: string): ForgeCheckState => {
|
||||
switch (state) {
|
||||
case 'success':
|
||||
return 'success';
|
||||
case 'failure':
|
||||
return 'failure';
|
||||
case 'error':
|
||||
return 'failure';
|
||||
case 'pending':
|
||||
return 'pending';
|
||||
case 'warning':
|
||||
return 'pending';
|
||||
case 'cancelled':
|
||||
return 'cancelled';
|
||||
case 'skipped':
|
||||
return 'skipped';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Aggregate a status list into one state: any failure/error wins, else any
|
||||
* pending, else success.
|
||||
*/
|
||||
export const aggregateStatusState = (statuses: GiteaCommitStatus[]): ForgeCheckState => {
|
||||
if (statuses.some((s) => s.state === 'failure' || s.state === 'error')) return 'failure';
|
||||
if (statuses.some((s) => s.state === 'pending' || s.state === 'warning')) return 'pending';
|
||||
return 'success';
|
||||
};
|
||||
|
||||
export const mapGiteaStatuses = (statuses: GiteaCommitStatus[]): ForgeChecksSummary => ({
|
||||
state: aggregateStatusState(statuses),
|
||||
total: statuses.length,
|
||||
success: statuses.filter((s) => s.state === 'success').length,
|
||||
failure: statuses.filter((s) => s.state === 'failure' || s.state === 'error').length,
|
||||
pending: statuses.filter((s) => s.state === 'pending' || s.state === 'warning').length,
|
||||
checks: statuses.map((status): ForgeCheck => ({
|
||||
kind: 'commit-status',
|
||||
name: status.name,
|
||||
state: mapStatusState(status.state),
|
||||
url: status.url ?? undefined,
|
||||
description: status.description ?? undefined,
|
||||
startedAt: status.createdAt,
|
||||
completedAt: status.createdAt,
|
||||
})),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers (repo refs and file changes; not part of the public API)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type {
|
||||
ForgeChecksSummary,
|
||||
ForgeComment,
|
||||
ForgeCommit,
|
||||
ForgeFileChange,
|
||||
ForgeIssue,
|
||||
ForgeProviderCapabilities,
|
||||
ForgeProviderKind,
|
||||
ForgePullRequest,
|
||||
ForgeRepoRef,
|
||||
ForgeTimelineEvent,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
@@ -75,6 +77,31 @@ export interface ForgeIssueDetail {
|
||||
commentsError?: string | null;
|
||||
}
|
||||
|
||||
/** Commits on a PR/MR (see `github prCommits`, `gitlab mrCommits`, `gitea prCommits`). */
|
||||
export interface ForgeCommitsResult {
|
||||
connected: boolean;
|
||||
repo?: ForgeRepoRef | null;
|
||||
commits: ForgeCommit[];
|
||||
/** Set when the wire call failed after resolving a repo — never a valid empty success. */
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
/** Activity timeline for a PR/MR (see `github prTimeline`, `gitlab mrTimeline`, gitea reviews). */
|
||||
export interface ForgeTimelineResult {
|
||||
connected: boolean;
|
||||
repo?: ForgeRepoRef | null;
|
||||
events: ForgeTimelineEvent[];
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
/** Rolled-up checks for a PR/MR; only non-null for providers with a dedicated checks surface. */
|
||||
export interface ForgeChecksResult {
|
||||
connected: boolean;
|
||||
repo?: ForgeRepoRef | null;
|
||||
checks: ForgeChecksSummary | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider-agnostic forge operations. Every method resolves the target
|
||||
* repository from the working directory (remotes + connected accounts) and
|
||||
@@ -131,6 +158,28 @@ export interface ForgeProvider {
|
||||
*/
|
||||
getIssue(directory: string, number: number, options?: { sourceRepo?: string | null }): Promise<ForgeIssueDetail>;
|
||||
|
||||
// NOTE: Slice B (next chunk) will add getCommits / getTimeline / checks for
|
||||
// the rich entity view. Do NOT add them here yet.
|
||||
// --- Rich entity view (commits / timeline / checks) ---
|
||||
|
||||
/**
|
||||
* Commits on a pull request. Wraps `github prCommits`, `gitlab mrCommits`,
|
||||
* and `gitea prCommits`. Optional: providers without a commits route do not
|
||||
* implement it, and the UI gates on method presence.
|
||||
*/
|
||||
getCommits?(directory: string, number: number, options?: { sourceRepo?: string | null }): Promise<ForgeCommitsResult>;
|
||||
|
||||
/**
|
||||
* Activity timeline for a pull request. Wraps `github prTimeline`,
|
||||
* `gitlab mrTimeline`, and (for gitea, which has no timeline endpoint) a
|
||||
* timeline synthesized from its reviews. Optional like `getCommits`.
|
||||
*/
|
||||
getTimeline?(directory: string, number: number, options?: { sourceRepo?: string | null }): Promise<ForgeTimelineResult>;
|
||||
|
||||
/**
|
||||
* Rolled-up checks for a pull request. Only gitea has a dedicated surface
|
||||
* (`gitea prStatuses`); GitHub check runs ride on
|
||||
* `getPullRequestContext().checks` and GitLab has no checks surface, so both
|
||||
* return null here. `null` return means the provider exposes no checks
|
||||
* through this method.
|
||||
*/
|
||||
getChecks?(directory: string, number: number, options?: { sourceRepo?: string | null }): Promise<ForgeChecksResult | null>;
|
||||
}
|
||||
|
||||
@@ -1280,6 +1280,55 @@ export const dict = {
|
||||
'filesView.editor.htmlPreviewTitle': 'HTML-Vorschau',
|
||||
'filesView.diagram.closeDiagramView': 'Diagrammansicht schließen',
|
||||
'filesView.diagram.saveDiagram': 'Diagramm speichern',
|
||||
|
||||
'forge.author': 'Autor',
|
||||
'forge.baseToHead': '{head} in {base}',
|
||||
'forge.checks.empty': 'Keine Prüfungen',
|
||||
'forge.checks.state.cancelled': 'Abgebrochen',
|
||||
'forge.checks.state.failure': 'Fehlgeschlagen',
|
||||
'forge.checks.state.pending': 'Ausstehend',
|
||||
'forge.checks.state.skipped': 'Übersprungen',
|
||||
'forge.checks.state.success': 'Erfolgreich',
|
||||
'forge.checks.state.unknown': 'Unbekannt',
|
||||
'forge.checks.statusStrip': 'Commit-Status',
|
||||
'forge.comment.inlineAt': 'Bei {path}:{line}',
|
||||
'forge.commits.empty': 'Keine Commits gefunden',
|
||||
'forge.commits.parents': 'Parents',
|
||||
'forge.copied': 'Commit-Hash kopiert',
|
||||
'forge.created': 'Erstellt',
|
||||
'forge.draft': 'Entwurf',
|
||||
'forge.error': 'Laden fehlgeschlagen',
|
||||
'forge.files.empty': 'Keine Dateien geändert',
|
||||
'forge.files.noDiff': 'Kein Diff verfügbar',
|
||||
'forge.loading': 'Wird geladen...',
|
||||
'forge.notConnected': 'Ihr Git-Forge ist nicht verbunden',
|
||||
'forge.section.checks': 'Prüfungen',
|
||||
'forge.section.commits': 'Commits',
|
||||
'forge.section.files': 'Geänderte Dateien',
|
||||
'forge.section.metadata': 'Details',
|
||||
'forge.section.timeline': 'Aktivität',
|
||||
'forge.state.closed': 'Geschlossen',
|
||||
'forge.state.merged': 'Zusammengeführt',
|
||||
'forge.state.open': 'Offen',
|
||||
'forge.timeline.empty': 'Noch keine Aktivität',
|
||||
'forge.timeline.event.approved': 'Pull Request genehmigt',
|
||||
'forge.timeline.event.assigned': 'Benutzer zugewiesen',
|
||||
'forge.timeline.event.closed': 'Pull Request geschlossen',
|
||||
'forge.timeline.event.commented': 'Kommentiert',
|
||||
'forge.timeline.event.committed': 'Commit hinzugefügt',
|
||||
'forge.timeline.event.demilestoned': 'Meilenstein entfernt',
|
||||
'forge.timeline.event.labeled': 'Label hinzugefügt',
|
||||
'forge.timeline.event.merged': 'Pull Request zusammengeführt',
|
||||
'forge.timeline.event.milestoned': 'Meilenstein hinzugefügt',
|
||||
'forge.timeline.event.opened': 'Pull Request geöffnet',
|
||||
'forge.timeline.event.other': 'Sonstige Aktivität',
|
||||
'forge.timeline.event.referenced': 'Pull Request referenziert',
|
||||
'forge.timeline.event.reopened': 'Pull Request erneut geöffnet',
|
||||
'forge.timeline.event.requested-changes': 'Änderungen angefordert',
|
||||
'forge.timeline.event.reviewed': 'Pull Request überprüft',
|
||||
'forge.timeline.event.unassigned': 'Benutzerzuweisung entfernt',
|
||||
'forge.timeline.event.unlabeled': 'Label entfernt',
|
||||
'forge.updated': 'Aktualisiert',
|
||||
'contextUsage.aria.label': 'Kontextnutzung',
|
||||
'contextUsage.mobile.title': 'Kontextnutzung',
|
||||
'contextUsage.mobile.usedTokens': 'Verwendete Tokens',
|
||||
|
||||
@@ -1533,6 +1533,54 @@ export const dict = {
|
||||
'filesView.editor.htmlPreviewTitle': 'HTML Preview',
|
||||
'filesView.diagram.closeDiagramView': 'Close diagram view',
|
||||
'filesView.diagram.saveDiagram': 'Save diagram',
|
||||
'forge.author': 'Author',
|
||||
'forge.baseToHead': '{head} into {base}',
|
||||
'forge.checks.empty': 'No checks',
|
||||
'forge.checks.state.cancelled': 'Cancelled',
|
||||
'forge.checks.state.failure': 'Failed',
|
||||
'forge.checks.state.pending': 'Pending',
|
||||
'forge.checks.state.skipped': 'Skipped',
|
||||
'forge.checks.state.success': 'Success',
|
||||
'forge.checks.state.unknown': 'Unknown',
|
||||
'forge.checks.statusStrip': 'Commit statuses',
|
||||
'forge.comment.inlineAt': 'At {path}:{line}',
|
||||
'forge.commits.empty': 'No commits found',
|
||||
'forge.commits.parents': 'Parents',
|
||||
'forge.copied': 'Commit hash copied',
|
||||
'forge.created': 'Created',
|
||||
'forge.draft': 'Draft',
|
||||
'forge.error': 'Failed to load',
|
||||
'forge.files.empty': 'No files changed',
|
||||
'forge.files.noDiff': 'No diff available',
|
||||
'forge.loading': 'Loading...',
|
||||
'forge.notConnected': 'Your git forge is not connected',
|
||||
'forge.section.checks': 'Checks',
|
||||
'forge.section.commits': 'Commits',
|
||||
'forge.section.files': 'Changed files',
|
||||
'forge.section.metadata': 'Details',
|
||||
'forge.section.timeline': 'Activity',
|
||||
'forge.state.closed': 'Closed',
|
||||
'forge.state.merged': 'Merged',
|
||||
'forge.state.open': 'Open',
|
||||
'forge.timeline.empty': 'No activity yet',
|
||||
'forge.timeline.event.approved': 'Approved the pull request',
|
||||
'forge.timeline.event.assigned': 'Assigned a user',
|
||||
'forge.timeline.event.closed': 'Closed the pull request',
|
||||
'forge.timeline.event.commented': 'Commented',
|
||||
'forge.timeline.event.committed': 'Added a commit',
|
||||
'forge.timeline.event.demilestoned': 'Removed the milestone',
|
||||
'forge.timeline.event.labeled': 'Added a label',
|
||||
'forge.timeline.event.merged': 'Merged the pull request',
|
||||
'forge.timeline.event.milestoned': 'Added a milestone',
|
||||
'forge.timeline.event.opened': 'Opened the pull request',
|
||||
'forge.timeline.event.other': 'Other activity',
|
||||
'forge.timeline.event.referenced': 'Referenced the pull request',
|
||||
'forge.timeline.event.reopened': 'Reopened the pull request',
|
||||
'forge.timeline.event.requested-changes': 'Requested changes',
|
||||
'forge.timeline.event.reviewed': 'Reviewed the pull request',
|
||||
'forge.timeline.event.unassigned': 'Unassigned a user',
|
||||
'forge.timeline.event.unlabeled': 'Removed a label',
|
||||
'forge.updated': 'Updated',
|
||||
'contextUsage.aria.label': 'Context usage',
|
||||
'contextUsage.mobile.title': 'Context Usage',
|
||||
'contextUsage.mobile.usedTokens': 'Used tokens',
|
||||
|
||||
@@ -1499,6 +1499,55 @@ export const dict: Record<I18nKey, string> = {
|
||||
"filesView.editor.htmlPreviewTitle": "Vista previa HTML",
|
||||
"filesView.diagram.closeDiagramView": "Cerrar vista de diagrama",
|
||||
"filesView.diagram.saveDiagram": "Guardar diagrama",
|
||||
|
||||
'forge.author': 'Autor',
|
||||
'forge.baseToHead': '{head} en {base}',
|
||||
'forge.checks.empty': 'Sin comprobaciones',
|
||||
'forge.checks.state.cancelled': 'Cancelado',
|
||||
'forge.checks.state.failure': 'Fallido',
|
||||
'forge.checks.state.pending': 'Pendiente',
|
||||
'forge.checks.state.skipped': 'Omitido',
|
||||
'forge.checks.state.success': 'Correcto',
|
||||
'forge.checks.state.unknown': 'Desconocido',
|
||||
'forge.checks.statusStrip': 'Estados de commit',
|
||||
'forge.comment.inlineAt': 'En {path}:{line}',
|
||||
'forge.commits.empty': 'No se encontraron commits',
|
||||
'forge.commits.parents': 'Padres',
|
||||
'forge.copied': 'Hash de commit copiado',
|
||||
'forge.created': 'Creado',
|
||||
'forge.draft': 'Borrador',
|
||||
'forge.error': 'Error al cargar',
|
||||
'forge.files.empty': 'No hay archivos modificados',
|
||||
'forge.files.noDiff': 'No hay diff disponible',
|
||||
'forge.loading': 'Cargando...',
|
||||
'forge.notConnected': 'Tu forge de git no está conectado',
|
||||
'forge.section.checks': 'Comprobaciones',
|
||||
'forge.section.commits': 'Commits',
|
||||
'forge.section.files': 'Archivos modificados',
|
||||
'forge.section.metadata': 'Detalles',
|
||||
'forge.section.timeline': 'Actividad',
|
||||
'forge.state.closed': 'Cerrado',
|
||||
'forge.state.merged': 'Combinado',
|
||||
'forge.state.open': 'Abierto',
|
||||
'forge.timeline.empty': 'Aún no hay actividad',
|
||||
'forge.timeline.event.approved': 'Aprobó el pull request',
|
||||
'forge.timeline.event.assigned': 'Asignó un usuario',
|
||||
'forge.timeline.event.closed': 'Cerró el pull request',
|
||||
'forge.timeline.event.commented': 'Comentó',
|
||||
'forge.timeline.event.committed': 'Añadió un commit',
|
||||
'forge.timeline.event.demilestoned': 'Eliminó el hito',
|
||||
'forge.timeline.event.labeled': 'Añadió una etiqueta',
|
||||
'forge.timeline.event.merged': 'Combinó el pull request',
|
||||
'forge.timeline.event.milestoned': 'Añadió un hito',
|
||||
'forge.timeline.event.opened': 'Abrió el pull request',
|
||||
'forge.timeline.event.other': 'Otra actividad',
|
||||
'forge.timeline.event.referenced': 'Hizo referencia al pull request',
|
||||
'forge.timeline.event.reopened': 'Reabrió el pull request',
|
||||
'forge.timeline.event.requested-changes': 'Solicitó cambios',
|
||||
'forge.timeline.event.reviewed': 'Revisó el pull request',
|
||||
'forge.timeline.event.unassigned': 'Desasignó un usuario',
|
||||
'forge.timeline.event.unlabeled': 'Eliminó una etiqueta',
|
||||
'forge.updated': 'Actualizado',
|
||||
"contextUsage.aria.label": "Uso del contexto",
|
||||
"contextUsage.mobile.title": "Uso del contexto",
|
||||
"contextUsage.mobile.usedTokens": "Tokens usados",
|
||||
|
||||
@@ -3205,6 +3205,55 @@ export const dict = {
|
||||
'contextPanel.browser.trustNotice': 'Les pages ouvertes ici s’exécutent avec un accès complet à OpenChamber — nécessaire pour l’inspection et les captures d’écran. N’ouvrez que des sites de confiance : une page malveillante pourrait lire vos données ou agir en votre nom.',
|
||||
'filesView.diagram.closeDiagramView': 'Fermer la vue diagramme',
|
||||
'filesView.diagram.saveDiagram': 'Enregistrer le diagramme',
|
||||
|
||||
'forge.author': 'Auteur',
|
||||
'forge.baseToHead': '{head} dans {base}',
|
||||
'forge.checks.empty': 'Aucune vérification',
|
||||
'forge.checks.state.cancelled': 'Annulé',
|
||||
'forge.checks.state.failure': 'Échec',
|
||||
'forge.checks.state.pending': 'En attente',
|
||||
'forge.checks.state.skipped': 'Ignoré',
|
||||
'forge.checks.state.success': 'Réussi',
|
||||
'forge.checks.state.unknown': 'Inconnu',
|
||||
'forge.checks.statusStrip': 'Statuts de commit',
|
||||
'forge.comment.inlineAt': 'À {path}:{line}',
|
||||
'forge.commits.empty': 'Aucun commit trouvé',
|
||||
'forge.commits.parents': 'Parents',
|
||||
'forge.copied': 'Hash du commit copié',
|
||||
'forge.created': 'Créé',
|
||||
'forge.draft': 'Brouillon',
|
||||
'forge.error': 'Échec du chargement',
|
||||
'forge.files.empty': 'Aucun fichier modifié',
|
||||
'forge.files.noDiff': 'Aucun diff disponible',
|
||||
'forge.loading': 'Chargement...',
|
||||
'forge.notConnected': 'Votre forge git n\'est pas connectée',
|
||||
'forge.section.checks': 'Vérifications',
|
||||
'forge.section.commits': 'Commits',
|
||||
'forge.section.files': 'Fichiers modifiés',
|
||||
'forge.section.metadata': 'Détails',
|
||||
'forge.section.timeline': 'Activité',
|
||||
'forge.state.closed': 'Fermé',
|
||||
'forge.state.merged': 'Fusionné',
|
||||
'forge.state.open': 'Ouvert',
|
||||
'forge.timeline.empty': 'Aucune activité pour le moment',
|
||||
'forge.timeline.event.approved': 'A approuvé la pull request',
|
||||
'forge.timeline.event.assigned': 'A assigné un utilisateur',
|
||||
'forge.timeline.event.closed': 'A fermé la pull request',
|
||||
'forge.timeline.event.commented': 'A commenté',
|
||||
'forge.timeline.event.committed': 'A ajouté un commit',
|
||||
'forge.timeline.event.demilestoned': 'A retiré le jalon',
|
||||
'forge.timeline.event.labeled': 'A ajouté une étiquette',
|
||||
'forge.timeline.event.merged': 'A fusionné la pull request',
|
||||
'forge.timeline.event.milestoned': 'A ajouté un jalon',
|
||||
'forge.timeline.event.opened': 'A ouvert la pull request',
|
||||
'forge.timeline.event.other': 'Autre activité',
|
||||
'forge.timeline.event.referenced': 'A référencé la pull request',
|
||||
'forge.timeline.event.reopened': 'A rouvert la pull request',
|
||||
'forge.timeline.event.requested-changes': 'A demandé des modifications',
|
||||
'forge.timeline.event.reviewed': 'A relu la pull request',
|
||||
'forge.timeline.event.unassigned': 'A retiré un utilisateur',
|
||||
'forge.timeline.event.unlabeled': 'A retiré une étiquette',
|
||||
'forge.updated': 'Mis à jour',
|
||||
'inlineComment.input.placeholderShort': 'Ajouter un commentaire...',
|
||||
'header.services.remoteUpdate.title': 'Mise à jour de l’instance distante',
|
||||
'header.services.remoteUpdate.checking': 'Recherche de mises à jour...',
|
||||
|
||||
@@ -1529,6 +1529,55 @@ export const dict: Record<I18nKey, string> = {
|
||||
'filesView.editor.htmlPreviewTitle': 'HTMLプレビュー',
|
||||
'filesView.diagram.closeDiagramView': 'ダイアグラムビューを閉じる',
|
||||
'filesView.diagram.saveDiagram': 'ダイアグラムを保存',
|
||||
|
||||
'forge.author': '作成者',
|
||||
'forge.baseToHead': '{head} を {base} に',
|
||||
'forge.checks.empty': 'チェックなし',
|
||||
'forge.checks.state.cancelled': 'キャンセル',
|
||||
'forge.checks.state.failure': '失敗',
|
||||
'forge.checks.state.pending': '保留中',
|
||||
'forge.checks.state.skipped': 'スキップ',
|
||||
'forge.checks.state.success': '成功',
|
||||
'forge.checks.state.unknown': '不明',
|
||||
'forge.checks.statusStrip': 'コミットステータス',
|
||||
'forge.comment.inlineAt': '{path}:{line}',
|
||||
'forge.commits.empty': 'コミットがありません',
|
||||
'forge.commits.parents': '親コミット',
|
||||
'forge.copied': 'コミットハッシュをコピーしました',
|
||||
'forge.created': '作成日',
|
||||
'forge.draft': '下書き',
|
||||
'forge.error': '読み込みに失敗しました',
|
||||
'forge.files.empty': '変更されたファイルはありません',
|
||||
'forge.files.noDiff': '差分はありません',
|
||||
'forge.loading': '読み込み中...',
|
||||
'forge.notConnected': 'Gitフォージに接続されていません',
|
||||
'forge.section.checks': 'チェック',
|
||||
'forge.section.commits': 'コミット',
|
||||
'forge.section.files': '変更されたファイル',
|
||||
'forge.section.metadata': '詳細',
|
||||
'forge.section.timeline': 'アクティビティ',
|
||||
'forge.state.closed': 'クローズ',
|
||||
'forge.state.merged': 'マージ済み',
|
||||
'forge.state.open': 'オープン',
|
||||
'forge.timeline.empty': 'アクティビティはまだありません',
|
||||
'forge.timeline.event.approved': 'プルリクエストを承認しました',
|
||||
'forge.timeline.event.assigned': 'ユーザーをアサインしました',
|
||||
'forge.timeline.event.closed': 'プルリクエストをクローズしました',
|
||||
'forge.timeline.event.commented': 'コメントしました',
|
||||
'forge.timeline.event.committed': 'コミットを追加しました',
|
||||
'forge.timeline.event.demilestoned': 'マイルストーンを解除しました',
|
||||
'forge.timeline.event.labeled': 'ラベルを追加しました',
|
||||
'forge.timeline.event.merged': 'プルリクエストをマージしました',
|
||||
'forge.timeline.event.milestoned': 'マイルストーンを追加しました',
|
||||
'forge.timeline.event.opened': 'プルリクエストをオープンしました',
|
||||
'forge.timeline.event.other': 'その他のアクティビティ',
|
||||
'forge.timeline.event.referenced': 'プルリクエストを参照しました',
|
||||
'forge.timeline.event.reopened': 'プルリクエストを再オープンしました',
|
||||
'forge.timeline.event.requested-changes': '変更を要求しました',
|
||||
'forge.timeline.event.reviewed': 'プルリクエストをレビューしました',
|
||||
'forge.timeline.event.unassigned': 'ユーザーのアサインを解除しました',
|
||||
'forge.timeline.event.unlabeled': 'ラベルを削除しました',
|
||||
'forge.updated': '更新日',
|
||||
'contextUsage.aria.label': 'コンテキスト使用量',
|
||||
'contextUsage.mobile.title': 'コンテキスト使用量',
|
||||
'contextUsage.mobile.usedTokens': '使用トークン',
|
||||
|
||||
@@ -1535,6 +1535,55 @@ export const dict: Record<I18nKey, string> = {
|
||||
'filesView.editor.htmlPreviewTitle': 'HTML 미리보기',
|
||||
'filesView.diagram.closeDiagramView': '다이어그램 보기 닫기',
|
||||
'filesView.diagram.saveDiagram': '다이어그램 저장',
|
||||
|
||||
'forge.author': '작성자',
|
||||
'forge.baseToHead': '{head} → {base}',
|
||||
'forge.checks.empty': '확인 항목 없음',
|
||||
'forge.checks.state.cancelled': '취소됨',
|
||||
'forge.checks.state.failure': '실패',
|
||||
'forge.checks.state.pending': '대기 중',
|
||||
'forge.checks.state.skipped': '건너뜀',
|
||||
'forge.checks.state.success': '성공',
|
||||
'forge.checks.state.unknown': '알 수 없음',
|
||||
'forge.checks.statusStrip': '커밋 상태',
|
||||
'forge.comment.inlineAt': '{path}:{line}',
|
||||
'forge.commits.empty': '커밋이 없습니다',
|
||||
'forge.commits.parents': '부모 커밋',
|
||||
'forge.copied': '커밋 해시가 복사되었습니다',
|
||||
'forge.created': '작성일',
|
||||
'forge.draft': '초안',
|
||||
'forge.error': '불러오지 못했습니다',
|
||||
'forge.files.empty': '변경된 파일이 없습니다',
|
||||
'forge.files.noDiff': 'diff를 사용할 수 없습니다',
|
||||
'forge.loading': '불러오는 중...',
|
||||
'forge.notConnected': 'Git 포지에 연결되어 있지 않습니다',
|
||||
'forge.section.checks': '확인',
|
||||
'forge.section.commits': '커밋',
|
||||
'forge.section.files': '변경된 파일',
|
||||
'forge.section.metadata': '세부 정보',
|
||||
'forge.section.timeline': '활동',
|
||||
'forge.state.closed': '닫힘',
|
||||
'forge.state.merged': '병합됨',
|
||||
'forge.state.open': '열림',
|
||||
'forge.timeline.empty': '아직 활동이 없습니다',
|
||||
'forge.timeline.event.approved': '풀 리퀘스트를 승인했습니다',
|
||||
'forge.timeline.event.assigned': '사용자를 지정했습니다',
|
||||
'forge.timeline.event.closed': '풀 리퀘스트를 닫았습니다',
|
||||
'forge.timeline.event.commented': '댓글을 달았습니다',
|
||||
'forge.timeline.event.committed': '커밋을 추가했습니다',
|
||||
'forge.timeline.event.demilestoned': '마일스톤을 제거했습니다',
|
||||
'forge.timeline.event.labeled': '레이블을 추가했습니다',
|
||||
'forge.timeline.event.merged': '풀 리퀘스트를 병합했습니다',
|
||||
'forge.timeline.event.milestoned': '마일스톤을 추가했습니다',
|
||||
'forge.timeline.event.opened': '풀 리퀘스트를 열었습니다',
|
||||
'forge.timeline.event.other': '기타 활동',
|
||||
'forge.timeline.event.referenced': '풀 리퀘스트를 참조했습니다',
|
||||
'forge.timeline.event.reopened': '풀 리퀘스트를 다시 열었습니다',
|
||||
'forge.timeline.event.requested-changes': '변경을 요청했습니다',
|
||||
'forge.timeline.event.reviewed': '풀 리퀘스트를 리뷰했습니다',
|
||||
'forge.timeline.event.unassigned': '사용자 지정을 해제했습니다',
|
||||
'forge.timeline.event.unlabeled': '레이블을 제거했습니다',
|
||||
'forge.updated': '업데이트일',
|
||||
'contextUsage.aria.label': '컨텍스트 사용량',
|
||||
'contextUsage.mobile.title': '컨텍스트 사용량',
|
||||
'contextUsage.mobile.usedTokens': '사용한 토큰',
|
||||
|
||||
@@ -2016,6 +2016,55 @@ export const dict: Record<I18nKey, string> = {
|
||||
'filesView.editor.switchToPreviewMode': 'Przełącz na tryb podglądu',
|
||||
'filesView.diagram.closeDiagramView': 'Zamknij widok diagramu',
|
||||
'filesView.diagram.saveDiagram': 'Zapisz diagram',
|
||||
|
||||
'forge.author': 'Autor',
|
||||
'forge.baseToHead': '{head} do {base}',
|
||||
'forge.checks.empty': 'Brak kontroli',
|
||||
'forge.checks.state.cancelled': 'Anulowano',
|
||||
'forge.checks.state.failure': 'Niepowodzenie',
|
||||
'forge.checks.state.pending': 'Oczekuje',
|
||||
'forge.checks.state.skipped': 'Pominięto',
|
||||
'forge.checks.state.success': 'Sukces',
|
||||
'forge.checks.state.unknown': 'Nieznany',
|
||||
'forge.checks.statusStrip': 'Statusy commitów',
|
||||
'forge.comment.inlineAt': 'W {path}:{line}',
|
||||
'forge.commits.empty': 'Nie znaleziono commitów',
|
||||
'forge.commits.parents': 'Rodzice',
|
||||
'forge.copied': 'Skopiowano hash commita',
|
||||
'forge.created': 'Utworzono',
|
||||
'forge.draft': 'Szkic',
|
||||
'forge.error': 'Nie udało się załadować',
|
||||
'forge.files.empty': 'Brak zmienionych plików',
|
||||
'forge.files.noDiff': 'Brak dostępnego diff',
|
||||
'forge.loading': 'Wczytywanie...',
|
||||
'forge.notConnected': 'Twoja platforma git nie jest połączona',
|
||||
'forge.section.checks': 'Kontrole',
|
||||
'forge.section.commits': 'Commity',
|
||||
'forge.section.files': 'Zmienione pliki',
|
||||
'forge.section.metadata': 'Szczegóły',
|
||||
'forge.section.timeline': 'Aktywność',
|
||||
'forge.state.closed': 'Zamknięty',
|
||||
'forge.state.merged': 'Połączono',
|
||||
'forge.state.open': 'Otwarty',
|
||||
'forge.timeline.empty': 'Brak aktywności',
|
||||
'forge.timeline.event.approved': 'Zatwierdził pull request',
|
||||
'forge.timeline.event.assigned': 'Przypisał użytkownika',
|
||||
'forge.timeline.event.closed': 'Zamknął pull request',
|
||||
'forge.timeline.event.commented': 'Skomentował',
|
||||
'forge.timeline.event.committed': 'Dodał commit',
|
||||
'forge.timeline.event.demilestoned': 'Usunął kamień milowy',
|
||||
'forge.timeline.event.labeled': 'Dodał etykietę',
|
||||
'forge.timeline.event.merged': 'Połączył pull request',
|
||||
'forge.timeline.event.milestoned': 'Dodał kamień milowy',
|
||||
'forge.timeline.event.opened': 'Otworzył pull request',
|
||||
'forge.timeline.event.other': 'Inna aktywność',
|
||||
'forge.timeline.event.referenced': 'Odwołał się do pull requesta',
|
||||
'forge.timeline.event.reopened': 'Ponownie otworzył pull request',
|
||||
'forge.timeline.event.requested-changes': 'Zażądał zmian',
|
||||
'forge.timeline.event.reviewed': 'Przejrzał pull request',
|
||||
'forge.timeline.event.unassigned': 'Usunął przypisanie użytkownika',
|
||||
'forge.timeline.event.unlabeled': 'Usunął etykietę',
|
||||
'forge.updated': 'Zaktualizowano',
|
||||
'filesView.editor.imageAltFallback': 'Obraz',
|
||||
'filesView.editor.openFilesAria': 'Otwarte pliki',
|
||||
'filesView.editor.openInDesktopApp': 'Otwórz w aplikacji desktopowej',
|
||||
|
||||
@@ -1499,6 +1499,55 @@ export const dict: Record<I18nKey, string> = {
|
||||
"filesView.editor.htmlPreviewTitle": "Pré-visualização HTML",
|
||||
"filesView.diagram.closeDiagramView": "Fechar visualização de diagrama",
|
||||
"filesView.diagram.saveDiagram": "Salvar diagrama",
|
||||
|
||||
'forge.author': 'Autor',
|
||||
'forge.baseToHead': '{head} em {base}',
|
||||
'forge.checks.empty': 'Sem verificações',
|
||||
'forge.checks.state.cancelled': 'Cancelado',
|
||||
'forge.checks.state.failure': 'Falhou',
|
||||
'forge.checks.state.pending': 'Pendente',
|
||||
'forge.checks.state.skipped': 'Ignorado',
|
||||
'forge.checks.state.success': 'Sucesso',
|
||||
'forge.checks.state.unknown': 'Desconhecido',
|
||||
'forge.checks.statusStrip': 'Status do commit',
|
||||
'forge.comment.inlineAt': 'Em {path}:{line}',
|
||||
'forge.commits.empty': 'Nenhum commit encontrado',
|
||||
'forge.commits.parents': 'Pais',
|
||||
'forge.copied': 'Hash do commit copiado',
|
||||
'forge.created': 'Criado',
|
||||
'forge.draft': 'Rascunho',
|
||||
'forge.error': 'Falha ao carregar',
|
||||
'forge.files.empty': 'Nenhum arquivo alterado',
|
||||
'forge.files.noDiff': 'Nenhum diff disponível',
|
||||
'forge.loading': 'Carregando...',
|
||||
'forge.notConnected': 'Sua plataforma git não está conectada',
|
||||
'forge.section.checks': 'Verificações',
|
||||
'forge.section.commits': 'Commits',
|
||||
'forge.section.files': 'Arquivos alterados',
|
||||
'forge.section.metadata': 'Detalhes',
|
||||
'forge.section.timeline': 'Atividade',
|
||||
'forge.state.closed': 'Fechado',
|
||||
'forge.state.merged': 'Mesclado',
|
||||
'forge.state.open': 'Aberto',
|
||||
'forge.timeline.empty': 'Nenhuma atividade ainda',
|
||||
'forge.timeline.event.approved': 'Aprovou o pull request',
|
||||
'forge.timeline.event.assigned': 'Atribuiu um usuário',
|
||||
'forge.timeline.event.closed': 'Fechou o pull request',
|
||||
'forge.timeline.event.commented': 'Comentou',
|
||||
'forge.timeline.event.committed': 'Adicionou um commit',
|
||||
'forge.timeline.event.demilestoned': 'Removeu o marco',
|
||||
'forge.timeline.event.labeled': 'Adicionou um rótulo',
|
||||
'forge.timeline.event.merged': 'Mesclou o pull request',
|
||||
'forge.timeline.event.milestoned': 'Adicionou um marco',
|
||||
'forge.timeline.event.opened': 'Abriu o pull request',
|
||||
'forge.timeline.event.other': 'Outra atividade',
|
||||
'forge.timeline.event.referenced': 'Referenciou o pull request',
|
||||
'forge.timeline.event.reopened': 'Reabriu o pull request',
|
||||
'forge.timeline.event.requested-changes': 'Solicitou alterações',
|
||||
'forge.timeline.event.reviewed': 'Revisou o pull request',
|
||||
'forge.timeline.event.unassigned': 'Removeu a atribuição de um usuário',
|
||||
'forge.timeline.event.unlabeled': 'Removeu um rótulo',
|
||||
'forge.updated': 'Atualizado',
|
||||
"contextUsage.aria.label": "Uso do contexto",
|
||||
"contextUsage.mobile.title": "Uso do contexto",
|
||||
"contextUsage.mobile.usedTokens": "Tokens usados",
|
||||
|
||||
@@ -1499,6 +1499,55 @@ export const dict: Record<I18nKey, string> = {
|
||||
"filesView.editor.htmlPreviewTitle": "Попередній перегляд HTML",
|
||||
"filesView.diagram.closeDiagramView": "Закрити перегляд діаграми",
|
||||
"filesView.diagram.saveDiagram": "Зберегти діаграму",
|
||||
|
||||
'forge.author': 'Автор',
|
||||
'forge.baseToHead': '{head} у {base}',
|
||||
'forge.checks.empty': 'Перевірок немає',
|
||||
'forge.checks.state.cancelled': 'Скасовано',
|
||||
'forge.checks.state.failure': 'Помилка',
|
||||
'forge.checks.state.pending': 'Очікує',
|
||||
'forge.checks.state.skipped': 'Пропущено',
|
||||
'forge.checks.state.success': 'Успішно',
|
||||
'forge.checks.state.unknown': 'Невідомо',
|
||||
'forge.checks.statusStrip': 'Статуси коммітів',
|
||||
'forge.comment.inlineAt': 'У {path}:{line}',
|
||||
'forge.commits.empty': 'Коммітів не знайдено',
|
||||
'forge.commits.parents': 'Батьківські комміти',
|
||||
'forge.copied': 'Хеш комміта скопійовано',
|
||||
'forge.created': 'Створено',
|
||||
'forge.draft': 'Чернетка',
|
||||
'forge.error': 'Не вдалося завантажити',
|
||||
'forge.files.empty': 'Змінених файлів немає',
|
||||
'forge.files.noDiff': 'Diff недоступний',
|
||||
'forge.loading': 'Завантаження...',
|
||||
'forge.notConnected': 'Вашу Git-платформу не підключено',
|
||||
'forge.section.checks': 'Перевірки',
|
||||
'forge.section.commits': 'Комміти',
|
||||
'forge.section.files': 'Змінені файли',
|
||||
'forge.section.metadata': 'Деталі',
|
||||
'forge.section.timeline': 'Активність',
|
||||
'forge.state.closed': 'Закрито',
|
||||
'forge.state.merged': 'Злито',
|
||||
'forge.state.open': 'Відкрито',
|
||||
'forge.timeline.empty': 'Активності ще немає',
|
||||
'forge.timeline.event.approved': 'Схвалив pull request',
|
||||
'forge.timeline.event.assigned': 'Призначив користувача',
|
||||
'forge.timeline.event.closed': 'Закрив pull request',
|
||||
'forge.timeline.event.commented': 'Прокоментував',
|
||||
'forge.timeline.event.committed': 'Додав комміт',
|
||||
'forge.timeline.event.demilestoned': 'Зняв віху',
|
||||
'forge.timeline.event.labeled': 'Додав мітку',
|
||||
'forge.timeline.event.merged': 'Злив pull request',
|
||||
'forge.timeline.event.milestoned': 'Додав віху',
|
||||
'forge.timeline.event.opened': 'Відкрив pull request',
|
||||
'forge.timeline.event.other': 'Інша активність',
|
||||
'forge.timeline.event.referenced': 'Послався на pull request',
|
||||
'forge.timeline.event.reopened': 'Повторно відкрив pull request',
|
||||
'forge.timeline.event.requested-changes': 'Зажадав змін',
|
||||
'forge.timeline.event.reviewed': 'Переглянув pull request',
|
||||
'forge.timeline.event.unassigned': 'Зняв призначення користувача',
|
||||
'forge.timeline.event.unlabeled': 'Видалив мітку',
|
||||
'forge.updated': 'Оновлено',
|
||||
"contextUsage.aria.label": "Використання контексту",
|
||||
"contextUsage.mobile.title": "Використання контексту",
|
||||
"contextUsage.mobile.usedTokens": "Використані токени",
|
||||
|
||||
@@ -1499,6 +1499,55 @@ export const dict: Record<I18nKey, string> = {
|
||||
'filesView.editor.htmlPreviewTitle': 'HTML 预览',
|
||||
'filesView.diagram.closeDiagramView': '关闭图表视图',
|
||||
'filesView.diagram.saveDiagram': '保存图表',
|
||||
|
||||
'forge.author': '作者',
|
||||
'forge.baseToHead': '{head} 合入 {base}',
|
||||
'forge.checks.empty': '无检查项',
|
||||
'forge.checks.state.cancelled': '已取消',
|
||||
'forge.checks.state.failure': '失败',
|
||||
'forge.checks.state.pending': '等待中',
|
||||
'forge.checks.state.skipped': '已跳过',
|
||||
'forge.checks.state.success': '成功',
|
||||
'forge.checks.state.unknown': '未知',
|
||||
'forge.checks.statusStrip': '提交状态',
|
||||
'forge.comment.inlineAt': '位于 {path}:{line}',
|
||||
'forge.commits.empty': '未找到提交',
|
||||
'forge.commits.parents': '父提交',
|
||||
'forge.copied': '提交哈希已复制',
|
||||
'forge.created': '创建时间',
|
||||
'forge.draft': '草稿',
|
||||
'forge.error': '加载失败',
|
||||
'forge.files.empty': '无文件更改',
|
||||
'forge.files.noDiff': '无可用差异',
|
||||
'forge.loading': '加载中...',
|
||||
'forge.notConnected': '你的 Git 平台未连接',
|
||||
'forge.section.checks': '检查',
|
||||
'forge.section.commits': '提交',
|
||||
'forge.section.files': '更改的文件',
|
||||
'forge.section.metadata': '详情',
|
||||
'forge.section.timeline': '动态',
|
||||
'forge.state.closed': '已关闭',
|
||||
'forge.state.merged': '已合并',
|
||||
'forge.state.open': '开启',
|
||||
'forge.timeline.empty': '暂无动态',
|
||||
'forge.timeline.event.approved': '批准了拉取请求',
|
||||
'forge.timeline.event.assigned': '指派了用户',
|
||||
'forge.timeline.event.closed': '关闭了拉取请求',
|
||||
'forge.timeline.event.commented': '发表了评论',
|
||||
'forge.timeline.event.committed': '添加了提交',
|
||||
'forge.timeline.event.demilestoned': '移除了里程碑',
|
||||
'forge.timeline.event.labeled': '添加了标签',
|
||||
'forge.timeline.event.merged': '合并了拉取请求',
|
||||
'forge.timeline.event.milestoned': '添加了里程碑',
|
||||
'forge.timeline.event.opened': '打开了拉取请求',
|
||||
'forge.timeline.event.other': '其他动态',
|
||||
'forge.timeline.event.referenced': '引用了拉取请求',
|
||||
'forge.timeline.event.reopened': '重新打开了拉取请求',
|
||||
'forge.timeline.event.requested-changes': '请求了变更',
|
||||
'forge.timeline.event.reviewed': '审阅了拉取请求',
|
||||
'forge.timeline.event.unassigned': '取消了指派用户',
|
||||
'forge.timeline.event.unlabeled': '移除了标签',
|
||||
'forge.updated': '更新时间',
|
||||
'contextUsage.aria.label': '上下文用量',
|
||||
'contextUsage.mobile.title': '上下文用量',
|
||||
'contextUsage.mobile.usedTokens': '已用 Token',
|
||||
|
||||
@@ -1509,6 +1509,55 @@ export const dict: Record<I18nKey, string> = {
|
||||
'filesView.editor.htmlPreviewTitle': 'HTML 預覽',
|
||||
'filesView.diagram.closeDiagramView': '關閉圖表檢視',
|
||||
'filesView.diagram.saveDiagram': '儲存圖表',
|
||||
|
||||
'forge.author': '作者',
|
||||
'forge.baseToHead': '{head} 合入 {base}',
|
||||
'forge.checks.empty': '無檢查項目',
|
||||
'forge.checks.state.cancelled': '已取消',
|
||||
'forge.checks.state.failure': '失敗',
|
||||
'forge.checks.state.pending': '等待中',
|
||||
'forge.checks.state.skipped': '已略過',
|
||||
'forge.checks.state.success': '成功',
|
||||
'forge.checks.state.unknown': '未知',
|
||||
'forge.checks.statusStrip': '提交狀態',
|
||||
'forge.comment.inlineAt': '位於 {path}:{line}',
|
||||
'forge.commits.empty': '找不到提交',
|
||||
'forge.commits.parents': '父提交',
|
||||
'forge.copied': '已複製提交雜湊',
|
||||
'forge.created': '建立時間',
|
||||
'forge.draft': '草稿',
|
||||
'forge.error': '載入失敗',
|
||||
'forge.files.empty': '沒有檔案變更',
|
||||
'forge.files.noDiff': '沒有可用的差異',
|
||||
'forge.loading': '載入中...',
|
||||
'forge.notConnected': '你的 Git 平台尚未連線',
|
||||
'forge.section.checks': '檢查',
|
||||
'forge.section.commits': '提交',
|
||||
'forge.section.files': '變更的檔案',
|
||||
'forge.section.metadata': '詳細資料',
|
||||
'forge.section.timeline': '動態',
|
||||
'forge.state.closed': '已關閉',
|
||||
'forge.state.merged': '已合併',
|
||||
'forge.state.open': '開啟',
|
||||
'forge.timeline.empty': '尚無動態',
|
||||
'forge.timeline.event.approved': '已核准拉取請求',
|
||||
'forge.timeline.event.assigned': '已指派使用者',
|
||||
'forge.timeline.event.closed': '已關閉拉取請求',
|
||||
'forge.timeline.event.commented': '已留言',
|
||||
'forge.timeline.event.committed': '已新增提交',
|
||||
'forge.timeline.event.demilestoned': '已移除里程碑',
|
||||
'forge.timeline.event.labeled': '已新增標籤',
|
||||
'forge.timeline.event.merged': '已合併拉取請求',
|
||||
'forge.timeline.event.milestoned': '已新增里程碑',
|
||||
'forge.timeline.event.opened': '已開啟拉取請求',
|
||||
'forge.timeline.event.other': '其他動態',
|
||||
'forge.timeline.event.referenced': '已引用拉取請求',
|
||||
'forge.timeline.event.reopened': '已重新開啟拉取請求',
|
||||
'forge.timeline.event.requested-changes': '已要求變更',
|
||||
'forge.timeline.event.reviewed': '已審閱拉取請求',
|
||||
'forge.timeline.event.unassigned': '已取消指派使用者',
|
||||
'forge.timeline.event.unlabeled': '已移除標籤',
|
||||
'forge.updated': '更新時間',
|
||||
'contextUsage.aria.label': '上下文用量',
|
||||
'contextUsage.mobile.title': '上下文用量',
|
||||
'contextUsage.mobile.usedTokens': '已用 Token',
|
||||
|
||||
Reference in New Issue
Block a user