feat: reorganize git view layout and add history/branch actions (#354)

* feat(ui): redesigned Git view layout

* feat: stabilize git views layout with min-h-0 and scroll

- Introduce min-h-0 and flex-1 on git layout containers
- Apply min-h-0 on PR checks dialog content and related areas
- Configure ScrollableOverlay to disable horizontal scroll and overscroll
This commit is contained in:
Bohdan Triapitsyn
2026-02-08 12:16:39 +02:00
committed by GitHub
parent b432437b02
commit 0a425cd882
14 changed files with 770 additions and 509 deletions
@@ -14,6 +14,7 @@ interface AnimatedTabsProps<T extends string> {
className?: string; className?: string;
isInteractive?: boolean; isInteractive?: boolean;
animate?: boolean; animate?: boolean;
collapseLabelsOnSmall?: boolean;
} }
export function AnimatedTabs<T extends string>({ export function AnimatedTabs<T extends string>({
@@ -23,6 +24,7 @@ export function AnimatedTabs<T extends string>({
className, className,
isInteractive = true, isInteractive = true,
animate = true, animate = true,
collapseLabelsOnSmall = false,
}: AnimatedTabsProps<T>) { }: AnimatedTabsProps<T>) {
const containerRef = React.useRef<HTMLDivElement>(null); const containerRef = React.useRef<HTMLDivElement>(null);
const activeTabRef = React.useRef<HTMLButtonElement>(null); const activeTabRef = React.useRef<HTMLButtonElement>(null);
@@ -73,10 +75,15 @@ export function AnimatedTabs<T extends string>({
return ( return (
<div <div
key={tab.value} key={tab.value}
className="flex h-7 flex-1 items-center justify-center gap-1.25 rounded-lg px-2.5 text-sm font-semibold" className={cn(
'flex h-7 flex-1 items-center justify-center rounded-lg px-2.5 text-sm font-semibold',
collapseLabelsOnSmall ? 'gap-0 sm:gap-1.25' : 'gap-1.25'
)}
> >
{Icon ? <Icon className="h-4 w-4" /> : null} {Icon ? <Icon className="h-4 w-4" /> : null}
<span className="truncate">{tab.label}</span> <span className={cn('truncate', collapseLabelsOnSmall ? 'hidden sm:inline' : null)}>
{tab.label}
</span>
</div> </div>
); );
@@ -99,11 +106,13 @@ export function AnimatedTabs<T extends string>({
onValueChange(tab.value); onValueChange(tab.value);
}} }}
className={cn( className={cn(
'flex h-7 flex-1 items-center justify-center gap-1.25 rounded-lg px-2.5 text-sm font-semibold transition-colors duration-150', 'flex h-7 flex-1 items-center justify-center rounded-lg px-2.5 text-sm font-semibold transition-colors duration-150',
collapseLabelsOnSmall ? 'gap-0 sm:gap-1.25' : 'gap-1.25',
isActive ? 'text-accent-foreground' : 'text-muted-foreground', isActive ? 'text-accent-foreground' : 'text-muted-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)] focus-visible:ring-offset-1 focus-visible:ring-offset-background' 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)] focus-visible:ring-offset-1 focus-visible:ring-offset-background'
)} )}
aria-pressed={isActive} aria-pressed={isActive}
aria-label={tab.label}
aria-disabled={!isInteractive} aria-disabled={!isInteractive}
tabIndex={isInteractive ? 0 : -1} tabIndex={isInteractive ? 0 : -1}
> >
@@ -112,7 +121,9 @@ export function AnimatedTabs<T extends string>({
className={cn('h-4 w-4', isActive ? 'text-accent-foreground' : 'text-muted-foreground')} className={cn('h-4 w-4', isActive ? 'text-accent-foreground' : 'text-muted-foreground')}
/> />
) : null} ) : null}
<span className="truncate">{tab.label}</span> <span className={cn('truncate', collapseLabelsOnSmall ? 'hidden sm:inline' : null)}>
{tab.label}
</span>
</button> </button>
); );
+191 -70
View File
@@ -15,11 +15,20 @@ import {
useIsGitRepo, useIsGitRepo,
} from '@/stores/useGitStore'; } from '@/stores/useGitStore';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { RiGitBranchLine, RiLoader4Line } from '@remixicon/react'; import {
RiGitBranchLine,
RiGitMergeLine,
RiGitCommitLine,
RiGitPullRequestLine,
RiLoader4Line,
RiSplitCellsHorizontal,
} from '@remixicon/react';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
import { AnimatedTabs } from '@/components/ui/animated-tabs';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
@@ -46,7 +55,7 @@ import { PullRequestSection } from './git/PullRequestSection';
import { ConflictDialog } from './git/ConflictDialog'; import { ConflictDialog } from './git/ConflictDialog';
import { StashDialog } from './git/StashDialog'; import { StashDialog } from './git/StashDialog';
import { InProgressOperationBanner } from './git/InProgressOperationBanner'; import { InProgressOperationBanner } from './git/InProgressOperationBanner';
import type { OperationLogEntry } from './git/BranchIntegrationSection'; import { BranchIntegrationSection, type OperationLogEntry } from './git/BranchIntegrationSection';
import type { GitRemote } from '@/lib/gitApi'; import type { GitRemote } from '@/lib/gitApi';
import { BranchPickerDialog } from '@/components/session/BranchPickerDialog'; import { BranchPickerDialog } from '@/components/session/BranchPickerDialog';
import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
@@ -54,6 +63,7 @@ import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
type SyncAction = 'fetch' | 'pull' | 'push' | null; type SyncAction = 'fetch' | 'pull' | 'push' | null;
type CommitAction = 'commit' | 'commitAndPush' | null; type CommitAction = 'commit' | 'commitAndPush' | null;
type BranchOperation = 'merge' | 'rebase' | null; type BranchOperation = 'merge' | 'rebase' | null;
type ActionTab = 'commit' | 'branch' | 'pr' | 'worktree';
type GitViewSnapshot = { type GitViewSnapshot = {
@@ -235,8 +245,6 @@ export const GitView: React.FC = () => {
const [isBranchPickerOpen, setIsBranchPickerOpen] = React.useState(false); const [isBranchPickerOpen, setIsBranchPickerOpen] = React.useState(false);
const [rootBranchHint, setRootBranchHint] = React.useState<string | null>(null); const [rootBranchHint, setRootBranchHint] = React.useState<string | null>(null);
const baseBranch = worktreeMetadata?.createdFromBranch || status?.current || 'HEAD';
React.useEffect(() => { React.useEffect(() => {
const projectRoot = worktreeMetadata?.projectDirectory; const projectRoot = worktreeMetadata?.projectDirectory;
if (!projectRoot) { if (!projectRoot) {
@@ -363,6 +371,8 @@ export const GitView: React.FC = () => {
const [remoteUrl, setRemoteUrl] = React.useState<string | null>(null); const [remoteUrl, setRemoteUrl] = React.useState<string | null>(null);
const [gitmojiEmojis, setGitmojiEmojis] = React.useState<GitmojiEntry[]>([]); const [gitmojiEmojis, setGitmojiEmojis] = React.useState<GitmojiEntry[]>([]);
const [gitmojiSearch, setGitmojiSearch] = React.useState(''); const [gitmojiSearch, setGitmojiSearch] = React.useState('');
const [isHistoryDialogOpen, setIsHistoryDialogOpen] = React.useState(false);
const [actionTab, setActionTab] = React.useState<ActionTab>('commit');
const [remotes, setRemotes] = React.useState<GitRemote[]>([]); const [remotes, setRemotes] = React.useState<GitRemote[]>([]);
const [branchOperation, setBranchOperation] = React.useState<BranchOperation>(null); const [branchOperation, setBranchOperation] = React.useState<BranchOperation>(null);
const [operationLogs, setOperationLogs] = React.useState<OperationLogEntry[]>([]); const [operationLogs, setOperationLogs] = React.useState<OperationLogEntry[]>([]);
@@ -915,6 +925,21 @@ export const GitView: React.FC = () => {
.sort(); .sort();
}, [branches]); }, [branches]);
const baseBranch = React.useMemo(() => {
const fromMeta = typeof worktreeMetadata?.createdFromBranch === 'string'
? worktreeMetadata.createdFromBranch.trim()
: '';
if (fromMeta && fromMeta !== 'HEAD') return fromMeta;
const fromHint = typeof rootBranchHint === 'string' ? rootBranchHint.trim() : '';
if (fromHint && fromHint !== 'HEAD') return fromHint;
if (localBranches.includes('main')) return 'main';
if (localBranches.includes('master')) return 'master';
if (localBranches.includes('develop')) return 'develop';
return 'main';
}, [localBranches, rootBranchHint, worktreeMetadata?.createdFromBranch]);
const availableIdentities = React.useMemo(() => { const availableIdentities = React.useMemo(() => {
const unique = new Map<string, GitIdentityProfile>(); const unique = new Map<string, GitIdentityProfile>();
if (globalIdentity) { if (globalIdentity) {
@@ -998,6 +1023,29 @@ export const GitView: React.FC = () => {
const selectedCount = selectedPaths.size; const selectedCount = selectedPaths.size;
const isBusy = isLoading || syncAction !== null || commitAction !== null; const isBusy = isLoading || syncAction !== null || commitAction !== null;
const hasChanges = uniqueChangeCount > 0; const hasChanges = uniqueChangeCount > 0;
const canShowIntegrateCommitsSection = Boolean(
worktreeMetadata && repoRootForIntegrate && sourceBranchForIntegrate && shouldShowIntegrateCommits
);
const canShowPullRequestSection = Boolean(
currentDirectory && status?.current && status?.tracking && status.current !== baseBranch
);
const canShowBranchWorkflows = Boolean(status?.current);
const integrateCommitsProps =
canShowIntegrateCommitsSection && repoRootForIntegrate && sourceBranchForIntegrate && worktreeMetadata
? {
repoRoot: repoRootForIntegrate,
sourceBranch: sourceBranchForIntegrate,
worktreeMetadata,
}
: null;
const pullRequestProps =
canShowPullRequestSection && currentDirectory && status?.current
? {
directory: currentDirectory,
branch: status.current,
}
: null;
// Keep these sections stable in layout; individual cards render placeholders when unavailable.
const toggleFileSelection = (path: string) => { const toggleFileSelection = (path: string) => {
setSelectedPaths((previous) => { setSelectedPaths((previous) => {
@@ -1484,12 +1532,7 @@ export const GitView: React.FC = () => {
onSelectIdentity={handleApplyIdentity} onSelectIdentity={handleApplyIdentity}
isApplyingIdentity={isSettingIdentity} isApplyingIdentity={isSettingIdentity}
isWorktreeMode={!!worktreeMetadata} isWorktreeMode={!!worktreeMetadata}
onMerge={handleMerge} onOpenHistory={() => setIsHistoryDialogOpen(true)}
onRebase={handleRebase}
branchOperation={branchOperation}
operationLogs={operationLogs}
onOperationComplete={handleOperationComplete}
isBusy={isBusy}
onOpenBranchPicker={branchPickerProject ? () => setIsBranchPickerOpen(true) : undefined} onOpenBranchPicker={branchPickerProject ? () => setIsBranchPickerOpen(true) : undefined}
/> />
@@ -1509,12 +1552,12 @@ export const GitView: React.FC = () => {
/> />
)} )}
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="p-3"> <div className="flex-1 min-h-0 overflow-hidden">
<div className="flex flex-col gap-3"> <div className="h-full min-h-0 grid grid-cols-1 xl:grid-cols-[minmax(520px,1fr)_480px]">
{/* Two-column layout on large screens: Changes + Commit */} <div className="min-w-0 min-h-0 h-full flex flex-col">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{hasChanges ? ( {hasChanges ? (
<ChangesSection <ChangesSection
variant="plain"
changeEntries={changeEntries} changeEntries={changeEntries}
selectedPaths={selectedPaths} selectedPaths={selectedPaths}
diffStats={status?.diffStats} diffStats={status?.diffStats}
@@ -1526,7 +1569,7 @@ export const GitView: React.FC = () => {
onRevertFile={handleRevertFile} onRevertFile={handleRevertFile}
/> />
) : ( ) : (
<div className="lg:col-span-2 flex justify-center"> <div className="flex-1 min-h-0 flex items-center justify-center px-6">
<GitEmptyState <GitEmptyState
behind={status?.behind ?? 0} behind={status?.behind ?? 0}
onPull={() => { onPull={() => {
@@ -1540,66 +1583,144 @@ export const GitView: React.FC = () => {
/> />
</div> </div>
)} )}
{changeEntries.length > 0 && (
<CommitSection
selectedCount={selectedCount}
commitMessage={commitMessage}
onCommitMessageChange={setCommitMessage}
generatedHighlights={generatedHighlights}
onInsertHighlights={handleInsertHighlights}
onClearHighlights={clearGeneratedHighlights}
onGenerateMessage={handleGenerateCommitMessage}
isGeneratingMessage={isGeneratingMessage}
onCommit={() => handleCommit({ pushAfter: false })}
onCommitAndPush={() => handleCommit({ pushAfter: true })}
commitAction={commitAction}
isBusy={isBusy}
gitmojiEnabled={settingsGitmojiEnabled}
onOpenGitmojiPicker={() => setIsGitmojiPickerOpen(true)}
/>
)}
</div> </div>
{worktreeMetadata && repoRootForIntegrate && sourceBranchForIntegrate && shouldShowIntegrateCommits ? ( <div className="min-w-0 min-h-0 h-full border-t xl:border-t-0 xl:border-l border-border/40 bg-muted/10 flex flex-col">
<IntegrateCommitsSection <div className="px-3 py-3">
repoRoot={repoRootForIntegrate} <AnimatedTabs<ActionTab>
sourceBranch={sourceBranchForIntegrate} value={actionTab}
worktreeMetadata={worktreeMetadata} onValueChange={setActionTab}
localBranches={localBranches} collapseLabelsOnSmall
defaultTargetBranch={defaultTargetBranch} tabs={[
refreshKey={integrateRefreshKey} { value: 'commit', label: 'Commit', icon: RiGitCommitLine },
onRefresh={() => { { value: 'branch', label: 'Update branch', icon: RiGitMergeLine },
if (!currentDirectory) return; { value: 'pr', label: 'PR', icon: RiGitPullRequestLine },
fetchStatus(currentDirectory, git); { value: 'worktree', label: 'Worktree', icon: RiSplitCellsHorizontal },
fetchBranches(currentDirectory, git); ]}
fetchLog(currentDirectory, git, logMaxCountLocal); />
}} </div>
/> <div className="h-px bg-border/40" />
) : null}
{currentDirectory && status?.current && status?.tracking ? ( <ScrollableOverlay
<PullRequestSection outerClassName="flex-1 min-h-0"
directory={currentDirectory} className="px-4 py-4"
branch={status.current} disableHorizontal
baseBranch={baseBranch} preventOverscroll
/> >
) : null} {actionTab === 'commit' ? (
<CommitSection
variant="plain"
selectedCount={selectedCount}
commitMessage={commitMessage}
onCommitMessageChange={setCommitMessage}
generatedHighlights={generatedHighlights}
onInsertHighlights={handleInsertHighlights}
onClearHighlights={clearGeneratedHighlights}
onGenerateMessage={handleGenerateCommitMessage}
isGeneratingMessage={isGeneratingMessage}
onCommit={() => handleCommit({ pushAfter: false })}
onCommitAndPush={() => handleCommit({ pushAfter: true })}
commitAction={commitAction}
isBusy={isBusy}
gitmojiEnabled={settingsGitmojiEnabled}
onOpenGitmojiPicker={() => setIsGitmojiPickerOpen(true)}
/>
) : null}
{/* History below, constrained width */} {actionTab === 'branch' ? (
<HistorySection <div className="space-y-4">
log={log} {canShowBranchWorkflows ? (
isLogLoading={isLogLoading} <BranchIntegrationSection
logMaxCount={logMaxCountLocal} mode="inline"
onLogMaxCountChange={handleLogMaxCountChange} currentBranch={status?.current}
expandedCommitHashes={expandedCommitHashes} localBranches={localBranches}
onToggleCommit={handleToggleCommit} remoteBranches={remoteBranches}
commitFilesMap={commitFilesMap} onMerge={handleMerge}
loadingCommitHashes={loadingCommitHashes} onRebase={handleRebase}
onCopyHash={handleCopyCommitHash} disabled={isBusy}
/> isOperating={branchOperation !== null}
operationLogs={operationLogs}
onOperationComplete={handleOperationComplete}
/>
) : (
<p className="typography-meta text-muted-foreground">Branch actions unavailable.</p>
)}
</div>
) : null}
{actionTab === 'worktree' ? (
integrateCommitsProps ? (
<IntegrateCommitsSection
variant="plain"
repoRoot={integrateCommitsProps.repoRoot}
sourceBranch={integrateCommitsProps.sourceBranch}
worktreeMetadata={integrateCommitsProps.worktreeMetadata}
localBranches={localBranches}
defaultTargetBranch={defaultTargetBranch}
refreshKey={integrateRefreshKey}
onRefresh={() => {
if (!currentDirectory) return;
fetchStatus(currentDirectory, git);
fetchBranches(currentDirectory, git);
fetchLog(currentDirectory, git, logMaxCountLocal);
}}
/>
) : (
<div className="space-y-1">
<div className="typography-ui-header font-semibold text-foreground">Re-integrate commits</div>
<div className="typography-micro text-muted-foreground">
Available in worktree mode.
</div>
</div>
)
) : null}
{actionTab === 'pr' ? (
pullRequestProps ? (
<PullRequestSection
variant="plain"
directory={pullRequestProps.directory}
branch={pullRequestProps.branch}
baseBranch={baseBranch}
/>
) : (
<div className="space-y-1">
<div className="typography-ui-header font-semibold text-foreground">Pull Request</div>
<div className="typography-micro text-muted-foreground">
Push a non-base branch (with upstream) to create a PR.
</div>
</div>
)
) : null}
</ScrollableOverlay>
</div>
</div> </div>
</ScrollableOverlay> </div>
<Dialog open={isHistoryDialogOpen} onOpenChange={setIsHistoryDialogOpen}>
<DialogContent className="max-w-5xl max-h-[80vh] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>History</DialogTitle>
<DialogDescription>
Browse recent commits and inspect file-level changes.
</DialogDescription>
</DialogHeader>
<div className="flex-1 min-h-0">
<HistorySection
log={log}
isLogLoading={isLogLoading}
logMaxCount={logMaxCountLocal}
onLogMaxCountChange={handleLogMaxCountChange}
expandedCommitHashes={expandedCommitHashes}
onToggleCommit={handleToggleCommit}
commitFilesMap={commitFilesMap}
loadingCommitHashes={loadingCommitHashes}
onCopyHash={handleCopyCommitHash}
showHeader={false}
/>
</div>
</DialogContent>
</Dialog>
<Dialog open={isGitmojiPickerOpen} onOpenChange={setIsGitmojiPickerOpen}> <Dialog open={isGitmojiPickerOpen} onOpenChange={setIsGitmojiPickerOpen}>
<DialogContent className="max-w-md p-0 overflow-hidden"> <DialogContent className="max-w-md p-0 overflow-hidden">
@@ -51,6 +51,7 @@ interface BranchIntegrationSectionProps {
isOperating?: boolean; isOperating?: boolean;
operationLogs?: OperationLogEntry[]; operationLogs?: OperationLogEntry[];
onOperationComplete?: () => void; onOperationComplete?: () => void;
mode?: 'dialog' | 'inline';
} }
export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> = ({ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> = ({
@@ -63,6 +64,7 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
isOperating = false, isOperating = false,
operationLogs = [], operationLogs = [],
onOperationComplete, onOperationComplete,
mode = 'dialog',
}) => { }) => {
const [dialogOpen, setDialogOpen] = React.useState(false); const [dialogOpen, setDialogOpen] = React.useState(false);
const [operation, setOperation] = React.useState<OperationType>('merge'); const [operation, setOperation] = React.useState<OperationType>('merge');
@@ -73,6 +75,7 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
const logContainerRef = React.useRef<HTMLDivElement>(null); const logContainerRef = React.useRef<HTMLDivElement>(null);
const isDisabled = disabled || isOperating; const isDisabled = disabled || isOperating;
const targetBranchLabel = currentBranch || 'current branch';
// Check if operation completed (all logs are done or error) // Check if operation completed (all logs are done or error)
const operationCompleted = operationLogs.length > 0 && const operationCompleted = operationLogs.length > 0 &&
@@ -159,12 +162,260 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
} }
}, [branchDropdownOpen]); }, [branchDropdownOpen]);
const renderOperating = () => (
<div className="space-y-3">
<div
ref={logContainerRef}
className="rounded-lg border border-border bg-muted/30 p-3 max-h-48 overflow-y-auto"
>
<div className="space-y-2">
{operationLogs.map((log, index) => (
<div key={index} className="flex items-start gap-2">
<div className="mt-0.5 shrink-0">
{log.status === 'running' && (
<RiLoader4Line className="size-3.5 animate-spin text-primary" />
)}
{log.status === 'done' && (
<RiCheckLine className="size-3.5 text-success" />
)}
{log.status === 'error' && (
<RiCloseLine className="size-3.5 text-destructive" />
)}
{log.status === 'pending' && (
<div className="size-3.5 rounded-full border border-muted-foreground/30" />
)}
</div>
<span
className={cn(
'typography-micro',
log.status === 'error' && 'text-destructive',
log.status === 'done' && 'text-muted-foreground',
log.status === 'running' && 'text-foreground',
log.status === 'pending' && 'text-muted-foreground/60'
)}
>
{log.message}
</span>
</div>
))}
</div>
</div>
{operationCompleted ? (
mode === 'dialog' ? (
<DialogFooter>
<Button variant="default" size="sm" onClick={handleClose}>
{hasError ? 'Close' : 'Done'}
</Button>
</DialogFooter>
) : (
<div className="flex justify-end">
<Button variant="default" size="sm" onClick={handleClose}>
{hasError ? 'Close' : 'Done'}
</Button>
</div>
)
) : null}
</div>
);
const renderForm = () => (
<>
{/* Operation Selection */}
<div className="space-y-3">
<p className="typography-meta text-muted-foreground">Operation</p>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setOperation('merge')}
className={cn(
'flex flex-col items-start gap-1 rounded-lg border p-3 text-left transition-colors',
operation === 'merge'
? 'border-primary bg-primary/5'
: 'border-border hover:border-border/80 hover:bg-muted/50'
)}
>
<div className="flex items-center gap-2">
<RiGitMergeLine
className={cn('size-4', operation === 'merge' ? 'text-primary' : 'text-muted-foreground')}
/>
<span
className={cn(
'typography-ui-label',
operation === 'merge' ? 'text-foreground' : 'text-muted-foreground'
)}
>
Merge
</span>
</div>
<p className="typography-micro text-muted-foreground">
Combines branches with a merge commit and preserves history.
</p>
</button>
<button
type="button"
onClick={() => setOperation('rebase')}
className={cn(
'flex flex-col items-start gap-1 rounded-lg border p-3 text-left transition-colors',
operation === 'rebase'
? 'border-primary bg-primary/5'
: 'border-border hover:border-border/80 hover:bg-muted/50'
)}
>
<div className="flex items-center gap-2">
<RiGitBranchLine
className={cn('size-4', operation === 'rebase' ? 'text-primary' : 'text-muted-foreground')}
/>
<span
className={cn(
'typography-ui-label',
operation === 'rebase' ? 'text-foreground' : 'text-muted-foreground'
)}
>
Rebase
</span>
</div>
<p className="typography-micro text-muted-foreground">
Moves your commits to be on top of another branch. Creates linear history.
</p>
</button>
</div>
</div>
{/* Branch Selection */}
<div className="space-y-3">
<p className="typography-meta text-muted-foreground">
{operation === 'merge' ? `Branch to merge into ${targetBranchLabel}` : 'Branch to rebase onto'}
</p>
<DropdownMenu open={branchDropdownOpen} onOpenChange={setBranchDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="w-full justify-between h-10">
<span className={cn('truncate', !selectedBranch && 'text-muted-foreground')}>
{selectedBranch || 'Select a branch...'}
</span>
<RiArrowDownSLine className="size-4 opacity-60 shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[--radix-dropdown-menu-trigger-width] p-0 max-h-[300px]">
<Command>
<CommandInput
ref={searchInputRef}
placeholder="Search branches..."
value={branchSearch}
onValueChange={setBranchSearch}
/>
<CommandList>
<CommandEmpty>No branches found.</CommandEmpty>
{filteredLocal.length > 0 && (
<CommandGroup heading="Local branches">
{filteredLocal.map((branch) => (
<CommandItem key={`local-${branch}`} onSelect={() => handleSelectBranch(branch)}>
<span className="typography-ui-label text-foreground truncate">{branch}</span>
</CommandItem>
))}
</CommandGroup>
)}
{filteredLocal.length > 0 && filteredRemote.length > 0 ? <CommandSeparator /> : null}
{filteredRemote.length > 0 && (
<CommandGroup heading="Remote branches">
{filteredRemote.map((branch) => (
<CommandItem key={`remote-${branch}`} onSelect={() => handleSelectBranch(branch)}>
<span className="typography-ui-label text-foreground truncate">{branch}</span>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Summary */}
{selectedBranch ? (
<div className="rounded-lg bg-muted/50 p-3">
<p className="typography-meta text-muted-foreground">
{operation === 'merge' ? (
<>
This will merge <span className="font-mono text-foreground">{selectedBranch}</span> into{' '}
<span className="font-mono text-foreground">{targetBranchLabel}</span>
</>
) : (
<>
This will rebase <span className="font-mono text-foreground">{targetBranchLabel}</span> onto{' '}
<span className="font-mono text-foreground">{selectedBranch}</span>
</>
)}
</p>
</div>
) : null}
{mode === 'dialog' ? (
<DialogFooter className="gap-2">
<Button variant="ghost" size="sm" onClick={handleCancel}>
Cancel
</Button>
<Button
variant="default"
size="sm"
onClick={handleConfirm}
disabled={!selectedBranch}
className="gap-1.5"
>
{operation === 'merge' ? (
<>
<RiGitMergeLine className="size-4" />
Merge
</>
) : (
<>
<RiGitBranchLine className="size-4" />
Rebase
</>
)}
</Button>
</DialogFooter>
) : (
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={handleCancel} disabled={isDisabled}>
Reset
</Button>
<div className="flex-1" />
<Button variant="default" size="sm" onClick={handleConfirm} disabled={isDisabled || !selectedBranch}>
{operation === 'merge' ? 'Merge' : 'Rebase'}
</Button>
</div>
)}
</>
);
const body = isOperating ? renderOperating() : renderForm();
if (mode === 'inline') {
return (
<div className="space-y-4">
<div className="space-y-1">
<div className="typography-ui-header font-semibold text-foreground">Update branch</div>
<div className="typography-micro text-muted-foreground">
Bring changes from another branch into{' '}
<span className="font-mono text-foreground">{targetBranchLabel}</span>.
</div>
</div>
{body}
</div>
);
}
return ( return (
<> <>
<Tooltip delayDuration={1000}> <Tooltip delayDuration={1000}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant="ghost" variant="outline"
size="sm" size="sm"
className="h-8 px-2 gap-1.5" className="h-8 px-2 gap-1.5"
onClick={handleOpenDialog} onClick={handleOpenDialog}
@@ -175,11 +426,11 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
) : ( ) : (
<RiGitMergeLine className="size-4" /> <RiGitMergeLine className="size-4" />
)} )}
<span className="hidden sm:inline">Integrate</span> <span>Merge/Rebase</span>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent sideOffset={8}> <TooltipContent sideOffset={8}>
Merge or rebase another branch Merge or rebase changes from another branch.
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@@ -190,10 +441,10 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
setDialogOpen(true); setDialogOpen(true);
} }
}}> }}>
<DialogContent className="max-w-md"> <DialogContent className="max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>Integrate Branch</DialogTitle> <DialogTitle>Update Branch</DialogTitle>
<DialogDescription> <DialogDescription>
{isOperating ? ( {isOperating ? (
operationCompleted ? ( operationCompleted ? (
hasError ? 'Operation failed' : 'Operation completed' hasError ? 'Operation failed' : 'Operation completed'
@@ -202,248 +453,15 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
) )
) : ( ) : (
<> <>
Choose how to integrate changes from another branch into{' '} Choose how to bring changes from another branch into{' '}
<span className="font-mono text-foreground">{currentBranch || 'current branch'}</span> <span className="font-mono text-foreground">{targetBranchLabel}</span>
.
</> </>
)} )}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{/* Show operation log when operating */} {body}
{isOperating ? (
<div className="space-y-3">
<div
ref={logContainerRef}
className="rounded-lg border border-border bg-muted/30 p-3 max-h-48 overflow-y-auto"
>
<div className="space-y-2">
{operationLogs.map((log, index) => (
<div key={index} className="flex items-start gap-2">
<div className="mt-0.5 shrink-0">
{log.status === 'running' && (
<RiLoader4Line className="size-3.5 animate-spin text-primary" />
)}
{log.status === 'done' && (
<RiCheckLine className="size-3.5 text-success" />
)}
{log.status === 'error' && (
<RiCloseLine className="size-3.5 text-destructive" />
)}
{log.status === 'pending' && (
<div className="size-3.5 rounded-full border border-muted-foreground/30" />
)}
</div>
<span className={cn(
'typography-micro',
log.status === 'error' && 'text-destructive',
log.status === 'done' && 'text-muted-foreground',
log.status === 'running' && 'text-foreground',
log.status === 'pending' && 'text-muted-foreground/60'
)}>
{log.message}
</span>
</div>
))}
</div>
</div>
{operationCompleted && (
<DialogFooter>
<Button
variant="default"
size="sm"
onClick={handleClose}
>
{hasError ? 'Close' : 'Done'}
</Button>
</DialogFooter>
)}
</div>
) : (
<>
{/* Operation Selection */}
<div className="space-y-3">
<p className="typography-meta text-muted-foreground">Operation</p>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setOperation('merge')}
className={cn(
'flex flex-col items-start gap-1 rounded-lg border p-3 text-left transition-colors',
operation === 'merge'
? 'border-primary bg-primary/5'
: 'border-border hover:border-border/80 hover:bg-muted/50'
)}
>
<div className="flex items-center gap-2">
<RiGitMergeLine className={cn(
'size-4',
operation === 'merge' ? 'text-primary' : 'text-muted-foreground'
)} />
<span className={cn(
'typography-ui-label',
operation === 'merge' ? 'text-foreground' : 'text-muted-foreground'
)}>
Merge
</span>
</div>
<p className="typography-micro text-muted-foreground">
Combines branches with a merge commit. Preserves history.
</p>
</button>
<button
type="button"
onClick={() => setOperation('rebase')}
className={cn(
'flex flex-col items-start gap-1 rounded-lg border p-3 text-left transition-colors',
operation === 'rebase'
? 'border-primary bg-primary/5'
: 'border-border hover:border-border/80 hover:bg-muted/50'
)}
>
<div className="flex items-center gap-2">
<RiGitBranchLine className={cn(
'size-4',
operation === 'rebase' ? 'text-primary' : 'text-muted-foreground'
)} />
<span className={cn(
'typography-ui-label',
operation === 'rebase' ? 'text-foreground' : 'text-muted-foreground'
)}>
Rebase
</span>
</div>
<p className="typography-micro text-muted-foreground">
Replays commits on top. Creates linear history.
</p>
</button>
</div>
</div>
{/* Branch Selection */}
<div className="space-y-3">
<p className="typography-meta text-muted-foreground">
{operation === 'merge' ? 'Branch to merge' : 'Branch to rebase onto'}
</p>
<DropdownMenu open={branchDropdownOpen} onOpenChange={setBranchDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="w-full justify-between h-10"
>
<span className={cn(
'truncate',
!selectedBranch && 'text-muted-foreground'
)}>
{selectedBranch || 'Select a branch...'}
</span>
<RiArrowDownSLine className="size-4 opacity-60 shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[--radix-dropdown-menu-trigger-width] p-0 max-h-[300px]">
<Command>
<CommandInput
ref={searchInputRef}
placeholder="Search branches..."
value={branchSearch}
onValueChange={setBranchSearch}
/>
<CommandList>
<CommandEmpty>No branches found.</CommandEmpty>
{filteredLocal.length > 0 && (
<CommandGroup heading="Local branches">
{filteredLocal.map((branch) => (
<CommandItem
key={`local-${branch}`}
onSelect={() => handleSelectBranch(branch)}
>
<span className="typography-ui-label text-foreground truncate">
{branch}
</span>
</CommandItem>
))}
</CommandGroup>
)}
{filteredLocal.length > 0 && filteredRemote.length > 0 && (
<CommandSeparator />
)}
{filteredRemote.length > 0 && (
<CommandGroup heading="Remote branches">
{filteredRemote.map((branch) => (
<CommandItem
key={`remote-${branch}`}
onSelect={() => handleSelectBranch(branch)}
>
<span className="typography-ui-label text-foreground truncate">
{branch}
</span>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Summary */}
{selectedBranch && (
<div className="rounded-lg bg-muted/50 p-3">
<p className="typography-meta text-muted-foreground">
{operation === 'merge' ? (
<>
This will merge{' '}
<span className="font-mono text-foreground">{selectedBranch}</span>
{' '}into{' '}
<span className="font-mono text-foreground">{currentBranch}</span>
</>
) : (
<>
This will rebase{' '}
<span className="font-mono text-foreground">{currentBranch}</span>
{' '}onto{' '}
<span className="font-mono text-foreground">{selectedBranch}</span>
</>
)}
</p>
</div>
)}
<DialogFooter className="gap-2">
<Button
variant="ghost"
size="sm"
onClick={handleCancel}
>
Cancel
</Button>
<Button
variant="default"
size="sm"
onClick={handleConfirm}
disabled={!selectedBranch}
className="gap-1.5"
>
{operation === 'merge' ? (
<>
<RiGitMergeLine className="size-4" />
Merge
</>
) : (
<>
<RiGitBranchLine className="size-4" />
Rebase
</>
)}
</Button>
</DialogFooter>
</>
)}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</> </>
@@ -14,6 +14,7 @@ interface ChangesSectionProps {
onClearSelection: () => void; onClearSelection: () => void;
onViewDiff: (path: string) => void; onViewDiff: (path: string) => void;
onRevertFile: (path: string) => void; onRevertFile: (path: string) => void;
variant?: 'framed' | 'plain';
} }
export const ChangesSection: React.FC<ChangesSectionProps> = ({ export const ChangesSection: React.FC<ChangesSectionProps> = ({
@@ -26,13 +27,27 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
onClearSelection, onClearSelection,
onViewDiff, onViewDiff,
onRevertFile, onRevertFile,
variant = 'framed',
}) => { }) => {
const selectedCount = selectedPaths.size; const selectedCount = selectedPaths.size;
const totalCount = changeEntries.length; const totalCount = changeEntries.length;
const containerClassName =
variant === 'framed'
? 'flex flex-col rounded-xl border border-border/60 bg-background/70'
: 'flex flex-col flex-1 min-h-0';
const headerClassName =
variant === 'framed'
? 'flex items-center justify-between gap-2 px-3 py-2 border-b border-border/40'
: 'flex items-center justify-between gap-2 px-4 py-3 border-b border-border/40';
const scrollOuterClassName =
variant === 'framed'
? 'flex-1 min-h-0 max-h-[30vh]'
: 'flex-1 min-h-0';
return ( return (
<section className="flex flex-col rounded-xl border border-border/60 bg-background/70"> <section className={containerClassName}>
<header className="flex items-center justify-between gap-2 px-3 py-2 border-b border-border/40"> <header className={headerClassName}>
<h3 className="typography-ui-header font-semibold text-foreground">Changes</h3> <h3 className="typography-ui-header font-semibold text-foreground">Changes</h3>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="typography-meta text-muted-foreground"> <span className="typography-meta text-muted-foreground">
@@ -61,7 +76,7 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
)} )}
</div> </div>
</header> </header>
<ScrollableOverlay outerClassName="flex-1 min-h-0 max-h-[30vh]" className="w-full"> <ScrollableOverlay outerClassName={scrollOuterClassName} className="w-full">
<ul className="divide-y divide-border/60"> <ul className="divide-y divide-border/60">
{changeEntries.map((file) => ( {changeEntries.map((file) => (
<ChangeRow <ChangeRow
@@ -33,6 +33,7 @@ interface CommitSectionProps {
isBusy: boolean; isBusy: boolean;
gitmojiEnabled: boolean; gitmojiEnabled: boolean;
onOpenGitmojiPicker: () => void; onOpenGitmojiPicker: () => void;
variant?: 'framed' | 'plain';
} }
export const CommitSection: React.FC<CommitSectionProps> = ({ export const CommitSection: React.FC<CommitSectionProps> = ({
@@ -50,18 +51,32 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
isBusy, isBusy,
gitmojiEnabled, gitmojiEnabled,
onOpenGitmojiPicker, onOpenGitmojiPicker,
variant = 'framed',
}) => { }) => {
const hasSelectedFiles = selectedCount > 0; const hasSelectedFiles = selectedCount > 0;
const canCommit = commitMessage.trim() && hasSelectedFiles && commitAction === null; const canCommit = commitMessage.trim() && hasSelectedFiles && commitAction === null;
const { isMobile } = useDeviceInfo(); const { isMobile } = useDeviceInfo();
const containerClassName =
variant === 'framed'
? 'rounded-xl border border-border/60 bg-background/70 overflow-hidden'
: 'border-0 bg-transparent rounded-none';
const headerClassName =
variant === 'framed'
? 'flex w-full items-center justify-between px-3 py-2'
: 'flex w-full items-center justify-between px-4 py-3 border-b border-border/40';
const contentClassName =
variant === 'framed'
? 'flex flex-col gap-3 p-3 pt-0'
: 'flex flex-col gap-3 px-4 py-3';
return ( return (
<Collapsible <Collapsible
open={hasSelectedFiles} open={variant === 'plain' ? true : hasSelectedFiles}
className="rounded-xl border border-border/60 bg-background/70 overflow-hidden" className={containerClassName}
data-keyboard-avoid="true" data-keyboard-avoid="true"
> >
<div className="flex w-full items-center justify-between px-3 py-2"> <div className={headerClassName}>
<h3 className="typography-ui-header font-semibold text-foreground">Commit</h3> <h3 className="typography-ui-header font-semibold text-foreground">Commit</h3>
<span className="typography-meta text-muted-foreground"> <span className="typography-meta text-muted-foreground">
{hasSelectedFiles {hasSelectedFiles
@@ -71,7 +86,13 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
</div> </div>
<CollapsibleContent> <CollapsibleContent>
<div className="flex flex-col gap-3 p-3 pt-0"> <div className={contentClassName}>
{!hasSelectedFiles ? (
<p className="typography-meta text-muted-foreground">
Select files in Changes to enable commit.
</p>
) : null}
<AIHighlightsBox <AIHighlightsBox
highlights={generatedHighlights} highlights={generatedHighlights}
onInsert={onInsertHighlights} onInsert={onInsertHighlights}
@@ -223,7 +223,7 @@ Important:
{conflictDetails?.headInfo && ( {conflictDetails?.headInfo && (
<div className="space-y-1 overflow-hidden"> <div className="space-y-1 overflow-hidden">
<p className="typography-meta text-muted-foreground">Head information:</p> <p className="typography-meta text-muted-foreground">HEAD information:</p>
<div className="typography-micro text-foreground font-mono bg-[var(--surface-elevated)] rounded-lg p-3 max-h-24 overflow-y-auto break-words whitespace-pre-wrap"> <div className="typography-micro text-foreground font-mono bg-[var(--surface-elevated)] rounded-lg p-3 max-h-24 overflow-y-auto break-words whitespace-pre-wrap">
{conflictDetails.headInfo} {conflictDetails.headInfo}
</div> </div>
@@ -14,8 +14,8 @@ export const GitEmptyState: React.FC<GitEmptyStateProps> = ({
isPulling, isPulling,
}) => { }) => {
return ( return (
<div className="flex flex-col items-center justify-center py-12 px-4 text-center"> <div className="flex flex-col items-center justify-center py-10 px-4 text-center">
<RiGitCommitLine className="size-10 text-emerald-500/60 mb-4" /> <RiGitCommitLine className="size-10 text-muted-foreground/70 mb-4" />
<p className="typography-ui-label font-semibold text-foreground mb-1"> <p className="typography-ui-label font-semibold text-foreground mb-1">
Working tree clean Working tree clean
</p> </p>
@@ -5,12 +5,13 @@ import {
RiArrowDownSLine, RiArrowDownSLine,
RiLoader4Line, RiLoader4Line,
RiGitBranchLine, RiGitBranchLine,
RiGitRepositoryLine,
RiBriefcaseLine, RiBriefcaseLine,
RiHomeLine, RiHomeLine,
RiGraduationCapLine, RiGraduationCapLine,
RiCodeLine, RiCodeLine,
RiHeartLine, RiHeartLine,
RiGitRepositoryLine, RiHistoryLine,
RiUser3Line, RiUser3Line,
} from '@remixicon/react'; } from '@remixicon/react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -24,11 +25,9 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { BranchSelector } from './BranchSelector'; import { BranchSelector } from './BranchSelector';
import { WorktreeBranchDisplay } from './WorktreeBranchDisplay'; import { WorktreeBranchDisplay } from './WorktreeBranchDisplay';
import { SyncActions } from './SyncActions'; import { SyncActions } from './SyncActions';
import { BranchIntegrationSection, type OperationLogEntry } from './BranchIntegrationSection';
import type { GitStatus, GitIdentityProfile, GitRemote } from '@/lib/api/types'; import type { GitStatus, GitIdentityProfile, GitRemote } from '@/lib/api/types';
type SyncAction = 'fetch' | 'pull' | 'push' | null; type SyncAction = 'fetch' | 'pull' | 'push' | null;
type BranchOperation = 'merge' | 'rebase' | null;
interface GitHeaderProps { interface GitHeaderProps {
status: GitStatus | null; status: GitStatus | null;
@@ -48,13 +47,7 @@ interface GitHeaderProps {
onSelectIdentity: (profile: GitIdentityProfile) => void; onSelectIdentity: (profile: GitIdentityProfile) => void;
isApplyingIdentity: boolean; isApplyingIdentity: boolean;
isWorktreeMode: boolean; isWorktreeMode: boolean;
// Branch integration (merge/rebase) onOpenHistory?: () => void;
onMerge: (branch: string) => void;
onRebase: (branch: string) => void;
branchOperation: BranchOperation;
operationLogs: OperationLogEntry[];
onOperationComplete: () => void;
isBusy: boolean;
onOpenBranchPicker?: () => void; onOpenBranchPicker?: () => void;
} }
@@ -208,12 +201,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
onSelectIdentity, onSelectIdentity,
isApplyingIdentity, isApplyingIdentity,
isWorktreeMode, isWorktreeMode,
onMerge, onOpenHistory,
onRebase,
branchOperation,
operationLogs,
onOperationComplete,
isBusy,
onOpenBranchPicker, onOpenBranchPicker,
}) => { }) => {
if (!status) { if (!status) {
@@ -271,20 +259,6 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
disabled={!status} disabled={!status}
/> />
<div className="h-4 w-px bg-border/60" />
<BranchIntegrationSection
currentBranch={status?.current}
localBranches={localBranches}
remoteBranches={remoteBranches}
onMerge={onMerge}
onRebase={onRebase}
disabled={isBusy}
isOperating={branchOperation !== null}
operationLogs={operationLogs}
onOperationComplete={onOperationComplete}
/>
<div className="flex-1" /> <div className="flex-1" />
{onOpenBranchPicker ? ( {onOpenBranchPicker ? (
@@ -297,13 +271,30 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
onClick={onOpenBranchPicker} onClick={onOpenBranchPicker}
> >
<RiGitRepositoryLine className="size-4" /> <RiGitRepositoryLine className="size-4" />
<span className="hidden sm:inline">Manage Branches</span> <span className="hidden sm:inline">Manage branches</span>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent sideOffset={8}>Manage branches</TooltipContent> <TooltipContent sideOffset={8}>Manage branches</TooltipContent>
</Tooltip> </Tooltip>
) : null} ) : null}
{onOpenHistory ? (
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="gap-1.5 px-2 py-1 h-8 typography-ui-label"
onClick={onOpenHistory}
>
<RiHistoryLine className="size-4" />
<span className="hidden sm:inline">History</span>
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={8}>Show commit history</TooltipContent>
</Tooltip>
) : null}
<IdentityDropdown <IdentityDropdown
activeProfile={activeIdentityProfile} activeProfile={activeIdentityProfile}
identities={availableIdentities} identities={availableIdentities}
@@ -32,6 +32,7 @@ interface HistorySectionProps {
commitFilesMap: Map<string, CommitFileEntry[]>; commitFilesMap: Map<string, CommitFileEntry[]>;
loadingCommitHashes: Set<string>; loadingCommitHashes: Set<string>;
onCopyHash: (hash: string) => void; onCopyHash: (hash: string) => void;
showHeader?: boolean;
} }
export const HistorySection: React.FC<HistorySectionProps> = ({ export const HistorySection: React.FC<HistorySectionProps> = ({
@@ -44,6 +45,7 @@ export const HistorySection: React.FC<HistorySectionProps> = ({
commitFilesMap, commitFilesMap,
loadingCommitHashes, loadingCommitHashes,
onCopyHash, onCopyHash,
showHeader = true,
}) => { }) => {
const [isOpen, setIsOpen] = React.useState(true); const [isOpen, setIsOpen] = React.useState(true);
@@ -51,6 +53,40 @@ export const HistorySection: React.FC<HistorySectionProps> = ({
return null; return null;
} }
const content = (
<ScrollableOverlay outerClassName="min-h-0 max-h-[50vh]" className="w-full">
{log.all.length === 0 ? (
<div className="flex h-full items-center justify-center p-4">
<p className="typography-ui-label text-muted-foreground">
No commits found
</p>
</div>
) : (
<ul className="divide-y divide-border/60">
{log.all.map((entry) => (
<HistoryCommitRow
key={entry.hash}
entry={entry}
isExpanded={expandedCommitHashes.has(entry.hash)}
onToggle={() => onToggleCommit(entry.hash)}
files={commitFilesMap.get(entry.hash) ?? []}
isLoadingFiles={loadingCommitHashes.has(entry.hash)}
onCopyHash={onCopyHash}
/>
))}
</ul>
)}
</ScrollableOverlay>
);
if (!showHeader) {
return (
<section className="rounded-xl border border-border/60 bg-background/70 overflow-hidden">
{content}
</section>
);
}
return ( return (
<Collapsible <Collapsible
open={isOpen} open={isOpen}
@@ -95,31 +131,7 @@ export const HistorySection: React.FC<HistorySectionProps> = ({
</div> </div>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent> <CollapsibleContent>{content}</CollapsibleContent>
<ScrollableOverlay outerClassName="min-h-0 max-h-[50vh]" className="w-full">
{log.all.length === 0 ? (
<div className="flex h-full items-center justify-center p-4">
<p className="typography-ui-label text-muted-foreground">
No commits found
</p>
</div>
) : (
<ul className="divide-y divide-border/60">
{log.all.map((entry) => (
<HistoryCommitRow
key={entry.hash}
entry={entry}
isExpanded={expandedCommitHashes.has(entry.hash)}
onToggle={() => onToggleCommit(entry.hash)}
files={commitFilesMap.get(entry.hash) ?? []}
isLoadingFiles={loadingCommitHashes.has(entry.hash)}
onCopyHash={onCopyHash}
/>
))}
</ul>
)}
</ScrollableOverlay>
</CollapsibleContent>
</Collapsible> </Collapsible>
); );
}; };
@@ -6,12 +6,6 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { import {
Command, Command,
CommandEmpty, CommandEmpty,
@@ -52,6 +46,7 @@ export const IntegrateCommitsSection: React.FC<{
defaultTargetBranch: string; defaultTargetBranch: string;
refreshKey?: number; refreshKey?: number;
onRefresh?: () => void; onRefresh?: () => void;
variant?: 'framed' | 'plain';
}> = ({ }> = ({
repoRoot, repoRoot,
sourceBranch, sourceBranch,
@@ -60,10 +55,10 @@ export const IntegrateCommitsSection: React.FC<{
defaultTargetBranch, defaultTargetBranch,
refreshKey, refreshKey,
onRefresh, onRefresh,
variant = 'framed',
}) => { }) => {
const currentSessionId = useSessionStore((s) => s.currentSessionId); const currentSessionId = useSessionStore((s) => s.currentSessionId);
const setActiveMainTab = useUIStore((s) => s.setActiveMainTab); const setActiveMainTab = useUIStore((s) => s.setActiveMainTab);
const [isOpen, setIsOpen] = React.useState(true);
const [branchDropdownOpen, setBranchDropdownOpen] = React.useState(false); const [branchDropdownOpen, setBranchDropdownOpen] = React.useState(false);
const searchInputRef = React.useRef<HTMLInputElement>(null); const searchInputRef = React.useRef<HTMLInputElement>(null);
@@ -250,7 +245,7 @@ Important:
// Use current session - set pending input text and synthetic parts // Use current session - set pending input text and synthetic parts
if (!currentSessionId) { if (!currentSessionId) {
toast.error('No active session', { description: 'Open a chat session first or use "New Session".' }); toast.error('No active session', { description: 'Open a chat session first or start a new session.' });
return; return;
} }
@@ -281,7 +276,7 @@ Important:
return; return;
} }
if (result.kind === 'conflict') { if (result.kind === 'conflict') {
toast.error('Cherry-pick conflict', { description: 'Resolve conflicts, then Continue.' }); toast.error('Cherry-pick conflict', { description: 'Resolve conflicts, then continue.' });
setUi({ kind: 'conflict', state: result.state, details: result.details }); setUi({ kind: 'conflict', state: result.state, details: result.details });
if (conflictStorageKey && typeof window !== 'undefined') { if (conflictStorageKey && typeof window !== 'undefined') {
window.localStorage.setItem(conflictStorageKey, JSON.stringify(result.state)); window.localStorage.setItem(conflictStorageKey, JSON.stringify(result.state));
@@ -342,13 +337,19 @@ Important:
return null; return null;
} }
const containerClassName =
variant === 'framed'
? 'rounded-xl border border-border/60 bg-background/70 overflow-hidden'
: 'border-0 bg-transparent rounded-none';
const headerClassName =
variant === 'framed'
? 'px-3 py-2 border-b border-border/40 flex items-center justify-between gap-2'
: 'px-0 py-3 border-b border-border/40 flex items-center justify-between gap-2';
const bodyClassName = variant === 'framed' ? 'flex flex-col gap-3 p-3' : 'flex flex-col gap-3 py-3';
return ( return (
<Collapsible <section className={containerClassName}>
open={isOpen} <div className={headerClassName}>
onOpenChange={setIsOpen}
className="rounded-xl border border-border/60 bg-background/70 overflow-hidden"
>
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 h-10 hover:bg-transparent">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<RiSplitCellsHorizontal className="size-4 text-muted-foreground" /> <RiSplitCellsHorizontal className="size-4 text-muted-foreground" />
<h3 className="typography-ui-header font-semibold text-foreground truncate">Re-integrate commits</h3> <h3 className="typography-ui-header font-semibold text-foreground truncate">Re-integrate commits</h3>
@@ -361,14 +362,12 @@ Important:
<RiLoader4Line className="size-4 animate-spin text-muted-foreground" /> <RiLoader4Line className="size-4 animate-spin text-muted-foreground" />
) : null} ) : null}
</div> </div>
</CollapsibleTrigger> </div>
<CollapsibleContent> <div className={bodyClassName}>
<div className="border-t border-border/40"> <div className="flex flex-wrap items-center gap-2">
<div className="flex flex-col gap-3 p-3"> <div className="min-w-0">
<div className="flex flex-wrap items-center gap-2"> <div className="typography-ui-label text-foreground">Move commits</div>
<div className="min-w-0">
<div className="typography-ui-label text-foreground">Move commits</div>
<div className="typography-micro text-muted-foreground truncate"> <div className="typography-micro text-muted-foreground truncate">
{sourceBranch} → {targetBranch} {sourceBranch} → {targetBranch}
</div> </div>
@@ -519,9 +518,7 @@ Important:
</div> </div>
</div> </div>
)} )}
</div> </div>
</div> </section>
</CollapsibleContent>
</Collapsible>
); );
}; };
@@ -4,6 +4,9 @@ import {
RiCheckboxBlankLine, RiCheckboxBlankLine,
RiCheckboxLine, RiCheckboxLine,
RiExternalLinkLine, RiExternalLinkLine,
RiGitClosePullRequestLine,
RiGitMergeLine,
RiGitPrDraftLine,
RiGitPullRequestLine, RiGitPullRequestLine,
RiLoader4Line, RiLoader4Line,
} from '@remixicon/react'; } from '@remixicon/react';
@@ -54,6 +57,28 @@ const statusColor = (state: string | undefined | null): string => {
} }
}; };
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 notMergeable = status?.canMerge === false || pr.mergeable === false;
if (checksFailed || notMergeable) {
return 'blocked';
}
return 'open';
};
const branchToTitle = (branch: string): string => { const branchToTitle = (branch: string): string => {
return branch return branch
.replace(/^refs\/heads\//, '') .replace(/^refs\/heads\//, '')
@@ -67,7 +92,6 @@ type PullRequestDraftSnapshot = {
title: string; title: string;
body: string; body: string;
draft: boolean; draft: boolean;
isOpen: boolean;
additionalContext: string; additionalContext: string;
}; };
@@ -103,7 +127,8 @@ export const PullRequestSection: React.FC<{
directory: string; directory: string;
branch: string; branch: string;
baseBranch: string; baseBranch: string;
}> = ({ directory, branch, baseBranch }) => { variant?: 'framed' | 'plain';
}> = ({ directory, branch, baseBranch, variant = 'framed' }) => {
const { github } = useRuntimeAPIs(); const { github } = useRuntimeAPIs();
const githubAuthStatus = useGitHubAuthStore((state) => state.status); const githubAuthStatus = useGitHubAuthStore((state) => state.status);
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked); const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
@@ -124,7 +149,6 @@ export const PullRequestSection: React.FC<{
[snapshotKey] [snapshotKey]
); );
const [isOpen, setIsOpen] = React.useState(initialSnapshot?.isOpen ?? true);
const [isLoading, setIsLoading] = React.useState(false); const [isLoading, setIsLoading] = React.useState(false);
const [status, setStatus] = React.useState<GitHubPullRequestStatus | null>(null); const [status, setStatus] = React.useState<GitHubPullRequestStatus | null>(null);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
@@ -401,7 +425,6 @@ export const PullRequestSection: React.FC<{
setTitle(snapshot?.title ?? branchToTitle(branch)); setTitle(snapshot?.title ?? branchToTitle(branch));
setBody(snapshot?.body ?? ''); setBody(snapshot?.body ?? '');
setDraft(snapshot?.draft ?? false); setDraft(snapshot?.draft ?? false);
setIsOpen(snapshot?.isOpen ?? true);
void refresh(); void refresh();
}, [branch, refresh, snapshotKey]); }, [branch, refresh, snapshotKey]);
@@ -420,10 +443,9 @@ export const PullRequestSection: React.FC<{
title, title,
body, body,
draft, draft,
isOpen,
additionalContext, additionalContext,
}); });
}, [snapshotKey, title, body, draft, isOpen, additionalContext, directory, branch]); }, [snapshotKey, title, body, draft, additionalContext, directory, branch]);
const generateDescription = React.useCallback(async () => { const generateDescription = React.useCallback(async () => {
if (isGenerating) return; if (isGenerating) return;
@@ -538,16 +560,31 @@ export const PullRequestSection: React.FC<{
const canMerge = Boolean(status?.canMerge); const canMerge = Boolean(status?.canMerge);
const isConnected = Boolean(status?.connected); const isConnected = Boolean(status?.connected);
const shouldShowConnectionNotice = githubAuthChecked && status?.connected === false; const shouldShowConnectionNotice = githubAuthChecked && status?.connected === false;
const prVisualState = getPrVisualState(status);
const prColorVar = prVisualState ? `var(--pr-${prVisualState})` : 'var(--status-info)';
const PrStateIcon = prVisualState === 'draft'
? RiGitPrDraftLine
: prVisualState === 'merged'
? RiGitMergeLine
: prVisualState === 'closed'
? RiGitClosePullRequestLine
: RiGitPullRequestLine;
const containerClassName =
variant === 'framed'
? 'rounded-xl border border-border/60 bg-background/70 overflow-hidden'
: 'border-0 bg-transparent rounded-none';
const headerClassName =
variant === 'framed'
? 'px-3 py-2 border-b border-border/40 flex items-center justify-between gap-2'
: 'px-0 py-3 border-b border-border/40 flex items-center justify-between gap-2';
const bodyClassName = variant === 'framed' ? 'flex flex-col gap-3 p-3' : 'flex flex-col gap-3 py-3';
return ( return (
<Collapsible <section className={containerClassName}>
open={isOpen} <div className={headerClassName}>
onOpenChange={setIsOpen}
className="rounded-xl border border-border/60 bg-background/70 overflow-hidden"
>
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 h-10 hover:bg-transparent">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<RiGitPullRequestLine className="size-4 text-muted-foreground" /> <PrStateIcon className="size-4 shrink-0" style={{ color: pr ? prColorVar : 'var(--surface-muted-foreground)' }} />
<h3 className="typography-ui-header font-semibold text-foreground truncate">Pull Request</h3> <h3 className="typography-ui-header font-semibold text-foreground truncate">Pull Request</h3>
{pr ? ( {pr ? (
<span className="typography-meta text-muted-foreground truncate">#{pr.number}</span> <span className="typography-meta text-muted-foreground truncate">#{pr.number}</span>
@@ -562,16 +599,14 @@ export const PullRequestSection: React.FC<{
</span> </span>
) : null} ) : null}
</div> </div>
</CollapsibleTrigger> </div>
<CollapsibleContent> <div className={bodyClassName}>
<div className="border-t border-border/40"> {shouldShowConnectionNotice ? (
<div className="flex flex-col gap-3 p-3"> <div className="space-y-2">
{shouldShowConnectionNotice ? ( <div className="typography-meta text-muted-foreground">
<div className="space-y-2"> GitHub not connected. Connect your GitHub account in settings.
<div className="typography-meta text-muted-foreground"> </div>
GitHub not connected. Connect your GitHub account in settings.
</div>
<Button variant="outline" size="sm" onClick={openGitHubSettings} className="w-fit"> <Button variant="outline" size="sm" onClick={openGitHubSettings} className="w-fit">
Open settings Open settings
</Button> </Button>
@@ -599,30 +634,43 @@ export const PullRequestSection: React.FC<{
<div className="min-w-0"> <div className="min-w-0">
<div className="typography-ui-label text-foreground truncate">{pr.title}</div> <div className="typography-ui-label text-foreground truncate">{pr.title}</div>
<div className="typography-micro text-muted-foreground truncate"> <div className="typography-micro text-muted-foreground truncate">
{pr.state}{pr.draft ? ' (draft)' : ''} <span style={{ color: prColorVar }}>
{pr.state}{pr.draft ? ' (draft)' : ''}
</span>
{pr.mergeable === false ? ' · not mergeable' : ''} {pr.mergeable === false ? ' · not mergeable' : ''}
{typeof pr.mergeableState === 'string' && pr.mergeableState ? ` · ${pr.mergeableState}` : ''} {pr.state === 'open' && typeof pr.mergeableState === 'string' && pr.mergeableState && pr.mergeableState !== 'unknown'
? ` · ${pr.mergeableState}`
: ''}
</div> </div>
<div className="mt-2 flex flex-wrap items-center gap-2"> <div className="mt-2 space-y-2">
{checks ? ( <div className="flex flex-col sm:flex-row items-stretch gap-2">
{checks ? (
<Button
variant="outline"
size="sm"
onClick={openChecksDialog}
disabled={isLoadingCheckDetails}
className="justify-center sm:flex-1"
>
{isLoadingCheckDetails ? <RiLoader4Line className="size-4 animate-spin" /> : null}
Check details
</Button>
) : null}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={openChecksDialog} onClick={sendCommentsToChat}
disabled={isLoadingCheckDetails} className={checks ? 'justify-center sm:flex-1' : 'justify-center'}
> >
{isLoadingCheckDetails ? <RiLoader4Line className="size-4 animate-spin" /> : null} Send PR comments to chat
Check details
</Button> </Button>
) : null} </div>
{checks?.failure ? ( {checks?.failure ? (
<Button variant="outline" size="sm" onClick={sendFailedChecksToChat}> <Button variant="outline" size="sm" onClick={sendFailedChecksToChat} className="w-full justify-center">
Send failed checks to chat Send failed checks to chat
</Button> </Button>
) : null} ) : null}
<Button variant="outline" size="sm" onClick={sendCommentsToChat}>
Send PR comments to chat
</Button>
</div> </div>
{canMerge && pr.draft ? ( {canMerge && pr.draft ? (
<div className="typography-micro text-muted-foreground"> <div className="typography-micro text-muted-foreground">
@@ -843,12 +891,10 @@ export const PullRequestSection: React.FC<{
</div> </div>
</div> </div>
)} )}
</div> </div>
</div>
</CollapsibleContent>
<Dialog open={checksDialogOpen} onOpenChange={setChecksDialogOpen}> <Dialog open={checksDialogOpen} onOpenChange={setChecksDialogOpen}>
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col"> <DialogContent className="max-w-2xl max-h-[70vh] flex flex-col min-h-0">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<RiGitPullRequestLine className="h-5 w-5" /> <RiGitPullRequestLine className="h-5 w-5" />
@@ -859,7 +905,7 @@ export const PullRequestSection: React.FC<{
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex-1 overflow-y-auto mt-2"> <div className="flex-1 min-h-0 overflow-y-auto mt-2">
{isLoadingCheckDetails ? ( {isLoadingCheckDetails ? (
<div className="text-center text-muted-foreground py-8 flex items-center justify-center gap-2"> <div className="text-center text-muted-foreground py-8 flex items-center justify-center gap-2">
<RiLoader4Line className="h-4 w-4 animate-spin" /> <RiLoader4Line className="h-4 w-4 animate-spin" />
@@ -890,6 +936,6 @@ export const PullRequestSection: React.FC<{
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</Collapsible> </section>
); );
}; };
@@ -61,7 +61,7 @@ export const StashDialog: React.FC<StashDialogProps> = ({
<DialogTitle>Uncommitted Changes</DialogTitle> <DialogTitle>Uncommitted Changes</DialogTitle>
</div> </div>
<DialogDescription> <DialogDescription>
You have uncommitted changes that would be overwritten by {operation}. You have uncommitted changes that would be overwritten by this {operation}.
Would you like to stash them temporarily? Would you like to stash them temporarily?
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -72,7 +72,11 @@ export const StashDialog: React.FC<StashDialogProps> = ({
</p> </p>
<ol className="list-decimal list-inside space-y-1 typography-meta text-foreground"> <ol className="list-decimal list-inside space-y-1 typography-meta text-foreground">
<li>Stash your uncommitted changes</li> <li>Stash your uncommitted changes</li>
<li>{operationLabel} <span className="font-mono text-primary">{targetBranch}</span></li> <li>
{operation === 'merge' ? 'Merge' : 'Rebase'}{' '}
{operation === 'merge' ? 'with' : 'onto'}{' '}
<span className="font-mono text-primary">{targetBranch}</span>
</li>
{restoreAfter && <li>Restore your stashed changes</li>} {restoreAfter && <li>Restore your stashed changes</li>}
</ol> </ol>
</div> </div>
@@ -88,7 +92,7 @@ export const StashDialog: React.FC<StashDialogProps> = ({
className="typography-ui-label text-foreground cursor-pointer select-none" className="typography-ui-label text-foreground cursor-pointer select-none"
onClick={() => !isProcessing && setRestoreAfter(!restoreAfter)} onClick={() => !isProcessing && setRestoreAfter(!restoreAfter)}
> >
Restore changes after {operation} Restore changes after the {operation}
</span> </span>
</div> </div>
+48 -31
View File
@@ -138,38 +138,55 @@ export const getPullRequestStatus = async (
return { connected: true, repo: null, branch, pr: null, checks: null, canMerge: false }; return { connected: true, repo: null, branch, pr: null, checks: null, canMerge: false };
} }
const listUrl = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls`); const listNumberByHead = async (state: 'open' | 'closed'): Promise<number | null> => {
listUrl.searchParams.set('state', 'open'); const url = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls`);
listUrl.searchParams.set('head', `${repo.owner}:${branch}`); url.searchParams.set('state', state);
listUrl.searchParams.set('per_page', '10'); url.searchParams.set('head', `${repo.owner}:${branch}`);
url.searchParams.set('per_page', '10');
const listResp = await githubFetch(listUrl.toString(), accessToken); const resp = await githubFetch(url.toString(), accessToken);
if (listResp.status === 401) { if (resp.status === 401) {
return { connected: false }; return null;
}
const list = await jsonOrNull<Array<{ number: number }>>(listResp);
let number = (listResp.ok && Array.isArray(list) && list.length > 0)
? list[0].number
: null;
// Fork PR support: head owner differs -> head filter yields empty.
if (!number) {
const openListUrl = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls`);
openListUrl.searchParams.set('state', 'open');
openListUrl.searchParams.set('per_page', '100');
const openResp = await githubFetch(openListUrl.toString(), accessToken);
if (openResp.status === 401) {
return { connected: false };
} }
const openList = await jsonOrNull<Array<JsonRecord>>(openResp); const list = await jsonOrNull<Array<{ number: number }>>(resp);
if (openResp.ok && Array.isArray(openList)) { return (resp.ok && Array.isArray(list) && list.length > 0) ? list[0].number : null;
const match = openList.find((prItem) => { };
const head = prItem?.head && typeof prItem.head === 'object' ? (prItem.head as JsonRecord) : null;
return readString(head?.ref) === branch; const listNumberByHeadRef = async (state: 'open' | 'closed'): Promise<number | null> => {
}); const url = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls`);
if (match && typeof match.number === 'number') { url.searchParams.set('state', state);
number = match.number; url.searchParams.set('per_page', '100');
} const resp = await githubFetch(url.toString(), accessToken);
if (resp.status === 401) {
return null;
}
const list = await jsonOrNull<Array<JsonRecord>>(resp);
if (!resp.ok || !Array.isArray(list)) return null;
const match = list.find((prItem) => {
const head = prItem?.head && typeof prItem.head === 'object' ? (prItem.head as JsonRecord) : null;
return readString(head?.ref) === branch;
});
return match && typeof match.number === 'number' ? match.number : null;
};
// PR status by branch:
// - Prefer open PRs.
// - If none, surface closed/merged PRs.
// - Fork PR support: head owner differs -> head filter yields empty; fall back to matching head.ref.
let number = await listNumberByHead('open');
if (!number) number = await listNumberByHead('closed');
if (!number) number = await listNumberByHeadRef('open');
if (!number) number = await listNumberByHeadRef('closed');
// Detect auth revocation (best-effort)
if (number === null) {
const probeUrl = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls`);
probeUrl.searchParams.set('state', 'open');
probeUrl.searchParams.set('per_page', '1');
const probeResp = await githubFetch(probeUrl.toString(), accessToken);
if (probeResp.status === 401) {
return { connected: false };
} }
} }
@@ -185,7 +202,7 @@ export const getPullRequestStatus = async (
throw new Error('Failed to load PR'); throw new Error('Failed to load PR');
} }
const merged = Boolean(prJson.merged); const merged = Boolean(prJson.merged || prJson.merged_at);
const prState = readString(prJson.state); const prState = readString(prJson.state);
const state = merged ? 'merged' : (prState === 'closed' ? 'closed' : 'open'); const state = merged ? 'merged' : (prState === 'closed' ? 'closed' : 'open');
const pr: GitHubPullRequest = { const pr: GitHubPullRequest = {
+32 -24
View File
@@ -5844,31 +5844,38 @@ async function main(options = {}) {
return res.json({ connected: true, repo: null, branch, pr: null, checks: null, canMerge: false }); return res.json({ connected: true, repo: null, branch, pr: null, checks: null, canMerge: false });
} }
// Find PR for this branch (same-repo assumption) const listByHead = async (state) => {
const list = await octokit.rest.pulls.list({ const resp = await octokit.rest.pulls.list({
owner: repo.owner, owner: repo.owner,
repo: repo.repo, repo: repo.repo,
state: 'open', state,
head: `${repo.owner}:${branch}`, head: `${repo.owner}:${branch}`,
per_page: 10, per_page: 10,
}); });
return Array.isArray(resp?.data) ? resp.data[0] : null;
};
let first = Array.isArray(list?.data) ? list.data[0] : null; const listByHeadRef = async (state) => {
const resp = await octokit.rest.pulls.list({
owner: repo.owner,
repo: repo.repo,
state,
per_page: 100,
});
const matches = Array.isArray(resp?.data)
? resp.data.filter((pr) => pr?.head?.ref === branch)
: [];
return matches[0] ?? null;
};
// Fork PR support: head owner != base owner. If no PR found via head filter, // PR status by branch:
// fall back to listing open PRs and matching by head ref name. // - Prefer open PRs.
if (!first) { // - If none, also surface closed/merged PRs.
const openList = await octokit.rest.pulls.list({ // - Fork PR support: head owner != base owner -> head filter yields empty; fall back to matching head.ref.
owner: repo.owner, let first = await listByHead('open');
repo: repo.repo, if (!first) first = await listByHead('closed');
state: 'open', if (!first) first = await listByHeadRef('open');
per_page: 100, if (!first) first = await listByHeadRef('closed');
});
const matches = Array.isArray(openList?.data)
? openList.data.filter((pr) => pr?.head?.ref === branch)
: [];
first = matches[0] ?? null;
}
if (!first) { if (!first) {
return res.json({ connected: true, repo, branch, pr: null, checks: null, canMerge: false }); return res.json({ connected: true, repo, branch, pr: null, checks: null, canMerge: false });
} }
@@ -5964,7 +5971,8 @@ async function main(options = {}) {
canMerge = false; canMerge = false;
} }
const mergedState = prData.merged ? 'merged' : (prData.state === 'closed' ? 'closed' : 'open'); const isMerged = Boolean(prData.merged || prData.merged_at);
const mergedState = isMerged ? 'merged' : (prData.state === 'closed' ? 'closed' : 'open');
return res.json({ return res.json({
connected: true, connected: true,