feat(ui): forge write operations — comments, replies, close/reopen, edit, reviews, draft, metadata
- server: write routes for all three providers (issue/PR comments, inline review-comment replies, issue/MR updates w/ labels-assignees-milestone, review submit, draft toggle) - ui: ForgeProvider gains six write ops; shared action components (composer, thread reply, state/review/draft/metadata/edit) wired into ForgeEntityDetailView and GitHub PR Overview
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
@@ -7,6 +7,7 @@ import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
|
||||
import type {
|
||||
ForgeChecksResult,
|
||||
ForgeCommitsResult,
|
||||
ForgeEntityRef,
|
||||
ForgeIssueDetail,
|
||||
ForgeProvider,
|
||||
ForgePullRequestContext,
|
||||
@@ -18,6 +19,12 @@ import { ForgeCommitsSection } from './ForgeCommitsSection';
|
||||
import { ForgeFilesDiffSection } from './ForgeFilesDiffSection';
|
||||
import { ForgeTimelineSection } from './ForgeTimelineSection';
|
||||
import { ForgeChecksSection } from './ForgeChecksSection';
|
||||
import {
|
||||
ForgeCommentComposer,
|
||||
ForgeEntityActions,
|
||||
ForgeMetadataEditor,
|
||||
ForgeThreadReply,
|
||||
} from './actions';
|
||||
|
||||
interface ForgeEntityDetailViewProps {
|
||||
provider: ForgeProvider;
|
||||
@@ -95,11 +102,32 @@ export const ForgeEntityDetailView: React.FC<ForgeEntityDetailViewProps> = ({ pr
|
||||
const [pull, setPull] = useState<PullData | null>(null);
|
||||
const [issueDetail, setIssueDetail] = useState<ForgeIssueDetail | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
// Bumped after a successful write so the owning load effect re-runs; never
|
||||
// bumped on render, so writes are the only trigger.
|
||||
const [reloadToken, setReloadToken] = useState(0);
|
||||
// Comments posted through this view are appended locally so they appear
|
||||
// immediately; a later context refresh reconciles them with authoritative
|
||||
// server data (and the load effect clears the local list).
|
||||
const [localComments, setLocalComments] = useState<ForgeComment[]>([]);
|
||||
// Id of the thread root the user is replying to (renders ForgeThreadReply
|
||||
// under that thread card).
|
||||
const [replyingTo, setReplyingTo] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
setReloadToken((value) => value + 1);
|
||||
}, []);
|
||||
|
||||
const ref = useMemo<ForgeEntityRef>(() => ({ kind: isIssue ? 'issue' : 'pull', number }), [isIssue, number]);
|
||||
|
||||
const appendComment = useCallback((comment: ForgeComment) => {
|
||||
setLocalComments((previous) => [...previous, comment]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setPull(null);
|
||||
setIssueDetail(null);
|
||||
setLocalComments([]);
|
||||
setIsLoading(true);
|
||||
|
||||
if (isIssue) {
|
||||
@@ -147,13 +175,14 @@ export const ForgeEntityDetailView: React.FC<ForgeEntityDetailViewProps> = ({ pr
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [directory, isIssue, number, provider, sourceRepo]);
|
||||
}, [directory, isIssue, number, provider, reloadToken, 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 derived = isIssue
|
||||
? issueDetail?.comments ?? []
|
||||
: [...(pull?.context?.issueComments ?? []), ...(pull?.context?.reviewComments ?? [])];
|
||||
return [...localComments, ...derived];
|
||||
}, [isIssue, issueDetail?.comments, localComments, pull?.context]);
|
||||
|
||||
const timelineEvents = useMemo<ForgeTimelineEvent[]>(() => pull?.timeline?.events ?? [], [pull?.timeline]);
|
||||
|
||||
@@ -170,6 +199,32 @@ export const ForgeEntityDetailView: React.FC<ForgeEntityDetailViewProps> = ({ pr
|
||||
return null;
|
||||
}, [pull?.context, provider.capabilities.checks, pull?.checks]);
|
||||
|
||||
const canReply = typeof provider.replyToThread === 'function';
|
||||
|
||||
const handleReply = useCallback((comment: ForgeComment) => {
|
||||
setReplyingTo(comment.id);
|
||||
}, []);
|
||||
|
||||
const renderThreadReply = useCallback(
|
||||
(comment: ForgeComment): React.ReactNode => {
|
||||
if (comment.id !== replyingTo) return null;
|
||||
return (
|
||||
<ForgeThreadReply
|
||||
provider={provider}
|
||||
directory={directory}
|
||||
ref={ref}
|
||||
thread={{ inReplyToId: comment.id, path: comment.path ?? null, line: comment.line ?? null }}
|
||||
onPosted={(created) => {
|
||||
appendComment(created);
|
||||
setReplyingTo(null);
|
||||
}}
|
||||
onCancel={() => setReplyingTo(null)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[appendComment, directory, provider, ref, replyingTo],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingBlock label={t('forge.loading')} />;
|
||||
}
|
||||
@@ -194,7 +249,17 @@ export const ForgeEntityDetailView: React.FC<ForgeEntityDetailViewProps> = ({ pr
|
||||
{t(`forge.state.${issueState}`)}
|
||||
</span>
|
||||
</div>
|
||||
<ForgeEntityActions provider={provider} directory={directory} ref={ref} issue={issue} onChanged={reload} />
|
||||
<ForgeMetadataChips kind="issue" issue={issue} />
|
||||
<ForgeMetadataEditor
|
||||
provider={provider}
|
||||
directory={directory}
|
||||
ref={ref}
|
||||
labels={issue.labels ?? []}
|
||||
assignees={issue.assignees ?? []}
|
||||
milestone={issue.milestone}
|
||||
onChanged={reload}
|
||||
/>
|
||||
{issue.body ? (
|
||||
<SimpleMarkdownRenderer content={issue.body} className={markdownClassName} enableFileReferences={false} />
|
||||
) : null}
|
||||
@@ -202,10 +267,13 @@ export const ForgeEntityDetailView: React.FC<ForgeEntityDetailViewProps> = ({ pr
|
||||
<SectionTitle>{t('forge.section.timeline')}</SectionTitle>
|
||||
<ForgeTimelineSection
|
||||
events={[]}
|
||||
comments={issueDetail.comments ?? []}
|
||||
comments={mergedComments}
|
||||
error={issueDetail.commentsError ?? null}
|
||||
onReply={canReply ? handleReply : undefined}
|
||||
renderReply={canReply ? renderThreadReply : undefined}
|
||||
/>
|
||||
</section>
|
||||
<ForgeCommentComposer provider={provider} directory={directory} ref={ref} onPosted={appendComment} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -242,6 +310,8 @@ export const ForgeEntityDetailView: React.FC<ForgeEntityDetailViewProps> = ({ pr
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<ForgeEntityActions provider={provider} directory={directory} ref={ref} pr={pr} onChanged={reload} />
|
||||
|
||||
<ForgeMetadataChips kind="pull" pr={pr} />
|
||||
|
||||
{checksForPull ? (
|
||||
@@ -273,8 +343,11 @@ export const ForgeEntityDetailView: React.FC<ForgeEntityDetailViewProps> = ({ pr
|
||||
events={timelineEvents}
|
||||
comments={mergedComments}
|
||||
error={pull.timeline?.error ?? null}
|
||||
onReply={canReply ? handleReply : undefined}
|
||||
renderReply={canReply ? renderThreadReply : undefined}
|
||||
/>
|
||||
</section>
|
||||
<ForgeCommentComposer provider={provider} directory={directory} ref={ref} onPosted={appendComment} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useMemo } 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 { formatDateTimeForPreference } from '@/lib/timeFormat';
|
||||
@@ -13,6 +14,10 @@ interface ForgeTimelineSectionProps {
|
||||
comments: ForgeComment[];
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
/** Optional: asked when the user hits Reply on an inline-comment thread (its root comment). */
|
||||
onReply?: (comment: ForgeComment) => void;
|
||||
/** Optional: rendered under a thread card the parent is replying to. */
|
||||
renderReply?: (comment: ForgeComment) => React.ReactNode;
|
||||
}
|
||||
|
||||
const EVENT_ICONS: Record<ForgeTimelineEventType, IconName> = {
|
||||
@@ -83,7 +88,7 @@ type TimelineItem =
|
||||
* 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 }) {
|
||||
export const ForgeTimelineSection = React.memo<ForgeTimelineSectionProps>(function ForgeTimelineSection({ events, comments, loading, error, onReply, renderReply }) {
|
||||
const { t } = useI18n();
|
||||
const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
|
||||
|
||||
@@ -252,6 +257,19 @@ export const ForgeTimelineSection = React.memo<ForgeTimelineSectionProps>(functi
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{root.path && onReply ? (
|
||||
<div className="flex items-center gap-1.5 pt-2">
|
||||
<Button
|
||||
variant="link"
|
||||
size="xs"
|
||||
onClick={() => onReply(root)}
|
||||
aria-label={t('forge.actions.reply')}
|
||||
>
|
||||
{t('forge.actions.reply')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{renderReply ? renderReply(root) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { toast } from '@/components/ui/toast';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { ForgeEntityRef, ForgeProvider } from '@/lib/forge/provider';
|
||||
import type { ForgeComment } from '@/lib/forge/types';
|
||||
|
||||
interface ForgeCommentComposerProps {
|
||||
provider: ForgeProvider;
|
||||
directory: string;
|
||||
ref: ForgeEntityRef;
|
||||
onPosted?: (comment: ForgeComment) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Comment composer for an issue or pull request thread. Renders nothing when
|
||||
* the provider has no `addComment` method. Posts through the facade and reports
|
||||
* the created comment via `onPosted`; failures toast a stable message.
|
||||
*/
|
||||
export const ForgeCommentComposer: React.FC<ForgeCommentComposerProps> = ({ provider, directory, ref, onPosted }) => {
|
||||
const { t } = useI18n();
|
||||
const [body, setBody] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const addComment = provider.addComment;
|
||||
if (!addComment) return null;
|
||||
|
||||
const canSubmit = body.trim().length > 0 && !submitting;
|
||||
|
||||
const submit = async (): Promise<void> => {
|
||||
if (!canSubmit) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await addComment(directory, ref, { body: body.trim() });
|
||||
if (!result.ok) {
|
||||
toast.error(t('forge.actions.error'));
|
||||
return;
|
||||
}
|
||||
setBody('');
|
||||
if (result.comment) onPosted?.(result.comment);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Textarea
|
||||
value={body}
|
||||
onChange={(event) => setBody(event.target.value)}
|
||||
placeholder={t('forge.actions.commentPlaceholder')}
|
||||
disabled={submitting}
|
||||
aria-label={t('forge.actions.commentPlaceholder')}
|
||||
className="min-h-[72px]"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={() => void submit()} disabled={!canSubmit}>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Icon name="loader-4" className="size-4 animate-spin" />
|
||||
{t('forge.actions.posting')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="chat-1" className="size-4" />
|
||||
{t('forge.actions.comment')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from '@/components/ui/toast';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { ForgeEntityRef, ForgeProvider } from '@/lib/forge/provider';
|
||||
|
||||
interface ForgeDraftToggleProps {
|
||||
provider: ForgeProvider;
|
||||
directory: string;
|
||||
ref: ForgeEntityRef;
|
||||
draft: boolean;
|
||||
onChanged?: (draft: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draft <-> ready toggle for a pull request. Renders nothing unless the
|
||||
* provider supports drafts (`capabilities.draft`) and implements
|
||||
* `toggleDraft`. Marks the PR ready when it is a draft, and back to draft
|
||||
* otherwise.
|
||||
*/
|
||||
export const ForgeDraftToggle: React.FC<ForgeDraftToggleProps> = ({ provider, directory, ref, draft, onChanged }) => {
|
||||
const { t } = useI18n();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const toggleDraft = provider.toggleDraft;
|
||||
if (!toggleDraft || !provider.capabilities.draft) return null;
|
||||
|
||||
const nextDraft = !draft;
|
||||
|
||||
const run = async (): Promise<void> => {
|
||||
if (submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await toggleDraft(directory, ref, nextDraft);
|
||||
if (!result.ok) {
|
||||
toast.error(t('forge.actions.error'));
|
||||
return;
|
||||
}
|
||||
toast.success(t('forge.actions.draftChanged'));
|
||||
onChanged?.(nextDraft);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void run()}
|
||||
disabled={submitting}
|
||||
aria-label={t(draft ? 'forge.actions.markReady' : 'forge.actions.markDraft')}
|
||||
>
|
||||
{submitting ? (
|
||||
<Icon name="loader-4" className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Icon name={draft ? 'checkbox-circle' : 'git-pr-draft'} className="size-4" />
|
||||
)}
|
||||
{t(draft ? 'forge.actions.markReady' : 'forge.actions.markDraft')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { toast } from '@/components/ui/toast';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { ForgeEntityRef, ForgeProvider } from '@/lib/forge/provider';
|
||||
|
||||
interface ForgeEditFormProps {
|
||||
provider: ForgeProvider;
|
||||
directory: string;
|
||||
ref: ForgeEntityRef;
|
||||
title: string;
|
||||
body?: string;
|
||||
onSaved?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline edit form for an issue/PR title and body. Renders nothing when the
|
||||
* provider has no `updateEntity` method. Saves both fields in one write and
|
||||
* reports through `onSaved`.
|
||||
*/
|
||||
export const ForgeEditForm: React.FC<ForgeEditFormProps> = ({ provider, directory, ref, title, body, onSaved, onCancel }) => {
|
||||
const { t } = useI18n();
|
||||
const [editTitle, setEditTitle] = useState(title);
|
||||
const [editBody, setEditBody] = useState(body ?? '');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const updateEntity = provider.updateEntity;
|
||||
if (!updateEntity) return null;
|
||||
|
||||
const canSubmit = editTitle.trim().length > 0 && !submitting;
|
||||
|
||||
const save = async (): Promise<void> => {
|
||||
if (!canSubmit) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await updateEntity(directory, ref, { title: editTitle.trim(), body: editBody });
|
||||
if (!result.ok) {
|
||||
toast.error(t('forge.actions.error'));
|
||||
return;
|
||||
}
|
||||
toast.success(t('forge.actions.updated'));
|
||||
onSaved?.();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
value={editTitle}
|
||||
onChange={(event) => setEditTitle(event.target.value)}
|
||||
placeholder={t('forge.actions.edit')}
|
||||
aria-label={t('forge.actions.edit')}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<Textarea
|
||||
value={editBody}
|
||||
onChange={(event) => setEditBody(event.target.value)}
|
||||
placeholder={t('forge.actions.commentPlaceholder')}
|
||||
disabled={submitting}
|
||||
aria-label={t('forge.actions.commentPlaceholder')}
|
||||
className="min-h-[96px]"
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<Button variant="ghost" size="sm" onClick={onCancel} disabled={submitting}>
|
||||
{t('forge.actions.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => void save()} disabled={!canSubmit}>
|
||||
{submitting ? (
|
||||
<Icon name="loader-4" className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Icon name="check" className="size-4" />
|
||||
)}
|
||||
{t('forge.actions.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { ForgeEntityRef, ForgeProvider } from '@/lib/forge/provider';
|
||||
import type { ForgeEntityState, ForgeIssue, ForgePullRequest } from '@/lib/forge/types';
|
||||
import { ForgeDraftToggle } from './ForgeDraftToggle';
|
||||
import { ForgeEditForm } from './ForgeEditForm';
|
||||
import { ForgeReviewActions } from './ForgeReviewActions';
|
||||
import { ForgeStateActions } from './ForgeStateActions';
|
||||
|
||||
interface ForgeEntityActionsProps {
|
||||
provider: ForgeProvider;
|
||||
directory: string;
|
||||
ref: ForgeEntityRef;
|
||||
pr?: ForgePullRequest | null;
|
||||
issue?: ForgeIssue | null;
|
||||
onChanged?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Header action bar for a forge issue or pull request: Edit (expands an inline
|
||||
* form), draft toggle + review actions (pulls only), and close/reopen. Each
|
||||
* affordance is capability- and method-gated; the bar renders nothing when no
|
||||
* write operation applies. Successes funnel through `onChanged` so the owning
|
||||
* view can refetch.
|
||||
*/
|
||||
export const ForgeEntityActions: React.FC<ForgeEntityActionsProps> = ({ provider, directory, ref, pr, issue, onChanged }) => {
|
||||
const { t } = useI18n();
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const entity = pr ?? issue;
|
||||
const entityState: ForgeEntityState = entity?.state ?? 'open';
|
||||
const isPull = ref.kind === 'pull';
|
||||
|
||||
const updateEntity = provider.updateEntity;
|
||||
const hasEdit = Boolean(updateEntity) && Boolean(entity);
|
||||
const hasDraft = isPull && provider.capabilities.draft && typeof provider.toggleDraft === 'function';
|
||||
const hasState = Boolean(updateEntity) && entityState !== 'merged';
|
||||
const hasReview = isPull && provider.capabilities.reviews !== 'none' && typeof provider.submitReview === 'function';
|
||||
|
||||
const showOtherActions = !editing && (hasDraft || hasState || hasReview);
|
||||
|
||||
const onSaved = useCallback(() => {
|
||||
setEditing(false);
|
||||
onChanged?.();
|
||||
}, [onChanged]);
|
||||
|
||||
if (!hasEdit && !showOtherActions) return null;
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{hasEdit && !editing ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEditing(true)}
|
||||
aria-label={t('forge.actions.edit')}
|
||||
>
|
||||
<Icon name="edit" className="size-4" />
|
||||
{t('forge.actions.edit')}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{hasEdit && showOtherActions ? <div className="h-4 w-px shrink-0 bg-border/60" aria-hidden /> : null}
|
||||
|
||||
{showOtherActions ? (
|
||||
<>
|
||||
{hasDraft && pr ? (
|
||||
<ForgeDraftToggle
|
||||
provider={provider}
|
||||
directory={directory}
|
||||
ref={ref}
|
||||
draft={pr.draft}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
) : null}
|
||||
{hasState ? (
|
||||
<ForgeStateActions
|
||||
provider={provider}
|
||||
directory={directory}
|
||||
ref={ref}
|
||||
state={entityState}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
) : null}
|
||||
{hasReview ? <ForgeReviewActions provider={provider} directory={directory} ref={ref} onReviewed={onChanged} /> : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{editing && entity ? (
|
||||
<ForgeEditForm
|
||||
provider={provider}
|
||||
directory={directory}
|
||||
ref={ref}
|
||||
title={entity.title}
|
||||
body={entity.body}
|
||||
onSaved={onSaved}
|
||||
onCancel={() => setEditing(false)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,245 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { toast } from '@/components/ui/toast';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { I18nKey } from '@/lib/i18n';
|
||||
import type { ForgeEntityRef, ForgeProvider } from '@/lib/forge/provider';
|
||||
import type { ForgeLabel, ForgeMilestone, ForgeUser } from '@/lib/forge/types';
|
||||
|
||||
interface ForgeMetadataEditorProps {
|
||||
provider: ForgeProvider;
|
||||
directory: string;
|
||||
ref: ForgeEntityRef;
|
||||
labels: ForgeLabel[];
|
||||
assignees: ForgeUser[];
|
||||
milestone: ForgeMilestone | null | undefined;
|
||||
onChanged?: () => void;
|
||||
}
|
||||
|
||||
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 removeButtonClassName =
|
||||
'inline-flex size-4 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-interactive-hover/60 hover:text-foreground disabled:pointer-events-none disabled:opacity-50';
|
||||
|
||||
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}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Metadata editor for an issue: labels / assignees / milestone chips with a
|
||||
* remove affordance plus per-category add inputs. Every write replaces the
|
||||
* full set of the changed field only (`provider.updateMetadata` semantics).
|
||||
* Renders nothing when `updateMetadata` is missing or no category is enabled.
|
||||
*/
|
||||
export const ForgeMetadataEditor: React.FC<ForgeMetadataEditorProps> = ({
|
||||
provider,
|
||||
directory,
|
||||
ref,
|
||||
labels,
|
||||
assignees,
|
||||
milestone,
|
||||
onChanged,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [labelInput, setLabelInput] = useState('');
|
||||
const [assigneeInput, setAssigneeInput] = useState('');
|
||||
const [milestoneInput, setMilestoneInput] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const updateMetadata = provider.updateMetadata;
|
||||
const canLabels = Boolean(updateMetadata) && provider.capabilities.labels;
|
||||
const canAssignees = Boolean(updateMetadata) && provider.capabilities.assignees;
|
||||
const canMilestones = Boolean(updateMetadata) && provider.capabilities.milestones;
|
||||
|
||||
if (!updateMetadata || (!canLabels && !canAssignees && !canMilestones)) return null;
|
||||
|
||||
const runMetadata = async (
|
||||
input: { labels?: string[]; assignees?: string[]; milestone?: string | null },
|
||||
successKey: I18nKey,
|
||||
): Promise<void> => {
|
||||
if (submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await updateMetadata(directory, ref, input);
|
||||
if (!result.ok) {
|
||||
toast.error(t('forge.actions.error'));
|
||||
return;
|
||||
}
|
||||
toast.success(t(successKey));
|
||||
onChanged?.();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addLabel = async (): Promise<void> => {
|
||||
const name = labelInput.trim();
|
||||
if (!name) return;
|
||||
await runMetadata({ labels: [...labels.map((label) => label.name), name] }, 'forge.actions.added');
|
||||
setLabelInput('');
|
||||
};
|
||||
|
||||
const removeLabel = async (name: string): Promise<void> => {
|
||||
await runMetadata({ labels: labels.filter((label) => label.name !== name).map((label) => label.name) }, 'forge.actions.removed');
|
||||
};
|
||||
|
||||
const addAssignee = async (): Promise<void> => {
|
||||
const login = assigneeInput.trim();
|
||||
if (!login) return;
|
||||
await runMetadata({ assignees: [...assignees.map((assignee) => assignee.login), login] }, 'forge.actions.added');
|
||||
setAssigneeInput('');
|
||||
};
|
||||
|
||||
const removeAssignee = async (id: string): Promise<void> => {
|
||||
await runMetadata({ assignees: assignees.filter((assignee) => assignee.id !== id).map((assignee) => assignee.login) }, 'forge.actions.removed');
|
||||
};
|
||||
|
||||
const addMilestone = async (): Promise<void> => {
|
||||
const title = milestoneInput.trim();
|
||||
if (!title) return;
|
||||
await runMetadata({ milestone: title }, 'forge.actions.metadataChanged');
|
||||
setMilestoneInput('');
|
||||
};
|
||||
|
||||
const removeMilestone = async (): Promise<void> => {
|
||||
await runMetadata({ milestone: null }, 'forge.actions.removed');
|
||||
};
|
||||
|
||||
const renderAvatar = (user: ForgeUser): React.ReactElement => {
|
||||
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`}>
|
||||
{(user.login || user.name || '?').charAt(0).toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{canLabels
|
||||
? labels.map((label) => (
|
||||
<span key={label.name} className={chipClassName} title={label.description || label.name}>
|
||||
<span
|
||||
aria-hidden
|
||||
className="size-2 rounded-full"
|
||||
style={{ backgroundColor: resolveLabelColor(label.color) ?? 'var(--status-info)' }}
|
||||
/>
|
||||
{label.name}
|
||||
<button
|
||||
type="button"
|
||||
className={removeButtonClassName}
|
||||
onClick={() => void removeLabel(label.name)}
|
||||
disabled={submitting}
|
||||
aria-label={`${t('forge.actions.remove')}: ${label.name}`}
|
||||
>
|
||||
<Icon name="close" className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
: null}
|
||||
|
||||
{canAssignees
|
||||
? assignees.map((assignee) => (
|
||||
<span key={assignee.id} className={chipClassName} title={assignee.login}>
|
||||
{renderAvatar(assignee)}
|
||||
{assignee.login}
|
||||
<button
|
||||
type="button"
|
||||
className={removeButtonClassName}
|
||||
onClick={() => void removeAssignee(assignee.id)}
|
||||
disabled={submitting}
|
||||
aria-label={`${t('forge.actions.remove')}: ${assignee.login}`}
|
||||
>
|
||||
<Icon name="close" className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
: null}
|
||||
|
||||
{canMilestones && milestone ? (
|
||||
<span className={chipClassName} title={milestone.title}>
|
||||
<Icon name="target" className="size-3 text-muted-foreground" />
|
||||
{milestone.title}
|
||||
<button
|
||||
type="button"
|
||||
className={removeButtonClassName}
|
||||
onClick={() => void removeMilestone()}
|
||||
disabled={submitting}
|
||||
aria-label={`${t('forge.actions.remove')}: ${milestone.title}`}
|
||||
>
|
||||
<Icon name="close" className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{canLabels ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Input
|
||||
value={labelInput}
|
||||
onChange={(event) => setLabelInput(event.target.value)}
|
||||
placeholder={t('forge.actions.addLabel')}
|
||||
aria-label={t('forge.actions.addLabel')}
|
||||
className="h-6 w-36"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') void addLabel();
|
||||
}}
|
||||
/>
|
||||
<Button variant="ghost" size="xs" onClick={() => void addLabel()} disabled={submitting || !labelInput.trim()}>
|
||||
{t('forge.actions.addLabel')}
|
||||
</Button>
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{canAssignees ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Input
|
||||
value={assigneeInput}
|
||||
onChange={(event) => setAssigneeInput(event.target.value)}
|
||||
placeholder={t('forge.actions.addAssignee')}
|
||||
aria-label={t('forge.actions.addAssignee')}
|
||||
className="h-6 w-36"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') void addAssignee();
|
||||
}}
|
||||
/>
|
||||
<Button variant="ghost" size="xs" onClick={() => void addAssignee()} disabled={submitting || !assigneeInput.trim()}>
|
||||
{t('forge.actions.addAssignee')}
|
||||
</Button>
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{canMilestones ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Input
|
||||
value={milestoneInput}
|
||||
onChange={(event) => setMilestoneInput(event.target.value)}
|
||||
placeholder={t('forge.actions.setMilestone')}
|
||||
aria-label={t('forge.actions.setMilestone')}
|
||||
className="h-6 w-36"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') void addMilestone();
|
||||
}}
|
||||
/>
|
||||
<Button variant="ghost" size="xs" onClick={() => void addMilestone()} disabled={submitting || !milestoneInput.trim()}>
|
||||
{t('forge.actions.setMilestone')}
|
||||
</Button>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { toast } from '@/components/ui/toast';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { I18nKey } from '@/lib/i18n';
|
||||
import type { ForgeEntityRef, ForgeProvider, ForgeReviewEvent } from '@/lib/forge/provider';
|
||||
|
||||
interface ForgeReviewActionsProps {
|
||||
provider: ForgeProvider;
|
||||
directory: string;
|
||||
ref: ForgeEntityRef;
|
||||
onReviewed?: () => void;
|
||||
}
|
||||
|
||||
const EVENT_LABEL_KEYS: Record<ForgeReviewEvent, I18nKey> = {
|
||||
approve: 'forge.actions.approve',
|
||||
'request-changes': 'forge.actions.requestChanges',
|
||||
comment: 'forge.actions.reviewComment',
|
||||
};
|
||||
|
||||
/**
|
||||
* Review submission controls for a pull request. Renders nothing unless the
|
||||
* provider exposes reviews (`capabilities.reviews !== 'none'`) and a
|
||||
* `submitReview` method. `approve-only` providers (GitLab) get a single direct
|
||||
* Approve button; `submit` providers (GitHub/Gitea) get Approve / Request
|
||||
* changes / Comment, each opening a small dialog with an optional body.
|
||||
*/
|
||||
export const ForgeReviewActions: React.FC<ForgeReviewActionsProps> = ({ provider, directory, ref, onReviewed }) => {
|
||||
const { t } = useI18n();
|
||||
const [pendingEvent, setPendingEvent] = useState<ForgeReviewEvent | null>(null);
|
||||
const [body, setBody] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const submitReview = provider.submitReview;
|
||||
if (!submitReview || provider.capabilities.reviews === 'none') return null;
|
||||
|
||||
const canRequestChanges = provider.capabilities.reviews === 'submit';
|
||||
|
||||
const openDialog = (event: ForgeReviewEvent): void => {
|
||||
setBody('');
|
||||
setPendingEvent(event);
|
||||
};
|
||||
|
||||
const submit = async (): Promise<void> => {
|
||||
if (!pendingEvent || submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await submitReview(directory, ref, {
|
||||
event: pendingEvent,
|
||||
...(body.trim() ? { body: body.trim() } : {}),
|
||||
});
|
||||
if (!result.ok) {
|
||||
toast.error(t('forge.actions.error'));
|
||||
return;
|
||||
}
|
||||
toast.success(t('forge.actions.reviewed'));
|
||||
setPendingEvent(null);
|
||||
setBody('');
|
||||
onReviewed?.();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderEventButton = (event: ForgeReviewEvent): React.ReactElement => (
|
||||
<Button variant="outline" size="sm" onClick={() => openDialog(event)} disabled={submitting}>
|
||||
{event === 'approve' ? <Icon name="checkbox-circle" className="size-4" /> : <Icon name="chat-1" className="size-4" />}
|
||||
{t(EVENT_LABEL_KEYS[event])}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{renderEventButton('approve')}
|
||||
{canRequestChanges ? (
|
||||
<>
|
||||
{renderEventButton('request-changes')}
|
||||
{renderEventButton('comment')}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={pendingEvent !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPendingEvent(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('forge.actions.reviewDialogTitle')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Textarea
|
||||
value={body}
|
||||
onChange={(event) => setBody(event.target.value)}
|
||||
placeholder={t('forge.actions.reviewBodyPlaceholder')}
|
||||
disabled={submitting}
|
||||
aria-label={t('forge.actions.reviewBodyPlaceholder')}
|
||||
className="min-h-[96px]"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={() => setPendingEvent(null)} disabled={submitting}>
|
||||
{t('forge.actions.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => void submit()} disabled={submitting}>
|
||||
{submitting ? (
|
||||
<Icon name="loader-4" className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Icon name="check" className="size-4" />
|
||||
)}
|
||||
{pendingEvent ? t(EVENT_LABEL_KEYS[pendingEvent]) : t('forge.actions.reviewDialogTitle')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from '@/components/ui/toast';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { ForgeEntityRef, ForgeProvider, ForgeWriteState } from '@/lib/forge/provider';
|
||||
import type { ForgeEntityState } from '@/lib/forge/types';
|
||||
|
||||
interface ForgeStateActionsProps {
|
||||
provider: ForgeProvider;
|
||||
directory: string;
|
||||
ref: ForgeEntityRef;
|
||||
state: ForgeEntityState;
|
||||
onChanged?: (state: ForgeEntityState) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close/reopen control for an issue or pull request. Renders nothing when the
|
||||
* provider has no `updateEntity` method, or when the entity is merged (a
|
||||
* terminal, non-writable state). Closing asks for confirmation first.
|
||||
*/
|
||||
export const ForgeStateActions: React.FC<ForgeStateActionsProps> = ({ provider, directory, ref, state, onChanged }) => {
|
||||
const { t } = useI18n();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const updateEntity = provider.updateEntity;
|
||||
if (!updateEntity || state === 'merged') return null;
|
||||
|
||||
const isOpen = state === 'open';
|
||||
const nextState: ForgeWriteState = isOpen ? 'closed' : 'open';
|
||||
|
||||
const run = async (): Promise<void> => {
|
||||
if (submitting) return;
|
||||
if (isOpen && !window.confirm(t('forge.actions.closeConfirm'))) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await updateEntity(directory, ref, { state: nextState });
|
||||
if (!result.ok) {
|
||||
toast.error(t('forge.actions.error'));
|
||||
return;
|
||||
}
|
||||
toast.success(t('forge.actions.stateChanged'));
|
||||
onChanged?.(nextState);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void run()}
|
||||
disabled={submitting}
|
||||
aria-label={t(isOpen ? 'forge.actions.close' : 'forge.actions.reopen')}
|
||||
>
|
||||
{submitting ? (
|
||||
<Icon name="loader-4" className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Icon name={isOpen ? 'git-close-pull-request' : 'git-pull-request'} className="size-4" />
|
||||
)}
|
||||
{t(isOpen ? 'forge.actions.close' : 'forge.actions.reopen')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { toast } from '@/components/ui/toast';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { ForgeEntityRef, ForgeProvider } from '@/lib/forge/provider';
|
||||
import type { ForgeComment } from '@/lib/forge/types';
|
||||
|
||||
/** Anchor of the thread being replied to (see `ForgeComment.inReplyToId`/`path`/`line`). */
|
||||
export interface ForgeThreadTarget {
|
||||
inReplyToId: string;
|
||||
path?: string | null;
|
||||
line?: number | null;
|
||||
}
|
||||
|
||||
interface ForgeThreadReplyProps {
|
||||
provider: ForgeProvider;
|
||||
directory: string;
|
||||
ref: ForgeEntityRef;
|
||||
thread: ForgeThreadTarget;
|
||||
onPosted?: (comment: ForgeComment) => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline reply editor for one comment thread. Renders nothing when the
|
||||
* provider has no `replyToThread` method. The parent decides when the editor
|
||||
* is visible (expansion is driven from outside); posting clears the editor and
|
||||
* reports the created comment via `onPosted`.
|
||||
*/
|
||||
export const ForgeThreadReply: React.FC<ForgeThreadReplyProps> = ({ provider, directory, ref, thread, onPosted, onCancel }) => {
|
||||
const { t } = useI18n();
|
||||
const [body, setBody] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const replyToThread = provider.replyToThread;
|
||||
if (!replyToThread) return null;
|
||||
|
||||
const canSubmit = body.trim().length > 0 && !submitting;
|
||||
|
||||
const submit = async (): Promise<void> => {
|
||||
if (!canSubmit) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await replyToThread(directory, ref, {
|
||||
body: body.trim(),
|
||||
inReplyToId: thread.inReplyToId,
|
||||
path: thread.path ?? null,
|
||||
line: thread.line ?? null,
|
||||
});
|
||||
if (!result.ok) {
|
||||
toast.error(t('forge.actions.error'));
|
||||
return;
|
||||
}
|
||||
setBody('');
|
||||
if (result.comment) onPosted?.(result.comment);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Textarea
|
||||
value={body}
|
||||
onChange={(event) => setBody(event.target.value)}
|
||||
placeholder={t('forge.actions.commentPlaceholder')}
|
||||
disabled={submitting}
|
||||
aria-label={t('forge.actions.commentPlaceholder')}
|
||||
className="min-h-[56px]"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<Button variant="ghost" size="sm" onClick={onCancel} disabled={submitting}>
|
||||
{t('forge.actions.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => void submit()} disabled={!canSubmit}>
|
||||
{submitting ? (
|
||||
<Icon name="loader-4" className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Icon name="chat-1" className="size-4" />
|
||||
)}
|
||||
{t('forge.actions.reply')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Write-action UI for forge issues and pull requests.
|
||||
*
|
||||
* Every component is capability- and method-gated: it renders nothing (or a
|
||||
* sub-affordance) unless the provider implements the underlying write method
|
||||
* and its capability flag is set. Components call the facade directly, toast
|
||||
* stable i18n messages on failure (never raw error text), and report success
|
||||
* through `onChanged`/`onPosted` callbacks so the owning view can refetch or
|
||||
* update local state.
|
||||
*/
|
||||
export { ForgeCommentComposer } from './ForgeCommentComposer';
|
||||
export { ForgeThreadReply } from './ForgeThreadReply';
|
||||
export { ForgeStateActions } from './ForgeStateActions';
|
||||
export { ForgeReviewActions } from './ForgeReviewActions';
|
||||
export { ForgeDraftToggle } from './ForgeDraftToggle';
|
||||
export { ForgeMetadataEditor } from './ForgeMetadataEditor';
|
||||
export { ForgeEditForm } from './ForgeEditForm';
|
||||
export { ForgeEntityActions } from './ForgeEntityActions';
|
||||
@@ -34,6 +34,12 @@ 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 {
|
||||
ForgeCommentComposer,
|
||||
ForgeDraftToggle,
|
||||
ForgeReviewActions,
|
||||
ForgeStateActions,
|
||||
} from '@/components/views/forge/actions';
|
||||
import type {
|
||||
GitHubPullRequest,
|
||||
GitHubCheckRun,
|
||||
@@ -1182,6 +1188,19 @@ export const PullRequestSection: React.FC<{
|
||||
}, delayMs));
|
||||
}, [refresh]);
|
||||
|
||||
// Forge write actions in the Overview refresh the status store so chips,
|
||||
// checks, and the header stay coherent after a state/draft/review change.
|
||||
const refreshPr = React.useCallback(() => {
|
||||
void refresh({ force: true });
|
||||
}, [refresh]);
|
||||
|
||||
// A posted comment lives in the context store (Comments tab), so refresh it
|
||||
// in place; the status store is unaffected by comments.
|
||||
const refreshPrContext = React.useCallback(() => {
|
||||
if (!github?.prContext || !pr) return;
|
||||
void ensurePrContext(github, directory, pr.number, { force: true, sourceRepo: status?.repo ?? null });
|
||||
}, [directory, ensurePrContext, github, pr, status?.repo]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!github?.prStatus || !canShow || remotes.length <= 1) {
|
||||
return;
|
||||
@@ -1869,6 +1888,31 @@ export const PullRequestSection: React.FC<{
|
||||
|
||||
{forgePr ? <ForgeMetadataChips kind="pull" pr={forgePr} /> : null}
|
||||
|
||||
{forgeProvider && forgePr && forgePr.state === 'open' ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<ForgeDraftToggle
|
||||
provider={forgeProvider}
|
||||
directory={directory}
|
||||
ref={{ kind: 'pull', number: pr.number }}
|
||||
draft={!!forgePr.draft}
|
||||
onChanged={refreshPr}
|
||||
/>
|
||||
<ForgeStateActions
|
||||
provider={forgeProvider}
|
||||
directory={directory}
|
||||
ref={{ kind: 'pull', number: pr.number }}
|
||||
state={forgePr.state}
|
||||
onChanged={refreshPr}
|
||||
/>
|
||||
<ForgeReviewActions
|
||||
provider={forgeProvider}
|
||||
directory={directory}
|
||||
ref={{ kind: 'pull', number: pr.number }}
|
||||
onReviewed={refreshPr}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isEditingPr ? (
|
||||
<Textarea
|
||||
value={editBody}
|
||||
@@ -1894,6 +1938,15 @@ export const PullRequestSection: React.FC<{
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{forgeProvider && forgePr && forgePr.state === 'open' ? (
|
||||
<ForgeCommentComposer
|
||||
provider={forgeProvider}
|
||||
directory={directory}
|
||||
ref={{ kind: 'pull', number: pr.number }}
|
||||
onPosted={refreshPrContext}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user