activeSurface was permanently 'chat' after the legacy mobile layout
removal, so the whole concept is gone: the store field, surfaceGuard,
setActiveSurface/setSurfaceGuard, the per-runtime surface memory in
prepare/restoreForRuntimeSwitch, and WorkspaceSurface itself. All ~30
setActiveSurface('chat') call sites were no-ops and are deleted;
always-true 'is the chat active' checks in keyboard shortcuts, Header
and ChatContainer are unconditional now. FilesView's dirty-file guard
kept its file-switch and close protection but drops the surface-switch
branch nothing could trigger. TerminalView visibility comes only from
its callers. The router keeps parsing legacy ?tab= links (they open the
matching context-panel surface) via its own RouteTab type and no longer
serializes a tab or diff file into URLs — desktop URLs never carried
them anyway.
2185 lines
93 KiB
TypeScript
2185 lines
93 KiB
TypeScript
import React from 'react';
|
|
import { cn } from '@/lib/utils';
|
|
import { toast } from '@/components/ui';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
|
import {
|
|
Collapsible,
|
|
CollapsibleContent,
|
|
CollapsibleTrigger,
|
|
} from '@/components/ui/collapsible';
|
|
import { generatePullRequestDescription } from '@/lib/gitApi';
|
|
import { openExternalUrl } from '@/lib/url';
|
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
|
import { useDeviceInfo } from '@/lib/device';
|
|
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
|
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
|
|
import { Icon } from "@/components/icon/Icon";
|
|
import { useUIStore } from '@/stores/useUIStore';
|
|
import { useWalkthroughStore } from '@/stores/useWalkthroughStore';
|
|
import { WALKTHROUGH_ACTION_CLASS } from '@/components/views/walkthrough/walkthroughAction';
|
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
|
import { formatDateTimeForPreference } from '@/lib/timeFormat';
|
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
|
import { useInlineCommentDraftStore, type InlineCommentDraftTarget } from '@/stores/useInlineCommentDraftStore';
|
|
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
|
import { getGitHubPrStatusKey, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
|
|
import { getPrContextKey, usePrContextStore } from '@/stores/usePrContextStore';
|
|
import { summarizeCheckRuns } from '@/lib/githubChecks';
|
|
import type {
|
|
GitHubPullRequest,
|
|
GitHubCheckRun,
|
|
GitHubAPI,
|
|
GitHubPullRequestStatus,
|
|
GitRemote,
|
|
} from '@/lib/api/types';
|
|
import { useI18n } from '@/lib/i18n';
|
|
|
|
type MergeMethod = 'merge' | 'squash' | 'rebase';
|
|
type PrSegment = 'overview' | 'checks' | 'comments';
|
|
|
|
const PR_CHECKS_AUTO_REFRESH_MS = 35_000;
|
|
|
|
const formatElapsedDuration = (startISO?: string, endISO?: string, now?: number): string | null => {
|
|
if (!startISO) return null;
|
|
const start = Date.parse(startISO);
|
|
if (!Number.isFinite(start)) return null;
|
|
const end = endISO ? Date.parse(endISO) : (now ?? Date.now());
|
|
if (!Number.isFinite(end) || end <= start) return null;
|
|
const totalMinutes = Math.floor((end - start) / 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 isFailedConclusion = (conclusion?: string | null): boolean => {
|
|
const normalized = typeof conclusion === 'string' ? conclusion.toLowerCase() : '';
|
|
return Boolean(normalized) && !['success', 'neutral', 'skipped'].includes(normalized);
|
|
};
|
|
type DetectedUpstream = { owner: string; repo: string; url: string; defaultBranch?: string; defaultBranchSha?: string | null; remoteName?: string | null };
|
|
|
|
const statusColor = (state: string | undefined | null): string => {
|
|
switch (state) {
|
|
case 'success':
|
|
return 'bg-[color:var(--status-success)]';
|
|
case 'failure':
|
|
return 'bg-[color:var(--status-error)]';
|
|
case 'pending':
|
|
return 'bg-[color:var(--status-warning)]';
|
|
default:
|
|
return 'bg-muted-foreground/40';
|
|
}
|
|
};
|
|
|
|
const getPrVisualState = (status: GitHubPullRequestStatus | null): 'draft' | 'open' | 'blocked' | 'merged' | 'closed' | null => {
|
|
const pr = status?.pr;
|
|
if (!pr) {
|
|
return null;
|
|
}
|
|
if (pr.state === 'merged') {
|
|
return 'merged';
|
|
}
|
|
if (pr.state === 'closed') {
|
|
return 'closed';
|
|
}
|
|
if (pr.draft) {
|
|
return 'draft';
|
|
}
|
|
const checksFailed = status?.checks?.state === 'failure';
|
|
const mergeableState = typeof pr.mergeableState === 'string' ? pr.mergeableState : '';
|
|
const notMergeable = pr.mergeable === false || mergeableState === 'blocked' || mergeableState === 'dirty';
|
|
if (checksFailed || notMergeable) {
|
|
return 'blocked';
|
|
}
|
|
return 'open';
|
|
};
|
|
|
|
const PR_ACTION_REFRESH_DELAYS_MS = [2_000, 5_000] as const;
|
|
|
|
const branchToTitle = (branch: string): string => {
|
|
return branch
|
|
.replace(/^refs\/heads\//, '')
|
|
.replace(/[-_]+/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
};
|
|
|
|
const normalizeBranchRef = (value: string): string => {
|
|
let normalized = value.trim();
|
|
if (!normalized) {
|
|
return '';
|
|
}
|
|
if (normalized.startsWith('refs/heads/')) {
|
|
normalized = normalized.slice('refs/heads/'.length);
|
|
}
|
|
if (normalized.startsWith('heads/')) {
|
|
normalized = normalized.slice('heads/'.length);
|
|
}
|
|
if (normalized.startsWith('remotes/')) {
|
|
normalized = normalized.slice('remotes/'.length);
|
|
}
|
|
return normalized;
|
|
};
|
|
|
|
const remoteBranchToName = (value: string, remoteName: string | null): string => {
|
|
const normalized = normalizeBranchRef(value);
|
|
if (!normalized || normalized.includes('->')) {
|
|
return '';
|
|
}
|
|
|
|
if (remoteName) {
|
|
const prefix = `${remoteName}/`;
|
|
if (normalized.startsWith(prefix)) {
|
|
return normalized.slice(prefix.length).trim();
|
|
}
|
|
return '';
|
|
}
|
|
|
|
const slashIndex = normalized.indexOf('/');
|
|
if (slashIndex > 0) {
|
|
return normalized.slice(slashIndex + 1).trim();
|
|
}
|
|
return normalized;
|
|
};
|
|
|
|
const getPullRequestSnapshotKey = (directory: string, branch: string): string => `${directory}::${branch}`;
|
|
|
|
type PullRequestDraftSnapshot = {
|
|
title: string;
|
|
body: string;
|
|
draft: boolean;
|
|
additionalContext: string;
|
|
targetBaseBranch?: string;
|
|
selectedRemoteName?: string;
|
|
activeSegment?: PrSegment;
|
|
};
|
|
|
|
const getTrackingRemoteName = (trackingBranch: string | null | undefined): string => {
|
|
const normalized = String(trackingBranch || '').trim();
|
|
if (!normalized) {
|
|
return '';
|
|
}
|
|
|
|
const slashIndex = normalized.indexOf('/');
|
|
if (slashIndex <= 0) {
|
|
return '';
|
|
}
|
|
|
|
return normalized.slice(0, slashIndex).trim();
|
|
};
|
|
|
|
const pickInitialPrRemote = (
|
|
remotes: GitRemote[],
|
|
options: { selectedRemoteName?: string; trackingBranch?: string }
|
|
): GitRemote | null => {
|
|
if (remotes.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const selectedRemoteName = String(options.selectedRemoteName || '').trim();
|
|
if (selectedRemoteName) {
|
|
const fromSnapshot = remotes.find((remote) => remote.name === selectedRemoteName);
|
|
if (fromSnapshot) {
|
|
return fromSnapshot;
|
|
}
|
|
}
|
|
|
|
const trackingRemoteName = getTrackingRemoteName(options.trackingBranch);
|
|
if (trackingRemoteName) {
|
|
const maybeUpstream =
|
|
trackingRemoteName === 'origin'
|
|
? remotes.find((remote) => remote.name === 'upstream')
|
|
: null;
|
|
if (maybeUpstream) {
|
|
return maybeUpstream;
|
|
}
|
|
|
|
const fromTracking = remotes.find((remote) => remote.name === trackingRemoteName);
|
|
if (fromTracking) {
|
|
return fromTracking;
|
|
}
|
|
}
|
|
|
|
const originRemote = remotes.find((remote) => remote.name === 'origin');
|
|
if (originRemote) {
|
|
return originRemote;
|
|
}
|
|
|
|
return remotes[0] ?? null;
|
|
};
|
|
|
|
const isEphemeralPrRemote = (name: string): boolean => name.startsWith('pr-');
|
|
|
|
const rankRemotesForAutoSelect = (
|
|
remotes: GitRemote[],
|
|
trackingBranch?: string,
|
|
): GitRemote[] => {
|
|
const trackingRemote = getTrackingRemoteName(trackingBranch);
|
|
const byName = new Map(remotes.map((remote) => [remote.name, remote]));
|
|
const ordered: GitRemote[] = [];
|
|
const pushUnique = (remote: GitRemote | null | undefined) => {
|
|
if (!remote) return;
|
|
if (ordered.some((item) => item.name === remote.name)) return;
|
|
ordered.push(remote);
|
|
};
|
|
|
|
if (trackingRemote) {
|
|
pushUnique(byName.get(trackingRemote));
|
|
}
|
|
pushUnique(byName.get('upstream'));
|
|
pushUnique(byName.get('origin'));
|
|
|
|
remotes
|
|
.filter((remote) => !isEphemeralPrRemote(remote.name))
|
|
.forEach((remote) => pushUnique(remote));
|
|
remotes.forEach((remote) => pushUnique(remote));
|
|
|
|
return ordered;
|
|
};
|
|
|
|
type TimelineCommentItem = {
|
|
id: string;
|
|
body: string;
|
|
authorName: string;
|
|
authorLogin: string | null;
|
|
avatarUrl: string | null;
|
|
createdAt?: string;
|
|
context: string;
|
|
path: string | null;
|
|
line: number | null;
|
|
};
|
|
|
|
const pullRequestDraftSnapshots = new Map<string, PullRequestDraftSnapshot>();
|
|
|
|
const openExternal = openExternalUrl;
|
|
|
|
function useDetectedUpstreamRepo(directory: string, github: GitHubAPI | undefined) {
|
|
const [detectedUpstream, setDetectedUpstream] = React.useState<DetectedUpstream | null>(null);
|
|
const [upstreamBranches, setUpstreamBranches] = React.useState<string[]>([]);
|
|
const attemptedDirectoryRef = React.useRef<string | null>(null);
|
|
|
|
React.useEffect(() => {
|
|
setDetectedUpstream(null);
|
|
setUpstreamBranches([]);
|
|
}, [directory]);
|
|
|
|
React.useEffect(() => {
|
|
if (!directory || !github?.repoUpstream || attemptedDirectoryRef.current === directory) {
|
|
return;
|
|
}
|
|
attemptedDirectoryRef.current = directory;
|
|
|
|
let cancelled = false;
|
|
void (async () => {
|
|
try {
|
|
const result = await github.repoUpstream(directory);
|
|
if (cancelled || !result?.isFork || !result.upstream) {
|
|
return;
|
|
}
|
|
|
|
setDetectedUpstream(result.upstream);
|
|
if (!github.repoBranches) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const branches = await github.repoBranches(result.upstream.owner, result.upstream.repo);
|
|
if (!cancelled) {
|
|
setUpstreamBranches(branches);
|
|
}
|
|
} catch {
|
|
// Silently fail - branch list is best-effort.
|
|
}
|
|
} catch {
|
|
// Silently fail - upstream detection is best-effort.
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [directory, github]);
|
|
|
|
return { detectedUpstream, upstreamBranches };
|
|
}
|
|
|
|
export const PullRequestSection: React.FC<{
|
|
directory: string;
|
|
branch: string;
|
|
baseBranch: string;
|
|
trackingBranch?: string;
|
|
remotes?: GitRemote[];
|
|
remoteBranches?: string[];
|
|
onGeneratedDescription?: () => void;
|
|
}> = ({ directory, branch, baseBranch, trackingBranch, remotes = [], remoteBranches = [], onGeneratedDescription }) => {
|
|
const { t } = useI18n();
|
|
const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
|
|
const { github } = useRuntimeAPIs();
|
|
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
|
|
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
|
|
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
|
|
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
|
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
|
const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
|
|
const { isMobile, hasTouchInput, screenWidth } = useDeviceInfo();
|
|
const openContextSurface = useUIStore((state) => state.openContextSurface);
|
|
const requestWalkthroughSource = useWalkthroughStore((state) => state.requestSource);
|
|
// Mirrors the rail's gating: the surface is not available on mobile widths or
|
|
// in VS Code, so neither is its entry point.
|
|
const showWalkthroughAction = !isMobile && screenWidth >= 768 && !isVSCodeRuntime();
|
|
|
|
const openGitHubSettings = React.useCallback(() => {
|
|
setSettingsPage('github');
|
|
setSettingsDialogOpen(true);
|
|
}, [setSettingsDialogOpen, setSettingsPage]);
|
|
|
|
const snapshotKey = React.useMemo(() => getPullRequestSnapshotKey(directory, branch), [directory, branch]);
|
|
const initialSnapshot = React.useMemo(
|
|
() => pullRequestDraftSnapshots.get(snapshotKey) ?? null,
|
|
[snapshotKey]
|
|
);
|
|
const ensurePrStatusEntry = useGitHubPrStatusStore((state) => state.ensureEntry);
|
|
const setPrStatusParams = useGitHubPrStatusStore((state) => state.setParams);
|
|
const startPrStatusWatching = useGitHubPrStatusStore((state) => state.startWatching);
|
|
const stopPrStatusWatching = useGitHubPrStatusStore((state) => state.stopWatching);
|
|
const refreshPrStatus = useGitHubPrStatusStore((state) => state.refresh);
|
|
const updatePrStatus = useGitHubPrStatusStore((state) => state.updateStatus);
|
|
|
|
const [title, setTitle] = React.useState(() => initialSnapshot?.title ?? branchToTitle(branch));
|
|
const [body, setBody] = React.useState(() => initialSnapshot?.body ?? '');
|
|
const [draft, setDraft] = React.useState(() => initialSnapshot?.draft ?? false);
|
|
const [additionalContext, setAdditionalContext] = React.useState(() => initialSnapshot?.additionalContext ?? '');
|
|
const [targetBaseBranch, setTargetBaseBranch] = React.useState(() => {
|
|
const fromSnapshot = typeof initialSnapshot?.targetBaseBranch === 'string'
|
|
? normalizeBranchRef(initialSnapshot.targetBaseBranch)
|
|
: '';
|
|
if (fromSnapshot) {
|
|
return fromSnapshot;
|
|
}
|
|
return normalizeBranchRef(baseBranch);
|
|
});
|
|
const [mergeMethod, setMergeMethod] = React.useState<MergeMethod>('squash');
|
|
|
|
const [isGenerating, setIsGenerating] = React.useState(false);
|
|
const [isCreating, setIsCreating] = React.useState(false);
|
|
const [isUpdating, setIsUpdating] = React.useState(false);
|
|
const [isMerging, setIsMerging] = React.useState(false);
|
|
const [isMarkingReady, setIsMarkingReady] = React.useState(false);
|
|
const [isEditingPr, setIsEditingPr] = React.useState(false);
|
|
const [hydratingPrBodyKey, setHydratingPrBodyKey] = React.useState<string | null>(null);
|
|
const [editTitle, setEditTitle] = React.useState('');
|
|
const [editBody, setEditBody] = React.useState('');
|
|
|
|
const [isContextOpen, setIsContextOpen] = React.useState(false);
|
|
const [isContextSheetOpen, setIsContextSheetOpen] = React.useState(false);
|
|
const [selectedRemote, setSelectedRemote] = React.useState<GitRemote | null>(() =>
|
|
pickInitialPrRemote(remotes, {
|
|
selectedRemoteName: initialSnapshot?.selectedRemoteName,
|
|
trackingBranch,
|
|
})
|
|
);
|
|
const [useDetectedUpstream, setUseDetectedUpstream] = React.useState(false);
|
|
const { detectedUpstream, upstreamBranches } = useDetectedUpstreamRepo(directory, github);
|
|
|
|
React.useEffect(() => {
|
|
setUseDetectedUpstream(false);
|
|
}, [directory]);
|
|
|
|
const hasUpstreamRemote = remotes.some((r) => r.name === 'upstream');
|
|
const isFork = hasUpstreamRemote || detectedUpstream !== null;
|
|
const canShow = Boolean(directory && branch && baseBranch && (branch !== baseBranch || isFork));
|
|
|
|
const prStatusKey = React.useMemo(
|
|
() => getGitHubPrStatusKey(directory, branch, selectedRemote?.name ?? null),
|
|
[directory, branch, selectedRemote?.name],
|
|
);
|
|
const statusEntry = useGitHubPrStatusStore((state) => state.entries[prStatusKey]);
|
|
|
|
const isLoading = statusEntry?.isLoading ?? false;
|
|
const status = statusEntry?.status ?? null;
|
|
const error = statusEntry?.error ?? null;
|
|
const isInitialStatusResolved = statusEntry?.isInitialStatusResolved ?? false;
|
|
|
|
const availableBaseBranches = React.useMemo(() => {
|
|
const selectedRemoteName = useDetectedUpstream ? null : (selectedRemote?.name?.trim() || null);
|
|
const unique = new Set<string>();
|
|
|
|
for (const remoteBranch of remoteBranches) {
|
|
const branchName = remoteBranchToName(remoteBranch, selectedRemoteName);
|
|
if (!branchName || branchName === 'HEAD') {
|
|
continue;
|
|
}
|
|
unique.add(branchName);
|
|
}
|
|
|
|
// When using detected upstream, include all upstream repo branches
|
|
if (useDetectedUpstream) {
|
|
for (const b of upstreamBranches) {
|
|
if (b && b !== 'HEAD') {
|
|
unique.add(b);
|
|
}
|
|
}
|
|
}
|
|
|
|
const defaultBase = normalizeBranchRef(baseBranch);
|
|
if (defaultBase && defaultBase !== 'HEAD') {
|
|
unique.add(defaultBase);
|
|
}
|
|
|
|
const currentTarget = normalizeBranchRef(targetBaseBranch);
|
|
if (currentTarget && currentTarget !== 'HEAD') {
|
|
unique.add(currentTarget);
|
|
}
|
|
|
|
return Array.from(unique).sort((a, b) => a.localeCompare(b));
|
|
}, [baseBranch, remoteBranches, selectedRemote?.name, targetBaseBranch, upstreamBranches, useDetectedUpstream]);
|
|
|
|
// Update selected remote when remotes change
|
|
React.useEffect(() => {
|
|
if (remotes.length === 0) {
|
|
if (selectedRemote) {
|
|
setSelectedRemote(null);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!selectedRemote || !remotes.some((remote) => remote.name === selectedRemote.name)) {
|
|
setSelectedRemote(
|
|
pickInitialPrRemote(remotes, {
|
|
selectedRemoteName: initialSnapshot?.selectedRemoteName,
|
|
trackingBranch,
|
|
})
|
|
);
|
|
}
|
|
}, [initialSnapshot?.selectedRemoteName, remotes, selectedRemote, trackingBranch]);
|
|
|
|
React.useEffect(() => {
|
|
const normalizedBase = normalizeBranchRef(baseBranch);
|
|
if (!targetBaseBranch && normalizedBase) {
|
|
setTargetBaseBranch(normalizedBase);
|
|
return;
|
|
}
|
|
|
|
if (availableBaseBranches.length === 0) {
|
|
return;
|
|
}
|
|
|
|
if (!availableBaseBranches.includes(targetBaseBranch)) {
|
|
const fallback = availableBaseBranches.includes(normalizedBase)
|
|
? normalizedBase
|
|
: availableBaseBranches[0];
|
|
if (fallback) {
|
|
setTargetBaseBranch(fallback);
|
|
}
|
|
}
|
|
}, [availableBaseBranches, baseBranch, targetBaseBranch]);
|
|
|
|
const [activeSegment, setActiveSegmentState] = React.useState<PrSegment>(() => initialSnapshot?.activeSegment ?? 'overview');
|
|
const [expandedCheckStepKeys, setExpandedCheckStepKeys] = React.useState<Set<string>>(new Set());
|
|
const [expandedCheckRunKeys, setExpandedCheckRunKeys] = React.useState<Set<string>>(new Set());
|
|
|
|
const attemptedBodyHydrationRef = React.useRef<Set<string>>(new Set());
|
|
const lastSyncedPrNumberRef = React.useRef<number | null>(null);
|
|
const didUserOverrideRemoteRef = React.useRef(false);
|
|
const autoRemoteProbeDoneRef = React.useRef<Set<string>>(new Set());
|
|
const pendingActionRefreshTimersRef = React.useRef<number[]>([]);
|
|
|
|
// Auto-enable detected upstream when there's no explicit upstream remote
|
|
React.useEffect(() => {
|
|
if (detectedUpstream && !hasUpstreamRemote) {
|
|
setUseDetectedUpstream(true);
|
|
}
|
|
}, [detectedUpstream, hasUpstreamRemote]);
|
|
|
|
// Set target base branch to upstream's default branch when using detected upstream
|
|
React.useEffect(() => {
|
|
if (useDetectedUpstream && detectedUpstream?.defaultBranch) {
|
|
setTargetBaseBranch(detectedUpstream.defaultBranch);
|
|
}
|
|
}, [useDetectedUpstream, detectedUpstream?.defaultBranch]);
|
|
|
|
const pr = status?.pr ?? null;
|
|
// A closed/merged PR is the branch's history, not its live status: it still
|
|
// deserves to be shown (you just merged it), but the branch is free again, so
|
|
// the panel offers creating the next PR instead of a read-only detail view.
|
|
const isHistoricalPr = pr?.state === 'merged' || pr?.state === 'closed';
|
|
const livePr = isHistoricalPr ? null : pr;
|
|
|
|
const prContextKey = livePr ? getPrContextKey(directory, livePr.number) : null;
|
|
const prContextEntry = usePrContextStore((state) => (prContextKey ? state.entries[prContextKey] : undefined));
|
|
const ensurePrContext = usePrContextStore((state) => state.ensure);
|
|
const prContext = prContextEntry?.result ?? null;
|
|
const isLoadingPrContext = prContextEntry?.isLoading ?? false;
|
|
|
|
const setActiveSegment = React.useCallback((segment: PrSegment) => {
|
|
setActiveSegmentState(segment);
|
|
const snapshot = pullRequestDraftSnapshots.get(snapshotKey);
|
|
if (snapshot) {
|
|
pullRequestDraftSnapshots.set(snapshotKey, { ...snapshot, activeSegment: segment });
|
|
}
|
|
}, [snapshotKey]);
|
|
|
|
// Load the context the active segment needs; checks include details.
|
|
React.useEffect(() => {
|
|
if (!livePr || !github?.prContext || activeSegment === 'overview') {
|
|
return;
|
|
}
|
|
void ensurePrContext(github, directory, livePr.number, {
|
|
includeCheckDetails: activeSegment === 'checks',
|
|
sourceRepo: status?.repo ?? null,
|
|
});
|
|
}, [activeSegment, directory, ensurePrContext, github, livePr, status?.repo]);
|
|
|
|
const checks = status?.checks ?? null;
|
|
const checksArePending = (checks?.pending ?? 0) > 0;
|
|
|
|
// The detailed run list (pulls/context) and the status aggregate (pr/status)
|
|
// come from different endpoints with different cache ages. The run list is
|
|
// the fresher, richer source whenever we have it — derive the aggregate from
|
|
// it and push it into the status store so every consumer (header, badges,
|
|
// git-view chip) shows the same numbers as the visible runs.
|
|
const contextCheckRuns = prContext?.checkRuns ?? null;
|
|
const contextFetchedAt = prContext?.fetchedAt;
|
|
React.useEffect(() => {
|
|
if (!contextCheckRuns || contextCheckRuns.length === 0) {
|
|
return;
|
|
}
|
|
const derived = summarizeCheckRuns(contextCheckRuns);
|
|
updatePrStatus(prStatusKey, (previous) => {
|
|
if (!previous?.pr) {
|
|
return previous;
|
|
}
|
|
// Never let older context data regress a fresher status snapshot.
|
|
if (typeof contextFetchedAt === 'number'
|
|
&& typeof previous.fetchedAt === 'number'
|
|
&& contextFetchedAt < previous.fetchedAt) {
|
|
return previous;
|
|
}
|
|
const current = previous.checks;
|
|
const unchanged = current
|
|
&& current.state === derived.state
|
|
&& current.total === derived.total
|
|
&& current.success === derived.success
|
|
&& current.failure === derived.failure
|
|
&& current.pending === derived.pending
|
|
&& current.inProgress === derived.inProgress
|
|
&& current.queued === derived.queued
|
|
&& current.startedAt === derived.startedAt;
|
|
if (unchanged) {
|
|
return previous;
|
|
}
|
|
return {
|
|
...previous,
|
|
checks: derived,
|
|
// Adopt the context's freshness so a later stale status response
|
|
// (older server stamp) is rejected by the store's freshness guard.
|
|
...(typeof contextFetchedAt === 'number' ? { fetchedAt: contextFetchedAt } : {}),
|
|
};
|
|
});
|
|
}, [contextCheckRuns, contextFetchedAt, prStatusKey, updatePrStatus]);
|
|
|
|
// While checks run and the checks segment is visible, keep the detailed
|
|
// run list fresh; the shared context store dedupes against other callers.
|
|
React.useEffect(() => {
|
|
if (activeSegment !== 'checks' || !checksArePending || !pr || !github?.prContext) {
|
|
return;
|
|
}
|
|
const intervalId = window.setInterval(() => {
|
|
void ensurePrContext(github, directory, pr.number, {
|
|
includeCheckDetails: true,
|
|
sourceRepo: status?.repo ?? null,
|
|
force: true,
|
|
});
|
|
}, PR_CHECKS_AUTO_REFRESH_MS);
|
|
return () => window.clearInterval(intervalId);
|
|
}, [activeSegment, checksArePending, directory, ensurePrContext, github, pr, status?.repo]);
|
|
|
|
// Coarse clock for "running for Nm" labels; only ticks while checks run.
|
|
const [nowTick, setNowTick] = React.useState(() => Date.now());
|
|
React.useEffect(() => {
|
|
if (!checksArePending) {
|
|
return;
|
|
}
|
|
setNowTick(Date.now());
|
|
const intervalId = window.setInterval(() => setNowTick(Date.now()), 30_000);
|
|
return () => window.clearInterval(intervalId);
|
|
}, [checksArePending]);
|
|
|
|
const currentPrBodyHydrationKey = pr ? `${directory}#${pr.number}` : null;
|
|
const isHydratingCurrentPrBody = Boolean(
|
|
currentPrBodyHydrationKey && hydratingPrBodyKey === currentPrBodyHydrationKey,
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
if (!github?.prContext || !pr) {
|
|
return;
|
|
}
|
|
|
|
if (typeof pr.body === 'string' && pr.body.length > 0) {
|
|
return;
|
|
}
|
|
|
|
const hydrationKey = `${directory}#${pr.number}`;
|
|
if (attemptedBodyHydrationRef.current.has(hydrationKey)) {
|
|
return;
|
|
}
|
|
attemptedBodyHydrationRef.current.add(hydrationKey);
|
|
setHydratingPrBodyKey(hydrationKey);
|
|
|
|
let cancelled = false;
|
|
void ensurePrContext(github, directory, pr.number, { sourceRepo: status?.repo ?? null })
|
|
.then((ctx) => {
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
const ctxPr = ctx?.pr;
|
|
if (!ctxPr) {
|
|
return;
|
|
}
|
|
updatePrStatus(prStatusKey, (prev) => {
|
|
if (!prev?.pr || prev.pr.number !== pr.number) {
|
|
return prev;
|
|
}
|
|
return {
|
|
...prev,
|
|
pr: {
|
|
...prev.pr,
|
|
body: ctxPr.body || '',
|
|
},
|
|
};
|
|
});
|
|
})
|
|
.catch(() => {})
|
|
.finally(() => {
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
setHydratingPrBodyKey((prev) => (prev === hydrationKey ? null : prev));
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [directory, ensurePrContext, github, pr, prStatusKey, status?.repo, updatePrStatus]);
|
|
|
|
React.useEffect(() => {
|
|
if (!pr) {
|
|
setIsEditingPr(false);
|
|
setEditTitle('');
|
|
setEditBody('');
|
|
lastSyncedPrNumberRef.current = null;
|
|
return;
|
|
}
|
|
|
|
const numberChanged =
|
|
lastSyncedPrNumberRef.current !== null && lastSyncedPrNumberRef.current !== pr.number;
|
|
|
|
if (numberChanged) {
|
|
setIsEditingPr(false);
|
|
}
|
|
|
|
if (!isEditingPr || numberChanged) {
|
|
setEditTitle(pr.title || '');
|
|
setEditBody(pr.body || '');
|
|
}
|
|
|
|
lastSyncedPrNumberRef.current = pr.number;
|
|
}, [isEditingPr, pr]);
|
|
|
|
const formatTimestamp = React.useCallback((value?: 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 connectedGitHubLogin = React.useMemo(() => {
|
|
const login = githubAuthStatus?.user?.login;
|
|
return typeof login === 'string' ? login.trim() : '';
|
|
}, [githubAuthStatus]);
|
|
|
|
const selfMentionHighlightClass = React.useMemo(() => {
|
|
return "[&_a[href*='oc-self-mention=1']]:!text-[var(--primary-base)] [&_a[href*='oc-self-mention=1']]:font-semibold [&_a[href*='oc-self-mention=1']]:!no-underline [&_a[href*='oc-self-mention=1']:hover]:!text-[var(--primary-hover)]";
|
|
}, []);
|
|
|
|
const linkifyMentionsMarkdown = React.useCallback((content: string) => {
|
|
const selfLoginLower = connectedGitHubLogin.toLowerCase();
|
|
const mentionRegex = /(^|[^\w`])@([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,38}))/g;
|
|
return content.replace(mentionRegex, (_match, prefix: string, username: string) => {
|
|
const mention = `@${username}`;
|
|
const usernameLower = username.toLowerCase();
|
|
const selfTag = selfLoginLower && usernameLower === selfLoginLower ? '?oc-self-mention=1' : '';
|
|
return `${prefix}[${mention}](https://github.com/${usernameLower}${selfTag})`;
|
|
});
|
|
}, [connectedGitHubLogin]);
|
|
|
|
const timelineComments = React.useMemo<TimelineCommentItem[]>(() => {
|
|
const issue = (prContext?.issueComments ?? []).map((comment) => ({
|
|
id: `issue-${comment.id}`,
|
|
body: comment.body || '',
|
|
authorName: comment.author?.name || comment.author?.login || t('gitView.pr.comments.unknownAuthor'),
|
|
authorLogin: comment.author?.login || null,
|
|
avatarUrl: comment.author?.avatarUrl || null,
|
|
createdAt: comment.createdAt,
|
|
context: t('gitView.pr.comments.generalContext'),
|
|
path: null as string | null,
|
|
line: null as number | null,
|
|
}));
|
|
|
|
const review = (prContext?.reviewComments ?? []).map((comment) => ({
|
|
id: `review-${comment.id}`,
|
|
body: comment.body || '',
|
|
authorName: comment.author?.name || comment.author?.login || t('gitView.pr.comments.unknownAuthor'),
|
|
authorLogin: comment.author?.login || null,
|
|
avatarUrl: comment.author?.avatarUrl || null,
|
|
createdAt: comment.createdAt,
|
|
context: t('gitView.pr.comments.reviewContext'),
|
|
path: comment.path || null,
|
|
line: comment.line ?? null,
|
|
}));
|
|
|
|
const all = [...issue, ...review];
|
|
all.sort((a, b) => {
|
|
const aTs = a.createdAt ? Date.parse(a.createdAt) : 0;
|
|
const bTs = b.createdAt ? Date.parse(b.createdAt) : 0;
|
|
const aVal = Number.isFinite(aTs) ? aTs : 0;
|
|
const bVal = Number.isFinite(bTs) ? bTs : 0;
|
|
return aVal - bVal;
|
|
});
|
|
return all;
|
|
}, [prContext, t]);
|
|
|
|
// PR comments/checks are pinned as inline-comment drafts above the chat
|
|
// input (like terminal selections), not sent as an immediate message — the
|
|
// user decides how to prompt and when to send.
|
|
const resolveDraftTarget = React.useCallback((): InlineCommentDraftTarget | null => {
|
|
// Same convention as diff/file comments: a new-session draft pins context
|
|
// under the 'draft' key, which the composer adopts when the session is
|
|
// created — starting a fresh session from a PR comment is a valid flow.
|
|
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
|
|
if (!sessionKey) {
|
|
toast.error(t('gitView.pr.toast.noActiveSession'), { description: t('gitView.pr.toast.noActiveSessionDescription') });
|
|
return null;
|
|
}
|
|
return { directory, sessionKey };
|
|
}, [currentSessionId, directory, newSessionDraftOpen, t]);
|
|
|
|
const attachCommentDraft = React.useCallback((target: InlineCommentDraftTarget, comment: TimelineCommentItem) => {
|
|
const authorLabel = comment.authorLogin ? `@${comment.authorLogin}` : comment.authorName;
|
|
const location = comment.path ? ` · ${comment.path}${comment.line ? `:${comment.line}` : ''}` : '';
|
|
useInlineCommentDraftStore.getState().addDraft(target, {
|
|
source: 'pr-comment',
|
|
fileLabel: `PR #${pr?.number ?? ''} ${authorLabel}${location}`,
|
|
startLine: comment.line ?? 0,
|
|
endLine: comment.line ?? 0,
|
|
code: comment.body,
|
|
language: 'markdown',
|
|
text: '',
|
|
});
|
|
}, [pr?.number]);
|
|
|
|
const renderCheckRunSummary = React.useCallback((run: GitHubCheckRun, options?: { hideHeader?: boolean }) => {
|
|
const status = run.status || 'unknown';
|
|
const conclusion = run.conclusion ?? undefined;
|
|
const statusText = conclusion ? `${status} / ${conclusion}` : status;
|
|
const appName = run.app?.name || run.app?.slug;
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className={options?.hideHeader ? 'flex items-start justify-end gap-3' : 'flex items-start justify-between gap-3'}>
|
|
{!options?.hideHeader ? (
|
|
<div className="min-w-0">
|
|
<div className="typography-ui-label text-foreground truncate">{run.name}</div>
|
|
<div className="typography-micro text-muted-foreground truncate">
|
|
{appName ? `${appName} · ${statusText}` : statusText}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{run.detailsUrl ? (
|
|
<Button variant="outline" size="sm" asChild className="flex-shrink-0">
|
|
<a href={run.detailsUrl} target="_blank" rel="noopener noreferrer">
|
|
<Icon name="external-link" className="size-4" />
|
|
Open
|
|
</a>
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
|
|
{run.output?.title ? (
|
|
<div className="typography-micro text-foreground">{run.output.title}</div>
|
|
) : null}
|
|
{run.output?.summary ? (
|
|
<div className="typography-micro text-muted-foreground whitespace-pre-wrap break-words">
|
|
{run.output.summary}
|
|
</div>
|
|
) : null}
|
|
{run.output?.text ? (
|
|
<div className="rounded border border-border/40 bg-transparent px-2 py-2 typography-micro text-muted-foreground whitespace-pre-wrap break-words max-h-48 overflow-y-auto">
|
|
{run.output.text}
|
|
</div>
|
|
) : null}
|
|
|
|
{Array.isArray(run.annotations) && run.annotations.length > 0 ? (
|
|
<div className="space-y-1">
|
|
<div className="typography-micro text-muted-foreground">
|
|
Failed annotations{run.annotations.length > 20 ? ` (showing 20/${run.annotations.length})` : ''}
|
|
</div>
|
|
<div className="space-y-1">
|
|
{run.annotations.slice(0, 20).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>
|
|
<div className="typography-micro text-foreground whitespace-pre-wrap break-words mt-1">
|
|
{annotation.message}
|
|
</div>
|
|
{annotation.rawDetails ? (
|
|
<div className="typography-micro text-muted-foreground whitespace-pre-wrap break-words mt-1">
|
|
{annotation.rawDetails}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{run.job?.steps && run.job.steps.length > 0 ? (
|
|
<div className="space-y-1">
|
|
<div className="typography-micro text-muted-foreground">{t('gitView.pr.checks.steps')}</div>
|
|
<div className="space-y-1">
|
|
{run.job.steps.map((step, idx) => {
|
|
const c = (step.conclusion || '').toLowerCase();
|
|
const isFail = c && !['success', 'neutral', 'skipped'].includes(c);
|
|
const stepKey = `${run.id ?? 'run'}:${run.job?.jobId ?? 'job'}:${step.number ?? idx}:${step.name}`;
|
|
const stepExpanded = expandedCheckStepKeys.has(stepKey);
|
|
if (!isFail) {
|
|
return (
|
|
<div
|
|
key={stepKey}
|
|
className="typography-micro flex w-full items-center gap-2 rounded px-2 py-1 text-muted-foreground"
|
|
>
|
|
<span className="truncate">{step.name}</span>
|
|
{step.conclusion ? <span className="ml-auto flex-shrink-0">{step.conclusion}</span> : null}
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<Collapsible key={stepKey} open={stepExpanded}>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setExpandedCheckStepKeys((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(stepKey)) {
|
|
next.delete(stepKey);
|
|
} else {
|
|
next.add(stepKey);
|
|
}
|
|
return next;
|
|
});
|
|
}}
|
|
className={
|
|
'typography-micro flex w-full items-center gap-2 rounded px-2 py-1 text-left ' +
|
|
(isFail ? 'bg-destructive/10 text-destructive' : 'text-muted-foreground')
|
|
}
|
|
>
|
|
{stepExpanded ? <Icon name="arrow-down-s" className="size-4" /> : <Icon name="arrow-right-s" className="size-4" />}
|
|
<span className="truncate">{step.name}</span>
|
|
{step.conclusion ? <span className="ml-auto flex-shrink-0">{step.conclusion}</span> : null}
|
|
</button>
|
|
<CollapsibleContent>
|
|
<div className="ml-6 mt-1 rounded border border-border/40 bg-transparent px-2 py-2 typography-micro text-muted-foreground space-y-1">
|
|
{typeof step.number === 'number' ? <div>{t('gitView.pr.checks.stepLabel')}: {step.number}</div> : null}
|
|
{step.status ? <div>{t('gitView.pr.checks.statusLabel')}: {step.status}</div> : null}
|
|
{step.conclusion ? <div>{t('gitView.pr.checks.conclusionLabel')}: {step.conclusion}</div> : null}
|
|
{step.startedAt ? <div>{t('gitView.pr.checks.startedLabel')}: {formatTimestamp(step.startedAt)}</div> : null}
|
|
{step.completedAt ? <div>{t('gitView.pr.checks.completedLabel')}: {formatTimestamp(step.completedAt)}</div> : null}
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}, [expandedCheckStepKeys, formatTimestamp, t]);
|
|
|
|
const [isAttachingChecks, setIsAttachingChecks] = React.useState(false);
|
|
const [isAttachingComments, setIsAttachingComments] = React.useState(false);
|
|
|
|
const sendFailedChecksToChat = React.useCallback(async () => {
|
|
if (!github?.prContext) {
|
|
toast.error(t('gitView.pr.toast.githubApiUnavailable'));
|
|
return;
|
|
}
|
|
if (!directory || !pr) return;
|
|
const target = resolveDraftTarget();
|
|
if (!target) {
|
|
return;
|
|
}
|
|
|
|
setIsAttachingChecks(true);
|
|
try {
|
|
const context = await ensurePrContext(github, directory, pr.number, { includeCheckDetails: true, sourceRepo: status?.repo ?? null });
|
|
if (!context) {
|
|
toast.error(t('gitView.pr.toast.loadChecksFailed'));
|
|
return;
|
|
}
|
|
const runs = context.checkRuns ?? [];
|
|
const failed = runs.filter((r) => isFailedConclusion(r.conclusion));
|
|
|
|
if (failed.length === 0) {
|
|
toast.message(t('gitView.pr.toast.noFailedChecks'));
|
|
return;
|
|
}
|
|
|
|
const draftStore = useInlineCommentDraftStore.getState();
|
|
for (const run of failed) {
|
|
const annotations = (run.annotations ?? []).map((annotation) => [
|
|
[annotation.level, annotation.title].filter(Boolean).join(' '),
|
|
annotation.path ? `${annotation.path}${typeof annotation.startLine === 'number' ? `:${annotation.startLine}` : ''}` : null,
|
|
annotation.message,
|
|
annotation.rawDetails,
|
|
].filter(Boolean).join('\n'));
|
|
const failedSteps = (run.job?.steps ?? [])
|
|
.filter((step) => isFailedConclusion(step.conclusion))
|
|
.map((step) => `step ${step.number ?? '?'}: ${step.name} → ${step.conclusion}`);
|
|
const payload = [
|
|
`check: ${run.job?.workflowName ? `${run.job.workflowName} / ${run.name}` : run.name}`,
|
|
`status: ${run.status ?? 'unknown'} / ${run.conclusion ?? 'unknown'}`,
|
|
run.detailsUrl ? `url: ${run.detailsUrl}` : null,
|
|
run.output?.title ? `title: ${run.output.title}` : null,
|
|
run.output?.summary ? `summary:\n${run.output.summary}` : null,
|
|
failedSteps.length > 0 ? `failed steps:\n${failedSteps.join('\n')}` : null,
|
|
annotations.length > 0 ? `annotations:\n${annotations.join('\n---\n')}` : null,
|
|
].filter(Boolean).join('\n\n');
|
|
draftStore.addDraft(target, {
|
|
source: 'pr-check',
|
|
fileLabel: `PR #${pr.number} · ${run.name}`,
|
|
startLine: 0,
|
|
endLine: 0,
|
|
code: payload,
|
|
language: 'text',
|
|
text: '',
|
|
});
|
|
}
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
toast.error(t('gitView.pr.toast.loadChecksFailed'), { description: message });
|
|
} finally {
|
|
setIsAttachingChecks(false);
|
|
}
|
|
}, [directory, ensurePrContext, github, pr, resolveDraftTarget, status?.repo, t]);
|
|
|
|
const sendCommentsToChat = React.useCallback(async () => {
|
|
if (!github?.prContext) {
|
|
toast.error(t('gitView.pr.toast.githubApiUnavailable'));
|
|
return;
|
|
}
|
|
if (!directory || !pr) return;
|
|
const target = resolveDraftTarget();
|
|
if (!target) {
|
|
return;
|
|
}
|
|
|
|
setIsAttachingComments(true);
|
|
try {
|
|
const context = await ensurePrContext(github, directory, pr.number, { sourceRepo: status?.repo ?? null });
|
|
if (!context) {
|
|
toast.error(t('gitView.pr.toast.loadPrCommentsFailed'));
|
|
return;
|
|
}
|
|
if (timelineComments.length === 0) {
|
|
toast.message(t('gitView.pr.toast.noPrComments'));
|
|
return;
|
|
}
|
|
|
|
for (const comment of timelineComments) {
|
|
attachCommentDraft(target, comment);
|
|
}
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
toast.error(t('gitView.pr.toast.loadPrCommentsFailed'), { description: message });
|
|
} finally {
|
|
setIsAttachingComments(false);
|
|
}
|
|
}, [attachCommentDraft, directory, ensurePrContext, github, pr, resolveDraftTarget, status?.repo, t, timelineComments]);
|
|
|
|
const sendSingleCommentToChat = React.useCallback(async (comment: TimelineCommentItem) => {
|
|
const target = resolveDraftTarget();
|
|
if (!target) {
|
|
return;
|
|
}
|
|
|
|
attachCommentDraft(target, comment);
|
|
}, [attachCommentDraft, resolveDraftTarget]);
|
|
|
|
const refresh = React.useCallback(async (options?: { force?: boolean; onlyExistingPr?: boolean; silent?: boolean; markInitialResolved?: boolean }) => {
|
|
await refreshPrStatus(prStatusKey, options);
|
|
}, [prStatusKey, refreshPrStatus]);
|
|
|
|
const scheduleActionRefresh = React.useCallback(() => {
|
|
pendingActionRefreshTimersRef.current.forEach((timerId) => {
|
|
window.clearTimeout(timerId);
|
|
});
|
|
pendingActionRefreshTimersRef.current = PR_ACTION_REFRESH_DELAYS_MS.map((delayMs) => window.setTimeout(() => {
|
|
void refresh({ force: true, silent: true, markInitialResolved: true });
|
|
}, delayMs));
|
|
}, [refresh]);
|
|
|
|
React.useEffect(() => {
|
|
if (!github?.prStatus || !canShow || remotes.length <= 1) {
|
|
return;
|
|
}
|
|
if (didUserOverrideRemoteRef.current) {
|
|
return;
|
|
}
|
|
if (status?.pr) {
|
|
return;
|
|
}
|
|
|
|
const probeKey = `${snapshotKey}::${selectedRemote?.name ?? ''}`;
|
|
if (autoRemoteProbeDoneRef.current.has(probeKey)) {
|
|
return;
|
|
}
|
|
autoRemoteProbeDoneRef.current.add(probeKey);
|
|
|
|
const candidates = rankRemotesForAutoSelect(remotes, trackingBranch)
|
|
.filter((remote) => remote.name !== selectedRemote?.name);
|
|
if (candidates.length === 0) {
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
const run = async () => {
|
|
for (const candidate of candidates) {
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
try {
|
|
const next = await github.prStatus(directory, branch, candidate.name);
|
|
if (!next?.pr) {
|
|
continue;
|
|
}
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
setSelectedRemote((prev) => (prev?.name === candidate.name ? prev : candidate));
|
|
return;
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
};
|
|
|
|
void run();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [branch, canShow, directory, github, remotes, selectedRemote?.name, snapshotKey, status?.pr, trackingBranch]);
|
|
|
|
React.useEffect(() => {
|
|
ensurePrStatusEntry(prStatusKey);
|
|
setPrStatusParams(prStatusKey, {
|
|
directory,
|
|
branch,
|
|
remoteName: selectedRemote?.name ?? null,
|
|
canShow,
|
|
github,
|
|
githubAuthChecked,
|
|
githubConnected: githubAuthStatus?.connected ?? null,
|
|
});
|
|
}, [
|
|
branch,
|
|
canShow,
|
|
directory,
|
|
ensurePrStatusEntry,
|
|
github,
|
|
githubAuthChecked,
|
|
githubAuthStatus?.connected,
|
|
prStatusKey,
|
|
selectedRemote?.name,
|
|
setPrStatusParams,
|
|
]);
|
|
|
|
React.useEffect(() => {
|
|
startPrStatusWatching(prStatusKey);
|
|
return () => {
|
|
stopPrStatusWatching(prStatusKey);
|
|
};
|
|
}, [prStatusKey, startPrStatusWatching, stopPrStatusWatching]);
|
|
|
|
React.useEffect(() => {
|
|
const snapshot = pullRequestDraftSnapshots.get(snapshotKey) ?? null;
|
|
setTitle(snapshot?.title ?? branchToTitle(branch));
|
|
setBody(snapshot?.body ?? '');
|
|
setDraft(snapshot?.draft ?? false);
|
|
setTargetBaseBranch(snapshot?.targetBaseBranch ? normalizeBranchRef(snapshot.targetBaseBranch) : normalizeBranchRef(baseBranch));
|
|
const nextRemote = pickInitialPrRemote(remotes, {
|
|
selectedRemoteName: snapshot?.selectedRemoteName,
|
|
trackingBranch,
|
|
});
|
|
setSelectedRemote((prev) => (prev?.name === nextRemote?.name ? prev : nextRemote));
|
|
}, [baseBranch, branch, remotes, snapshotKey, trackingBranch]);
|
|
|
|
React.useEffect(() => {
|
|
void refresh({ markInitialResolved: true });
|
|
}, [prStatusKey, refresh]);
|
|
|
|
React.useEffect(() => {
|
|
if (!canShow || !selectedRemote?.name) {
|
|
return;
|
|
}
|
|
void refresh({ force: true, silent: true, markInitialResolved: true });
|
|
}, [canShow, refresh, selectedRemote?.name]);
|
|
|
|
React.useEffect(() => {
|
|
const resolvedRemoteName = status?.resolvedRemoteName?.trim();
|
|
if (!resolvedRemoteName || didUserOverrideRemoteRef.current) {
|
|
return;
|
|
}
|
|
const resolvedRemote = remotes.find((candidate) => candidate.name === resolvedRemoteName);
|
|
if (!resolvedRemote) {
|
|
return;
|
|
}
|
|
setSelectedRemote((prev) => (prev?.name === resolvedRemote.name ? prev : resolvedRemote));
|
|
}, [remotes, status?.resolvedRemoteName]);
|
|
|
|
React.useEffect(() => {
|
|
// Coming back to the app is the moment a PR is most likely to have changed
|
|
// elsewhere — including a merged one being replaced by a newer open PR — so
|
|
// staleness is read from the store when the event fires, not captured here.
|
|
const refreshWhenStale = () => {
|
|
const lastRefreshAt = useGitHubPrStatusStore.getState().entries[prStatusKey]?.lastRefreshAt ?? 0;
|
|
if (Date.now() - lastRefreshAt > 60_000) {
|
|
void refresh({ force: true, silent: true });
|
|
}
|
|
};
|
|
const onVisibility = () => {
|
|
if (document.visibilityState !== 'visible') {
|
|
return;
|
|
}
|
|
refreshWhenStale();
|
|
};
|
|
|
|
window.addEventListener('focus', refreshWhenStale);
|
|
document.addEventListener('visibilitychange', onVisibility);
|
|
return () => {
|
|
window.removeEventListener('focus', refreshWhenStale);
|
|
document.removeEventListener('visibilitychange', onVisibility);
|
|
};
|
|
}, [prStatusKey, refresh]);
|
|
|
|
React.useEffect(() => {
|
|
if (githubAuthChecked && githubAuthStatus?.connected === false) {
|
|
void refresh({ force: true, silent: true, markInitialResolved: true });
|
|
}
|
|
}, [githubAuthChecked, githubAuthStatus, refresh]);
|
|
|
|
React.useEffect(() => {
|
|
if (!directory || !branch) {
|
|
return;
|
|
}
|
|
pullRequestDraftSnapshots.set(snapshotKey, {
|
|
title,
|
|
body,
|
|
draft,
|
|
additionalContext,
|
|
targetBaseBranch,
|
|
selectedRemoteName: selectedRemote?.name,
|
|
activeSegment,
|
|
});
|
|
}, [snapshotKey, title, body, draft, additionalContext, targetBaseBranch, selectedRemote?.name, directory, branch, activeSegment]);
|
|
|
|
React.useEffect(() => {
|
|
const pendingActionRefreshTimers = pendingActionRefreshTimersRef.current;
|
|
return () => {
|
|
pendingActionRefreshTimers.forEach((timerId) => {
|
|
window.clearTimeout(timerId);
|
|
});
|
|
pendingActionRefreshTimersRef.current = [];
|
|
};
|
|
}, []);
|
|
|
|
const generateDescription = React.useCallback(async () => {
|
|
if (isGenerating) return;
|
|
if (!directory) return;
|
|
setIsGenerating(true);
|
|
try {
|
|
// For cross-repo PRs, use the upstream's default branch SHA for the commit range.
|
|
// Using a bare branch name like "main" would resolve to the local ref, making
|
|
// "git log main..main" a no-op. The SHA points to the actual upstream commit.
|
|
const baseRef = (useDetectedUpstream && detectedUpstream?.defaultBranchSha)
|
|
? detectedUpstream.defaultBranchSha
|
|
: targetBaseBranch;
|
|
const payload: { base: string; head: string; context?: string; files?: string[] } = {
|
|
base: baseRef,
|
|
head: branch,
|
|
};
|
|
if (additionalContext) {
|
|
payload.context = additionalContext;
|
|
}
|
|
const generated = await generatePullRequestDescription(directory, payload);
|
|
|
|
if (generated.title?.trim()) {
|
|
setTitle(generated.title.trim());
|
|
}
|
|
if (generated.body?.trim()) {
|
|
setBody(generated.body.trim());
|
|
}
|
|
onGeneratedDescription?.();
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
toast.error(t('gitView.pr.toast.generateDescriptionFailed'), { description: message });
|
|
} finally {
|
|
setIsGenerating(false);
|
|
}
|
|
}, [additionalContext, branch, detectedUpstream?.defaultBranchSha, directory, isGenerating, onGeneratedDescription, targetBaseBranch, t, useDetectedUpstream]);
|
|
|
|
const createPr = React.useCallback(async () => {
|
|
if (!github?.prCreate) {
|
|
toast.error(t('gitView.pr.toast.githubApiUnavailable'));
|
|
return;
|
|
}
|
|
const trimmedTitle = title.trim();
|
|
if (!trimmedTitle) {
|
|
toast.error(t('gitView.pr.toast.titleRequired'));
|
|
return;
|
|
}
|
|
|
|
const trimmedBase = targetBaseBranch.trim();
|
|
if (!trimmedBase) {
|
|
toast.error(t('gitView.pr.toast.baseBranchRequired'));
|
|
return;
|
|
}
|
|
if (!useDetectedUpstream && trimmedBase === branch) {
|
|
toast.error(t('gitView.pr.toast.baseMustDifferFromHead'));
|
|
return;
|
|
}
|
|
|
|
setIsCreating(true);
|
|
try {
|
|
const trackingRemoteName = getTrackingRemoteName(trackingBranch);
|
|
|
|
const usingDetectedUpstream = useDetectedUpstream && detectedUpstream;
|
|
|
|
const pr = await github.prCreate({
|
|
directory,
|
|
title: trimmedTitle,
|
|
head: branch,
|
|
base: trimmedBase,
|
|
...(body.trim() ? { body } : {}),
|
|
draft,
|
|
...(usingDetectedUpstream
|
|
? { targetRepo: { owner: detectedUpstream.owner, repo: detectedUpstream.repo }, headRemote: 'origin' }
|
|
: {
|
|
...(selectedRemote ? { remote: selectedRemote.name } : {}),
|
|
...(trackingRemoteName && trackingRemoteName !== selectedRemote?.name
|
|
? { headRemote: trackingRemoteName }
|
|
: {}),
|
|
}),
|
|
});
|
|
toast.success(t('gitView.pr.toast.prCreated'));
|
|
updatePrStatus(prStatusKey, (prev) => (prev ? { ...prev, pr } : prev));
|
|
await refresh({ force: true });
|
|
scheduleActionRefresh();
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
toast.error(t('gitView.pr.toast.createPrFailed'), { description: message });
|
|
} finally {
|
|
setIsCreating(false);
|
|
}
|
|
}, [body, branch, detectedUpstream, directory, draft, github, prStatusKey, refresh, scheduleActionRefresh, selectedRemote, targetBaseBranch, title, trackingBranch, updatePrStatus, useDetectedUpstream, t]);
|
|
|
|
const mergePr = React.useCallback(async (pr: GitHubPullRequest) => {
|
|
if (!github?.prMerge) {
|
|
toast.error(t('gitView.pr.toast.githubApiUnavailable'));
|
|
return;
|
|
}
|
|
setIsMerging(true);
|
|
try {
|
|
const result = await github.prMerge({ directory, number: pr.number, method: mergeMethod });
|
|
if (result.merged) {
|
|
toast.success(t('gitView.pr.toast.prMerged'));
|
|
} else {
|
|
toast.message(t('gitView.pr.toast.prNotMerged'), { description: result.message || t('gitView.pr.notMergeable') });
|
|
}
|
|
await refresh({ force: true });
|
|
scheduleActionRefresh();
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
toast.error(t('gitView.pr.toast.mergeFailed'), { description: message });
|
|
if (pr.url) {
|
|
void openExternal(pr.url);
|
|
}
|
|
} finally {
|
|
setIsMerging(false);
|
|
}
|
|
}, [directory, github, mergeMethod, refresh, scheduleActionRefresh, t]);
|
|
|
|
const markReady = React.useCallback(async (pr: GitHubPullRequest) => {
|
|
if (!github?.prReady) {
|
|
toast.error(t('gitView.pr.toast.githubApiUnavailable'));
|
|
return;
|
|
}
|
|
setIsMarkingReady(true);
|
|
try {
|
|
await github.prReady({ directory, number: pr.number });
|
|
toast.success(t('gitView.pr.toast.markedReady'));
|
|
await refresh({ force: true });
|
|
scheduleActionRefresh();
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
toast.error(t('gitView.pr.toast.markReadyFailed'), { description: message });
|
|
if (pr.url) {
|
|
void openExternal(pr.url);
|
|
}
|
|
} finally {
|
|
setIsMarkingReady(false);
|
|
}
|
|
}, [directory, github, refresh, scheduleActionRefresh, t]);
|
|
|
|
const updatePr = React.useCallback(async (pr: GitHubPullRequest) => {
|
|
if (!github?.prUpdate) {
|
|
toast.error(t('gitView.pr.toast.githubApiUnavailable'));
|
|
return;
|
|
}
|
|
|
|
const trimmedTitle = editTitle.trim();
|
|
if (!trimmedTitle) {
|
|
toast.error(t('gitView.pr.toast.titleRequired'));
|
|
return;
|
|
}
|
|
|
|
setIsUpdating(true);
|
|
try {
|
|
const updated = await github.prUpdate({
|
|
directory,
|
|
number: pr.number,
|
|
title: trimmedTitle,
|
|
body: editBody,
|
|
});
|
|
updatePrStatus(prStatusKey, (prev) => (prev
|
|
? {
|
|
...prev,
|
|
pr: {
|
|
...(prev.pr ?? pr),
|
|
...updated,
|
|
},
|
|
}
|
|
: prev));
|
|
setIsEditingPr(false);
|
|
toast.success(t('gitView.pr.toast.prUpdated'));
|
|
await refresh({ force: true });
|
|
scheduleActionRefresh();
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
toast.error(t('gitView.pr.toast.updatePrFailed'), { description: message });
|
|
} finally {
|
|
setIsUpdating(false);
|
|
}
|
|
}, [directory, editBody, editTitle, github, prStatusKey, refresh, scheduleActionRefresh, updatePrStatus, t]);
|
|
|
|
if (!canShow) {
|
|
return (
|
|
<section className="border-0 bg-transparent rounded-none">
|
|
<div className="space-y-1 pt-3">
|
|
<div className="typography-ui-header font-semibold text-foreground">{t('gitView.pullRequest.title')}</div>
|
|
<div className="typography-micro text-muted-foreground">
|
|
{t('gitView.pullRequest.availableOnFeatureBranches')}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
const originRepoUrl = status?.repo?.url || null;
|
|
const repoUrl = (useDetectedUpstream && detectedUpstream?.url) ? detectedUpstream.url : originRepoUrl;
|
|
const canMerge = Boolean(status?.canMerge);
|
|
const isConnected = Boolean(status?.connected);
|
|
const shouldShowConnectionNotice = githubAuthChecked && status?.connected === false;
|
|
const prVisualState = getPrVisualState(status);
|
|
const prColorVar = prVisualState ? `var(--pr-${prVisualState})` : 'var(--status-info)';
|
|
const prStateIconName = prVisualState === 'draft'
|
|
? 'git-pr-draft'
|
|
: prVisualState === 'merged'
|
|
? 'git-merge'
|
|
: prVisualState === 'closed'
|
|
? 'git-close-pull-request'
|
|
: 'git-pull-request';
|
|
const prStatusText = pr
|
|
? [
|
|
`${pr.state}${pr.draft ? ' (draft)' : ''}`,
|
|
pr.mergeable === false ? t('gitView.pr.notMergeable') : null,
|
|
pr.state === 'open' && typeof pr.mergeableState === 'string' && pr.mergeableState && pr.mergeableState !== 'unknown'
|
|
? pr.mergeableState
|
|
: null,
|
|
].filter(Boolean).join(' · ')
|
|
: '';
|
|
const checksText = checks
|
|
? checks.total > 0
|
|
? `${checks.success}/${checks.total} ${t('gitView.pr.checks.label')}`
|
|
: `${checks.state} ${t('gitView.pr.checks.label')}`
|
|
: '';
|
|
const containerClassName = 'border-0 bg-transparent rounded-none';
|
|
const headerClassName = 'px-0 py-3 border-b border-border/40 flex flex-col gap-1';
|
|
const bodyClassName = 'flex flex-col gap-3 py-3';
|
|
|
|
return (
|
|
<section className={containerClassName}>
|
|
<div className={headerClassName}>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
{pr ? (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="xs"
|
|
className="shrink-0"
|
|
onClick={() => void openExternal(pr.url)}
|
|
aria-label={t('gitView.pr.actions.openOnGitHubAria')}
|
|
>
|
|
<Icon name={prStateIconName} className="size-4 shrink-0" style={{ color: prColorVar }} />
|
|
{t('gitView.pr.actions.openOnGitHub')}
|
|
</Button>
|
|
) : (
|
|
<Icon name={prStateIconName} className="size-4 shrink-0" style={{ color: 'var(--surface-muted-foreground)' }} />
|
|
)}
|
|
<h3 className="typography-ui-header font-semibold text-foreground truncate">{t('gitView.pullRequest.title')}</h3>
|
|
{pr ? (
|
|
<span className="typography-meta text-muted-foreground truncate">#{pr.number}</span>
|
|
) : null}
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-1">
|
|
{isLoading ? <Icon name="loader-4" className="size-4 animate-spin text-muted-foreground" /> : null}
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="inline-flex size-5 items-center justify-center rounded hover:bg-interactive-hover/60 disabled:opacity-40"
|
|
disabled={isLoading}
|
|
onClick={() => void refresh({ force: true })}
|
|
aria-label={t('gitView.pr.actions.refreshAria')}
|
|
>
|
|
<Icon name="refresh" className="size-3.5 text-muted-foreground" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent><p>{t('gitView.pr.actions.refresh')}</p></TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
|
|
{pr ? (
|
|
<div className="@container/pr-actions flex min-w-0 items-center justify-between gap-2">
|
|
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 typography-micro text-muted-foreground">
|
|
<span style={{ color: prColorVar }}>{prStatusText}</span>
|
|
{checks ? (
|
|
<span className="inline-flex items-center gap-1.5">
|
|
<span className={`h-2 w-2 rounded-full ${statusColor(checks.state)}`} />
|
|
{checksText}
|
|
</span>
|
|
) : null}
|
|
{trackingBranch && selectedRemote && trackingBranch.split('/')[0] !== selectedRemote.name ? (
|
|
<span className="min-w-0 truncate">
|
|
{trackingBranch.split('/')[0]} → {selectedRemote.name}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-1.5">
|
|
{showWalkthroughAction ? (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className={cn('pr-actions__walkthrough-button h-7 shrink-0 gap-1.5 px-2', WALKTHROUGH_ACTION_CLASS)}
|
|
onClick={() => {
|
|
requestWalkthroughSource(directory, { kind: 'pr', number: pr.number });
|
|
openContextSurface(directory, 'walkthrough');
|
|
}}
|
|
aria-label={t('walkthrough.action.open')}
|
|
>
|
|
<Icon name="route" className="size-4" />
|
|
<span className="pr-actions__walkthrough-label typography-ui-label">
|
|
{t('walkthrough.action.open')}
|
|
</span>
|
|
</Button>
|
|
) : null}
|
|
{canMerge && pr.draft && pr.state === 'open' ? (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 w-7 px-0"
|
|
onClick={() => markReady(pr)}
|
|
disabled={isMarkingReady || isMerging || isUpdating || isEditingPr}
|
|
aria-label={t('gitView.pr.actions.markReadyAria')}
|
|
>
|
|
{isMarkingReady ? <Icon name="loader-4" className="size-4 animate-spin" /> : <Icon name="checkbox-circle" className="size-4" />}
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent><p>{t('gitView.pr.actions.markReady')}</p></TooltipContent>
|
|
</Tooltip>
|
|
) : null}
|
|
{canMerge ? (
|
|
<>
|
|
<Select
|
|
value={mergeMethod}
|
|
onValueChange={(value) => setMergeMethod(value as MergeMethod)}
|
|
disabled={isMerging || pr.state !== 'open'}
|
|
>
|
|
<SelectTrigger size="sm" className="h-7 w-auto min-w-0">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="squash">{t('gitView.pr.mergeMethod.squash')}</SelectItem>
|
|
<SelectItem value="merge">{t('gitView.pr.mergeMethod.merge')}</SelectItem>
|
|
<SelectItem value="rebase">{t('gitView.pr.mergeMethod.rebase')}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
size="sm"
|
|
className="h-7 w-7 px-0"
|
|
onClick={() => mergePr(pr)}
|
|
disabled={isMerging || isMarkingReady || pr.state !== 'open' || pr.draft || isUpdating || isEditingPr}
|
|
aria-label={t('gitView.pr.actions.mergePrAria')}
|
|
>
|
|
{isMerging ? <Icon name="loader-4" className="size-4 animate-spin" /> : <Icon name="git-merge" className="size-4" />}
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent><p>{t('gitView.pr.actions.mergePr')}</p></TooltipContent>
|
|
</Tooltip>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className={bodyClassName}>
|
|
{shouldShowConnectionNotice ? (
|
|
<div className="space-y-2">
|
|
<div className="typography-meta text-muted-foreground">
|
|
{t('gitView.pr.githubNotConnected')}
|
|
</div>
|
|
<Button variant="outline" size="sm" onClick={openGitHubSettings} className="w-fit">
|
|
{t('gitView.pr.actions.openSettings')}
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
|
|
{error ? (
|
|
<div className="space-y-2">
|
|
<div className="typography-ui-label text-foreground">{t('gitView.pr.statusUnavailable')}</div>
|
|
<div className="typography-meta text-muted-foreground break-words">{error}</div>
|
|
{repoUrl ? (
|
|
<Button variant="outline" size="sm" asChild className="w-fit">
|
|
<a href={repoUrl} target="_blank" rel="noopener noreferrer">
|
|
<Icon name="external-link" className="size-4" />
|
|
Open Repo
|
|
</a>
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
{!pr && !isInitialStatusResolved && !error && !shouldShowConnectionNotice ? (
|
|
<div className="flex items-center gap-2 typography-micro text-muted-foreground">
|
|
<Icon name="loader-4" className="size-4 animate-spin" />
|
|
{t('gitView.pr.checkingStatus')}
|
|
</div>
|
|
) : pr && !isHistoricalPr ? (
|
|
<div className="flex flex-col gap-3">
|
|
<div className="h-8 min-w-0">
|
|
<SortableTabsStrip
|
|
className="h-full"
|
|
items={[
|
|
{ id: 'overview', label: t('gitView.pr.segment.overview') },
|
|
{
|
|
id: 'checks',
|
|
label: checks && checks.total > 0
|
|
? `${t('gitView.pr.segment.checks')} ${checks.success}/${checks.total}`
|
|
: t('gitView.pr.segment.checks'),
|
|
icon: checks
|
|
? <span className={`h-1.5 w-1.5 rounded-full ${statusColor(checks.state)}`} />
|
|
: undefined,
|
|
},
|
|
{
|
|
id: 'comments',
|
|
label: prContext
|
|
? `${t('gitView.pr.segment.comments')} ${(prContext.issueComments?.length ?? 0) + (prContext.reviewComments?.length ?? 0)}`
|
|
: t('gitView.pr.segment.comments'),
|
|
},
|
|
]}
|
|
activeId={activeSegment}
|
|
onSelect={(segmentId) => setActiveSegment(segmentId as PrSegment)}
|
|
layoutMode="fit"
|
|
variant="active-pill"
|
|
activePillButtonClassName="h-7"
|
|
/>
|
|
</div>
|
|
|
|
{activeSegment === 'overview' ? (
|
|
<div className="flex min-w-0 flex-col gap-2">
|
|
{canMerge && pr.draft ? (
|
|
<div className="typography-micro text-muted-foreground">
|
|
{t('gitView.pr.draftMustBeReady')}
|
|
</div>
|
|
) : null}
|
|
{!canMerge ? (
|
|
<div className="typography-micro text-muted-foreground">{t('gitView.pr.noMergePermission')}</div>
|
|
) : null}
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="min-w-0 flex-1">
|
|
{isEditingPr ? (
|
|
<Input
|
|
value={editTitle}
|
|
onChange={(e) => setEditTitle(e.target.value)}
|
|
placeholder={t('gitView.pr.placeholder.title')}
|
|
autoCorrect={hasTouchInput ? "on" : "off"}
|
|
autoCapitalize={hasTouchInput ? "sentences" : "off"}
|
|
spellCheck={hasTouchInput}
|
|
/>
|
|
) : (
|
|
<div className="typography-markdown text-xl font-semibold text-foreground break-words leading-snug">{pr.title}</div>
|
|
)}
|
|
</div>
|
|
{pr.state === 'open' ? (
|
|
<div className="flex shrink-0 items-center gap-1.5">
|
|
{isEditingPr ? (
|
|
<>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-9 w-9 px-0"
|
|
onClick={() => {
|
|
setIsEditingPr(false);
|
|
setEditTitle(pr.title || '');
|
|
setEditBody(pr.body || '');
|
|
}}
|
|
disabled={isUpdating}
|
|
aria-label={t('gitView.pr.actions.cancelEditingAria')}
|
|
>
|
|
<Icon name="close" className="size-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent><p>{t('gitView.pr.actions.cancelEditing')}</p></TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
size="sm"
|
|
className="h-9 w-9 px-0"
|
|
onClick={() => updatePr(pr)}
|
|
disabled={isUpdating || !editTitle.trim()}
|
|
aria-label={t('gitView.pr.actions.savePrAria')}
|
|
>
|
|
{isUpdating ? <Icon name="loader-4" className="size-4 animate-spin" /> : <Icon name="check" className="size-4" />}
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent><p>{t('gitView.pr.actions.savePr')}</p></TooltipContent>
|
|
</Tooltip>
|
|
</>
|
|
) : (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 w-7 px-0"
|
|
onClick={() => setIsEditingPr(true)}
|
|
aria-label={t('gitView.pr.actions.editPrAria')}
|
|
>
|
|
<Icon name="edit" className="size-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent><p>{t('gitView.pr.actions.editPr')}</p></TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
{isEditingPr ? (
|
|
<Textarea
|
|
value={editBody}
|
|
onChange={(e) => setEditBody(e.target.value)}
|
|
outerClassName="min-h-[60vh]"
|
|
placeholder={t('gitView.pr.placeholder.description')}
|
|
autoCorrect={hasTouchInput ? "on" : "off"}
|
|
autoCapitalize={hasTouchInput ? "sentences" : "off"}
|
|
spellCheck={hasTouchInput}
|
|
/>
|
|
) : null}
|
|
|
|
{!isEditingPr ? (
|
|
pr.body?.trim() ? (
|
|
<SimpleMarkdownRenderer
|
|
content={pr.body}
|
|
className="typography-markdown-body min-w-0 text-muted-foreground break-words"
|
|
enableFileReferences={false}
|
|
/>
|
|
) : (
|
|
<div className="typography-micro text-muted-foreground whitespace-pre-wrap break-words">
|
|
{isHydratingCurrentPrBody ? t('gitView.pr.loadingDescription') : t('gitView.pr.noDescription')}
|
|
</div>
|
|
)
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
{activeSegment === 'checks' ? (
|
|
<div className="flex min-w-0 flex-col gap-3">
|
|
{checks && checks.total > 0 ? (
|
|
<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">
|
|
{checks.success > 0 ? (
|
|
<div className="bg-[color:var(--status-success)]" style={{ width: `${(checks.success / checks.total) * 100}%` }} />
|
|
) : null}
|
|
{checks.failure > 0 ? (
|
|
<div className="bg-[color:var(--status-error)]" style={{ width: `${(checks.failure / checks.total) * 100}%` }} />
|
|
) : null}
|
|
{checks.pending > 0 ? (
|
|
<div className="bg-[color:var(--status-warning)]" style={{ width: `${(checks.pending / checks.total) * 100}%` }} />
|
|
) : null}
|
|
</div>
|
|
<span className="shrink-0 typography-micro tabular-nums text-muted-foreground">
|
|
{checks.success}/{checks.total} {t('gitView.pr.checks.label')}
|
|
</span>
|
|
{(checks.inProgress ?? 0) > 0 ? (
|
|
<span className="inline-flex shrink-0 items-center gap-1 typography-micro text-[var(--status-warning)]">
|
|
<Icon name="loader-4" className="size-3.5 animate-spin" />
|
|
{formatElapsedDuration(checks.startedAt, undefined, nowTick)}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
{checks?.failure ? (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-fit gap-1.5 border-[var(--status-success-border)] bg-[var(--status-success-background)] text-[var(--status-success)]"
|
|
onClick={sendFailedChecksToChat}
|
|
disabled={isAttachingChecks}
|
|
aria-label={t('gitView.pr.actions.resolveFailedChecksAria')}
|
|
>
|
|
{isAttachingChecks
|
|
? <Icon name="loader-4" className="size-4 animate-spin" />
|
|
: <Icon name="ai-generate-2" className="size-4" />}
|
|
{t('gitView.pr.actions.resolveFailedChecks')}
|
|
</Button>
|
|
) : null}
|
|
|
|
{(prContext?.checkRuns?.length ?? 0) > 0 ? (
|
|
<div className="flex flex-col gap-1.5">
|
|
{(prContext?.checkRuns ?? []).map((run, idx) => {
|
|
const runKey = `${run.id ?? 'run'}:${run.name}:${idx}`;
|
|
const isRunning = run.status === 'in_progress';
|
|
const isQueued = run.status === 'queued';
|
|
const failed = isFailedConclusion(run.conclusion);
|
|
const expanded = expandedCheckRunKeys.has(runKey);
|
|
const hasDetails = Boolean(
|
|
run.output?.title || run.output?.summary || run.output?.text
|
|
|| (run.annotations?.length ?? 0) > 0
|
|
|| (run.job?.steps?.length ?? 0) > 0
|
|
|| run.detailsUrl,
|
|
);
|
|
const workflowName = run.job?.workflowName;
|
|
const durationLabel = isRunning
|
|
? formatElapsedDuration(run.startedAt, undefined, nowTick)
|
|
: formatElapsedDuration(run.startedAt, run.completedAt);
|
|
return (
|
|
<div key={runKey} className={cn('rounded-md border border-border/40', failed && 'border-[var(--status-error-border)]')}>
|
|
<button
|
|
type="button"
|
|
disabled={!hasDetails}
|
|
onClick={() => {
|
|
setExpandedCheckRunKeys((previous) => {
|
|
const next = new Set(previous);
|
|
if (next.has(runKey)) {
|
|
next.delete(runKey);
|
|
} else {
|
|
next.add(runKey);
|
|
}
|
|
return next;
|
|
});
|
|
}}
|
|
className="flex w-full items-center gap-2 px-2.5 py-2 text-left disabled:cursor-default"
|
|
>
|
|
{isRunning ? (
|
|
<Icon name="loader-4" className="size-4 shrink-0 animate-spin text-[var(--status-warning)]" />
|
|
) : isQueued ? (
|
|
<Icon name="time" className="size-4 shrink-0 text-muted-foreground" />
|
|
) : failed ? (
|
|
<Icon name="close-circle" className="size-4 shrink-0 text-[var(--status-error)]" />
|
|
) : (
|
|
<Icon name="checkbox-circle" className="size-4 shrink-0 text-[var(--status-success)]" />
|
|
)}
|
|
<span className="min-w-0 flex-1 truncate typography-ui-label text-foreground">
|
|
{workflowName && workflowName !== run.name ? `${workflowName} / ${run.name}` : run.name}
|
|
</span>
|
|
{durationLabel ? (
|
|
<span className="shrink-0 typography-micro tabular-nums text-muted-foreground">{durationLabel}</span>
|
|
) : null}
|
|
{hasDetails ? (
|
|
<Icon name="arrow-down-s" className={cn('size-4 shrink-0 text-muted-foreground transition-transform', expanded && 'rotate-180')} />
|
|
) : null}
|
|
</button>
|
|
{expanded && hasDetails ? (
|
|
<div className="min-w-0 overflow-hidden border-t border-border/40 p-2.5">
|
|
{renderCheckRunSummary(run, { hideHeader: true })}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : isLoadingPrContext ? (
|
|
<div className="flex items-center justify-center gap-2 py-6 typography-micro text-muted-foreground">
|
|
<Icon name="loader-4" className="size-4 animate-spin" />
|
|
{t('gitView.loading.loading')}
|
|
</div>
|
|
) : (
|
|
<div className="py-6 text-center typography-micro text-muted-foreground">{t('gitView.pr.checkDetails.empty')}</div>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
|
|
{activeSegment === 'comments' ? (
|
|
<div className="flex min-w-0 flex-col gap-2">
|
|
{timelineComments.length > 0 ? (
|
|
<div className="flex items-center justify-end">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 gap-1.5 text-[var(--status-success)] hover:bg-[var(--status-success-background)] hover:text-[var(--status-success)]"
|
|
onClick={sendCommentsToChat}
|
|
disabled={isAttachingComments}
|
|
aria-label={t('gitView.pr.actions.shareCommentsAria')}
|
|
>
|
|
{isAttachingComments
|
|
? <Icon name="loader-4" className="size-3.5 animate-spin" />
|
|
: <Icon name="ai-generate-2" className="size-3.5" />}
|
|
{t('gitView.pr.comments.addAll')}
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
|
|
{timelineComments.length > 0 ? (
|
|
<div className="relative pl-3">
|
|
<div>
|
|
{timelineComments.map((comment, idx) => {
|
|
const initial = (comment.authorName || '?').charAt(0).toUpperCase();
|
|
const isLast = idx === timelineComments.length - 1;
|
|
return (
|
|
<div key={comment.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}
|
|
<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">
|
|
{comment.avatarUrl ? (
|
|
<img src={comment.avatarUrl} alt={comment.authorName} className="h-full w-full object-cover" />
|
|
) : (
|
|
<span>{initial}</span>
|
|
)}
|
|
</div>
|
|
<div className="rounded-lg bg-surface-elevated px-3 pt-0 pb-3 space-y-2">
|
|
<div className="flex flex-col items-start gap-1 typography-micro text-muted-foreground sm:flex-row sm:flex-wrap sm:items-center sm:gap-x-1 sm:gap-y-1">
|
|
<span className="text-foreground whitespace-nowrap">
|
|
{comment.authorName}
|
|
{comment.authorLogin && comment.authorLogin !== comment.authorName ? ` · @${comment.authorLogin}` : ''}
|
|
</span>
|
|
{comment.createdAt ? <span className="whitespace-nowrap">{formatTimestamp(comment.createdAt)}</span> : null}
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 px-0 has-[>svg]:px-0 sm:px-2 sm:has-[>svg]:px-2.5 text-[var(--status-success)] hover:bg-[var(--status-success-background)] hover:text-[var(--status-success)] justify-start"
|
|
onClick={() => {
|
|
void sendSingleCommentToChat(comment);
|
|
}}
|
|
aria-label={t('gitView.pr.actions.sendCommentToAgentAria')}
|
|
>
|
|
<Icon name="ai-generate-2" className="size-3.5" />
|
|
{t('gitView.pr.actions.sendToAgent')}
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent><p>{t('gitView.pr.actions.sendCommentToAgent')}</p></TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
<div className="typography-micro text-muted-foreground">
|
|
{comment.context}
|
|
{comment.path ? ` · ${comment.path}` : ''}
|
|
{comment.line ? `:${comment.line}` : ''}
|
|
</div>
|
|
<SimpleMarkdownRenderer
|
|
content={linkifyMentionsMarkdown(comment.body)}
|
|
className={[
|
|
'typography-markdown-body text-foreground break-words [&_a]:no-underline [&_a:hover]:no-underline',
|
|
selfMentionHighlightClass,
|
|
].filter(Boolean).join(' ')}
|
|
enableFileReferences={false}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
) : isLoadingPrContext && !prContext ? (
|
|
<div className="flex items-center justify-center gap-2 py-6 typography-micro text-muted-foreground">
|
|
<Icon name="loader-4" className="size-4 animate-spin" />
|
|
{t('gitView.loading.loading')}
|
|
</div>
|
|
) : (
|
|
<div className="py-6 text-center typography-micro text-muted-foreground">{t('gitView.pr.comments.empty')}</div>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col gap-3">
|
|
{pr && isHistoricalPr ? (
|
|
<div className="flex min-w-0 items-center gap-2 rounded-md border border-border/60 bg-surface-muted/40 px-2.5 py-2">
|
|
<Icon
|
|
name={pr.state === 'merged' ? 'git-merge' : 'git-close-pull-request'}
|
|
className="size-4 shrink-0"
|
|
style={{ color: prColorVar }}
|
|
/>
|
|
<div className="min-w-0 flex-1 typography-micro text-muted-foreground">
|
|
{pr.state === 'merged'
|
|
? t('gitView.pr.history.merged', { number: pr.number, base: pr.base || targetBaseBranch })
|
|
: t('gitView.pr.history.closed', { number: pr.number })}
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="xs"
|
|
className="shrink-0"
|
|
onClick={() => void openExternal(pr.url)}
|
|
aria-label={t('gitView.pr.actions.openOnGitHubAria')}
|
|
>
|
|
<Icon name="external-link" className="size-3.5" />
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="min-w-0">
|
|
<div className="typography-ui-label text-foreground">{t('gitView.pr.createTitle')}</div>
|
|
<div className="typography-micro text-muted-foreground truncate">
|
|
{branch} <span className="opacity-60">(local)</span> → {targetBaseBranch} <span className="opacity-60">({useDetectedUpstream && detectedUpstream ? 'upstream' : 'remote'})</span>
|
|
</div>
|
|
</div>
|
|
{repoUrl ? (
|
|
<Button variant="outline" size="sm" asChild>
|
|
<a href={repoUrl} target="_blank" rel="noopener noreferrer">
|
|
<Icon name="external-link" className="size-4" />
|
|
{t('gitView.pr.actions.repo')}
|
|
</a>
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
|
|
<label className="space-y-1">
|
|
<div className="typography-micro text-muted-foreground">{t('gitView.pr.field.title')}</div>
|
|
<Input
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
placeholder={t('gitView.pr.placeholder.title')}
|
|
autoCorrect={hasTouchInput ? "on" : "off"}
|
|
autoCapitalize={hasTouchInput ? "sentences" : "off"}
|
|
spellCheck={hasTouchInput}
|
|
/>
|
|
</label>
|
|
|
|
<label className="space-y-1">
|
|
<div className="typography-micro text-muted-foreground">{t('gitView.pr.field.baseBranch')}</div>
|
|
{availableBaseBranches.length > 0 ? (
|
|
<Select value={targetBaseBranch} onValueChange={setTargetBaseBranch}>
|
|
<SelectTrigger size="lg">
|
|
<SelectValue placeholder={t('gitView.pr.placeholder.selectBaseBranch')} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{availableBaseBranches.map((candidate) => (
|
|
<SelectItem key={candidate} value={candidate}>{candidate}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
) : (
|
|
<Input
|
|
value={targetBaseBranch}
|
|
onChange={(e) => setTargetBaseBranch(e.target.value)}
|
|
placeholder={t('gitView.pr.placeholder.main')}
|
|
/>
|
|
)}
|
|
</label>
|
|
|
|
<label className="space-y-1">
|
|
<div className="typography-micro text-muted-foreground">{t('gitView.pr.field.description')}</div>
|
|
<Textarea
|
|
value={body}
|
|
onChange={(e) => setBody(e.target.value)}
|
|
className="min-h-[110px]"
|
|
placeholder={t('gitView.pr.placeholder.whatChanged')}
|
|
autoCorrect={hasTouchInput ? "on" : "off"}
|
|
autoCapitalize={hasTouchInput ? "sentences" : "off"}
|
|
spellCheck={hasTouchInput}
|
|
/>
|
|
</label>
|
|
|
|
<div
|
|
className="flex items-center gap-2 cursor-pointer"
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-pressed={draft}
|
|
onClick={() => setDraft((v) => !v)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === ' ' || e.key === 'Enter') {
|
|
e.preventDefault();
|
|
setDraft((v) => !v);
|
|
}
|
|
}}
|
|
>
|
|
<Checkbox
|
|
size="sm"
|
|
checked={draft}
|
|
onChange={(next) => setDraft(next)}
|
|
ariaLabel={t('gitView.pr.actions.toggleDraftAria')}
|
|
/>
|
|
<span className="typography-ui-label text-foreground select-none">{t('gitView.pr.field.draft')}</span>
|
|
</div>
|
|
|
|
{/* Additional Context Section */}
|
|
{isMobile ? (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="typography-micro text-muted-foreground">
|
|
{t('gitView.pr.additionalContext.optional')}
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setIsContextSheetOpen(true)}
|
|
>
|
|
{additionalContext.trim() ? t('gitView.pr.actions.edit') : t('gitView.pr.actions.add')}
|
|
</Button>
|
|
</div>
|
|
{additionalContext.trim() && (
|
|
<div className="flex items-center gap-2">
|
|
<span className="inline-flex items-center rounded-full bg-[var(--interactive-selection)] px-2 py-0.5 text-xs text-[var(--interactive-selection-foreground)]">
|
|
{t('gitView.pr.additionalContext.added')}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<Collapsible open={isContextOpen} onOpenChange={setIsContextOpen}>
|
|
<CollapsibleTrigger className="flex w-full items-center justify-between rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-3 py-2 hover:bg-[var(--interactive-hover)]">
|
|
<span className="typography-micro text-muted-foreground">
|
|
{t('gitView.pr.additionalContext.optional')}
|
|
</span>
|
|
<span className="typography-micro text-[var(--primary-base)]">
|
|
{isContextOpen ? t('gitView.pr.actions.hide') : additionalContext.trim() ? t('gitView.pr.actions.edit') : t('gitView.pr.actions.add')}
|
|
</span>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<div className="mt-2 space-y-2 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-3">
|
|
<Textarea
|
|
value={additionalContext}
|
|
onChange={(e) => setAdditionalContext(e.target.value)}
|
|
className="min-h-[100px] bg-transparent"
|
|
placeholder={t('gitView.pr.placeholder.additionalContext')}
|
|
/>
|
|
<p className="typography-micro text-muted-foreground">
|
|
{t('gitView.pr.additionalContext.hint')}
|
|
</p>
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
)}
|
|
|
|
{/* Mobile Sheet for Context */}
|
|
<MobileOverlayPanel
|
|
open={isContextSheetOpen}
|
|
onClose={() => setIsContextSheetOpen(false)}
|
|
title={t('gitView.pr.additionalContext.title')}
|
|
footer={
|
|
<Button
|
|
size="sm"
|
|
onClick={() => setIsContextSheetOpen(false)}
|
|
className="w-full"
|
|
>
|
|
{t('gitView.common.done')}
|
|
</Button>
|
|
}
|
|
>
|
|
<div className="space-y-3">
|
|
<Textarea
|
|
value={additionalContext}
|
|
onChange={(e) => setAdditionalContext(e.target.value)}
|
|
className="min-h-[200px] bg-transparent"
|
|
placeholder={t('gitView.pr.placeholder.additionalContext')}
|
|
autoFocus
|
|
/>
|
|
<p className="typography-micro text-muted-foreground">
|
|
{t('gitView.pr.additionalContext.hint')}
|
|
</p>
|
|
</div>
|
|
</MobileOverlayPanel>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={generateDescription}
|
|
disabled={isGenerating || isCreating}
|
|
>
|
|
{isGenerating ? <Icon name="loader-4" className="size-4 animate-spin" /> : <Icon name="ai-generate-2" className="size-4 text-primary" />}
|
|
{t('gitView.commit.generate')}
|
|
</Button>
|
|
<div className="flex-1" />
|
|
<Button
|
|
size="sm"
|
|
className="min-w-[7.5rem] justify-center gap-2"
|
|
onClick={createPr}
|
|
disabled={isCreating || !isConnected || !targetBaseBranch.trim() || (!useDetectedUpstream && targetBaseBranch.trim() === branch)}
|
|
>
|
|
<span className="inline-flex size-4 items-center justify-center">
|
|
{isCreating ? <Icon name="loader-4" className="size-4 animate-spin" /> : <Icon name="git-pull-request" className="size-4" />}
|
|
</span>
|
|
<span>{t('gitView.pr.actions.createPr')}</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
</section>
|
|
);
|
|
};
|