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;
isInteractive?: boolean;
animate?: boolean;
collapseLabelsOnSmall?: boolean;
}
export function AnimatedTabs<T extends string>({
@@ -23,6 +24,7 @@ export function AnimatedTabs<T extends string>({
className,
isInteractive = true,
animate = true,
collapseLabelsOnSmall = false,
}: AnimatedTabsProps<T>) {
const containerRef = React.useRef<HTMLDivElement>(null);
const activeTabRef = React.useRef<HTMLButtonElement>(null);
@@ -73,10 +75,15 @@ export function AnimatedTabs<T extends string>({
return (
<div
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}
<span className="truncate">{tab.label}</span>
<span className={cn('truncate', collapseLabelsOnSmall ? 'hidden sm:inline' : null)}>
{tab.label}
</span>
</div>
);
@@ -99,11 +106,13 @@ export function AnimatedTabs<T extends string>({
onValueChange(tab.value);
}}
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',
'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-label={tab.label}
aria-disabled={!isInteractive}
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')}
/>
) : null}
<span className="truncate">{tab.label}</span>
<span className={cn('truncate', collapseLabelsOnSmall ? 'hidden sm:inline' : null)}>
{tab.label}
</span>
</button>
);
+191 -70
View File
@@ -15,11 +15,20 @@ import {
useIsGitRepo,
} from '@/stores/useGitStore';
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 { AnimatedTabs } from '@/components/ui/animated-tabs';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
@@ -46,7 +55,7 @@ import { PullRequestSection } from './git/PullRequestSection';
import { ConflictDialog } from './git/ConflictDialog';
import { StashDialog } from './git/StashDialog';
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 { BranchPickerDialog } from '@/components/session/BranchPickerDialog';
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
@@ -54,6 +63,7 @@ import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
type SyncAction = 'fetch' | 'pull' | 'push' | null;
type CommitAction = 'commit' | 'commitAndPush' | null;
type BranchOperation = 'merge' | 'rebase' | null;
type ActionTab = 'commit' | 'branch' | 'pr' | 'worktree';
type GitViewSnapshot = {
@@ -235,8 +245,6 @@ export const GitView: React.FC = () => {
const [isBranchPickerOpen, setIsBranchPickerOpen] = React.useState(false);
const [rootBranchHint, setRootBranchHint] = React.useState<string | null>(null);
const baseBranch = worktreeMetadata?.createdFromBranch || status?.current || 'HEAD';
React.useEffect(() => {
const projectRoot = worktreeMetadata?.projectDirectory;
if (!projectRoot) {
@@ -363,6 +371,8 @@ export const GitView: React.FC = () => {
const [remoteUrl, setRemoteUrl] = React.useState<string | null>(null);
const [gitmojiEmojis, setGitmojiEmojis] = React.useState<GitmojiEntry[]>([]);
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 [branchOperation, setBranchOperation] = React.useState<BranchOperation>(null);
const [operationLogs, setOperationLogs] = React.useState<OperationLogEntry[]>([]);
@@ -915,6 +925,21 @@ export const GitView: React.FC = () => {
.sort();
}, [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 unique = new Map<string, GitIdentityProfile>();
if (globalIdentity) {
@@ -998,6 +1023,29 @@ export const GitView: React.FC = () => {
const selectedCount = selectedPaths.size;
const isBusy = isLoading || syncAction !== null || commitAction !== null;
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) => {
setSelectedPaths((previous) => {
@@ -1484,12 +1532,7 @@ export const GitView: React.FC = () => {
onSelectIdentity={handleApplyIdentity}
isApplyingIdentity={isSettingIdentity}
isWorktreeMode={!!worktreeMetadata}
onMerge={handleMerge}
onRebase={handleRebase}
branchOperation={branchOperation}
operationLogs={operationLogs}
onOperationComplete={handleOperationComplete}
isBusy={isBusy}
onOpenHistory={() => setIsHistoryDialogOpen(true)}
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 flex-col gap-3">
{/* Two-column layout on large screens: Changes + Commit */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
<div className="flex-1 min-h-0 overflow-hidden">
<div className="h-full min-h-0 grid grid-cols-1 xl:grid-cols-[minmax(520px,1fr)_480px]">
<div className="min-w-0 min-h-0 h-full flex flex-col">
{hasChanges ? (
<ChangesSection
variant="plain"
changeEntries={changeEntries}
selectedPaths={selectedPaths}
diffStats={status?.diffStats}
@@ -1526,7 +1569,7 @@ export const GitView: React.FC = () => {
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
behind={status?.behind ?? 0}
onPull={() => {
@@ -1540,66 +1583,144 @@ export const GitView: React.FC = () => {
/>
</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>
{worktreeMetadata && repoRootForIntegrate && sourceBranchForIntegrate && shouldShowIntegrateCommits ? (
<IntegrateCommitsSection
repoRoot={repoRootForIntegrate}
sourceBranch={sourceBranchForIntegrate}
worktreeMetadata={worktreeMetadata}
localBranches={localBranches}
defaultTargetBranch={defaultTargetBranch}
refreshKey={integrateRefreshKey}
onRefresh={() => {
if (!currentDirectory) return;
fetchStatus(currentDirectory, git);
fetchBranches(currentDirectory, git);
fetchLog(currentDirectory, git, logMaxCountLocal);
}}
/>
) : null}
<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">
<div className="px-3 py-3">
<AnimatedTabs<ActionTab>
value={actionTab}
onValueChange={setActionTab}
collapseLabelsOnSmall
tabs={[
{ value: 'commit', label: 'Commit', icon: RiGitCommitLine },
{ value: 'branch', label: 'Update branch', icon: RiGitMergeLine },
{ value: 'pr', label: 'PR', icon: RiGitPullRequestLine },
{ value: 'worktree', label: 'Worktree', icon: RiSplitCellsHorizontal },
]}
/>
</div>
<div className="h-px bg-border/40" />
{currentDirectory && status?.current && status?.tracking ? (
<PullRequestSection
directory={currentDirectory}
branch={status.current}
baseBranch={baseBranch}
/>
) : null}
<ScrollableOverlay
outerClassName="flex-1 min-h-0"
className="px-4 py-4"
disableHorizontal
preventOverscroll
>
{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 */}
<HistorySection
log={log}
isLogLoading={isLogLoading}
logMaxCount={logMaxCountLocal}
onLogMaxCountChange={handleLogMaxCountChange}
expandedCommitHashes={expandedCommitHashes}
onToggleCommit={handleToggleCommit}
commitFilesMap={commitFilesMap}
loadingCommitHashes={loadingCommitHashes}
onCopyHash={handleCopyCommitHash}
/>
{actionTab === 'branch' ? (
<div className="space-y-4">
{canShowBranchWorkflows ? (
<BranchIntegrationSection
mode="inline"
currentBranch={status?.current}
localBranches={localBranches}
remoteBranches={remoteBranches}
onMerge={handleMerge}
onRebase={handleRebase}
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>
</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}>
<DialogContent className="max-w-md p-0 overflow-hidden">
@@ -51,6 +51,7 @@ interface BranchIntegrationSectionProps {
isOperating?: boolean;
operationLogs?: OperationLogEntry[];
onOperationComplete?: () => void;
mode?: 'dialog' | 'inline';
}
export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> = ({
@@ -63,6 +64,7 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
isOperating = false,
operationLogs = [],
onOperationComplete,
mode = 'dialog',
}) => {
const [dialogOpen, setDialogOpen] = React.useState(false);
const [operation, setOperation] = React.useState<OperationType>('merge');
@@ -73,6 +75,7 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
const logContainerRef = React.useRef<HTMLDivElement>(null);
const isDisabled = disabled || isOperating;
const targetBranchLabel = currentBranch || 'current branch';
// Check if operation completed (all logs are done or error)
const operationCompleted = operationLogs.length > 0 &&
@@ -159,12 +162,260 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
}
}, [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 (
<>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
variant="ghost"
variant="outline"
size="sm"
className="h-8 px-2 gap-1.5"
onClick={handleOpenDialog}
@@ -175,11 +426,11 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
) : (
<RiGitMergeLine className="size-4" />
)}
<span className="hidden sm:inline">Integrate</span>
<span>Merge/Rebase</span>
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={8}>
Merge or rebase another branch
Merge or rebase changes from another branch.
</TooltipContent>
</Tooltip>
@@ -190,10 +441,10 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
setDialogOpen(true);
}
}}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Integrate Branch</DialogTitle>
<DialogDescription>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Update Branch</DialogTitle>
<DialogDescription>
{isOperating ? (
operationCompleted ? (
hasError ? 'Operation failed' : 'Operation completed'
@@ -202,248 +453,15 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
)
) : (
<>
Choose how to integrate changes from another branch into{' '}
<span className="font-mono text-foreground">{currentBranch || 'current branch'}</span>
Choose how to bring changes from another branch into{' '}
<span className="font-mono text-foreground">{targetBranchLabel}</span>
.
</>
)}
</DialogDescription>
</DialogHeader>
{/* Show operation log when operating */}
{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>
</>
)}
{body}
</DialogContent>
</Dialog>
</>
@@ -14,6 +14,7 @@ interface ChangesSectionProps {
onClearSelection: () => void;
onViewDiff: (path: string) => void;
onRevertFile: (path: string) => void;
variant?: 'framed' | 'plain';
}
export const ChangesSection: React.FC<ChangesSectionProps> = ({
@@ -26,13 +27,27 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
onClearSelection,
onViewDiff,
onRevertFile,
variant = 'framed',
}) => {
const selectedCount = selectedPaths.size;
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 (
<section className="flex flex-col rounded-xl border border-border/60 bg-background/70">
<header className="flex items-center justify-between gap-2 px-3 py-2 border-b border-border/40">
<section className={containerClassName}>
<header className={headerClassName}>
<h3 className="typography-ui-header font-semibold text-foreground">Changes</h3>
<div className="flex items-center gap-2">
<span className="typography-meta text-muted-foreground">
@@ -61,7 +76,7 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
)}
</div>
</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">
{changeEntries.map((file) => (
<ChangeRow
@@ -33,6 +33,7 @@ interface CommitSectionProps {
isBusy: boolean;
gitmojiEnabled: boolean;
onOpenGitmojiPicker: () => void;
variant?: 'framed' | 'plain';
}
export const CommitSection: React.FC<CommitSectionProps> = ({
@@ -50,18 +51,32 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
isBusy,
gitmojiEnabled,
onOpenGitmojiPicker,
variant = 'framed',
}) => {
const hasSelectedFiles = selectedCount > 0;
const canCommit = commitMessage.trim() && hasSelectedFiles && commitAction === null;
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 (
<Collapsible
open={hasSelectedFiles}
className="rounded-xl border border-border/60 bg-background/70 overflow-hidden"
open={variant === 'plain' ? true : hasSelectedFiles}
className={containerClassName}
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>
<span className="typography-meta text-muted-foreground">
{hasSelectedFiles
@@ -71,7 +86,13 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
</div>
<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
highlights={generatedHighlights}
onInsert={onInsertHighlights}
@@ -223,7 +223,7 @@ Important:
{conflictDetails?.headInfo && (
<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">
{conflictDetails.headInfo}
</div>
@@ -14,8 +14,8 @@ export const GitEmptyState: React.FC<GitEmptyStateProps> = ({
isPulling,
}) => {
return (
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
<RiGitCommitLine className="size-10 text-emerald-500/60 mb-4" />
<div className="flex flex-col items-center justify-center py-10 px-4 text-center">
<RiGitCommitLine className="size-10 text-muted-foreground/70 mb-4" />
<p className="typography-ui-label font-semibold text-foreground mb-1">
Working tree clean
</p>
@@ -5,12 +5,13 @@ import {
RiArrowDownSLine,
RiLoader4Line,
RiGitBranchLine,
RiGitRepositoryLine,
RiBriefcaseLine,
RiHomeLine,
RiGraduationCapLine,
RiCodeLine,
RiHeartLine,
RiGitRepositoryLine,
RiHistoryLine,
RiUser3Line,
} from '@remixicon/react';
import { Button } from '@/components/ui/button';
@@ -24,11 +25,9 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { BranchSelector } from './BranchSelector';
import { WorktreeBranchDisplay } from './WorktreeBranchDisplay';
import { SyncActions } from './SyncActions';
import { BranchIntegrationSection, type OperationLogEntry } from './BranchIntegrationSection';
import type { GitStatus, GitIdentityProfile, GitRemote } from '@/lib/api/types';
type SyncAction = 'fetch' | 'pull' | 'push' | null;
type BranchOperation = 'merge' | 'rebase' | null;
interface GitHeaderProps {
status: GitStatus | null;
@@ -48,13 +47,7 @@ interface GitHeaderProps {
onSelectIdentity: (profile: GitIdentityProfile) => void;
isApplyingIdentity: boolean;
isWorktreeMode: boolean;
// Branch integration (merge/rebase)
onMerge: (branch: string) => void;
onRebase: (branch: string) => void;
branchOperation: BranchOperation;
operationLogs: OperationLogEntry[];
onOperationComplete: () => void;
isBusy: boolean;
onOpenHistory?: () => void;
onOpenBranchPicker?: () => void;
}
@@ -208,12 +201,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
onSelectIdentity,
isApplyingIdentity,
isWorktreeMode,
onMerge,
onRebase,
branchOperation,
operationLogs,
onOperationComplete,
isBusy,
onOpenHistory,
onOpenBranchPicker,
}) => {
if (!status) {
@@ -271,20 +259,6 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
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" />
{onOpenBranchPicker ? (
@@ -297,13 +271,30 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
onClick={onOpenBranchPicker}
>
<RiGitRepositoryLine className="size-4" />
<span className="hidden sm:inline">Manage Branches</span>
<span className="hidden sm:inline">Manage branches</span>
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={8}>Manage branches</TooltipContent>
</Tooltip>
) : 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
activeProfile={activeIdentityProfile}
identities={availableIdentities}
@@ -32,6 +32,7 @@ interface HistorySectionProps {
commitFilesMap: Map<string, CommitFileEntry[]>;
loadingCommitHashes: Set<string>;
onCopyHash: (hash: string) => void;
showHeader?: boolean;
}
export const HistorySection: React.FC<HistorySectionProps> = ({
@@ -44,6 +45,7 @@ export const HistorySection: React.FC<HistorySectionProps> = ({
commitFilesMap,
loadingCommitHashes,
onCopyHash,
showHeader = true,
}) => {
const [isOpen, setIsOpen] = React.useState(true);
@@ -51,6 +53,40 @@ export const HistorySection: React.FC<HistorySectionProps> = ({
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 (
<Collapsible
open={isOpen}
@@ -95,31 +131,7 @@ export const HistorySection: React.FC<HistorySectionProps> = ({
</div>
</CollapsibleTrigger>
<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>
<CollapsibleContent>{content}</CollapsibleContent>
</Collapsible>
);
};
@@ -6,12 +6,6 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import {
Command,
CommandEmpty,
@@ -52,6 +46,7 @@ export const IntegrateCommitsSection: React.FC<{
defaultTargetBranch: string;
refreshKey?: number;
onRefresh?: () => void;
variant?: 'framed' | 'plain';
}> = ({
repoRoot,
sourceBranch,
@@ -60,10 +55,10 @@ export const IntegrateCommitsSection: React.FC<{
defaultTargetBranch,
refreshKey,
onRefresh,
variant = 'framed',
}) => {
const currentSessionId = useSessionStore((s) => s.currentSessionId);
const setActiveMainTab = useUIStore((s) => s.setActiveMainTab);
const [isOpen, setIsOpen] = React.useState(true);
const [branchDropdownOpen, setBranchDropdownOpen] = React.useState(false);
const searchInputRef = React.useRef<HTMLInputElement>(null);
@@ -250,7 +245,7 @@ Important:
// Use current session - set pending input text and synthetic parts
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;
}
@@ -281,7 +276,7 @@ Important:
return;
}
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 });
if (conflictStorageKey && typeof window !== 'undefined') {
window.localStorage.setItem(conflictStorageKey, JSON.stringify(result.state));
@@ -342,13 +337,19 @@ Important:
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 (
<Collapsible
open={isOpen}
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">
<section className={containerClassName}>
<div className={headerClassName}>
<div className="flex items-center gap-2 min-w-0">
<RiSplitCellsHorizontal className="size-4 text-muted-foreground" />
<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" />
) : null}
</div>
</CollapsibleTrigger>
</div>
<CollapsibleContent>
<div className="border-t border-border/40">
<div className="flex flex-col gap-3 p-3">
<div className="flex flex-wrap items-center gap-2">
<div className="min-w-0">
<div className="typography-ui-label text-foreground">Move commits</div>
<div className={bodyClassName}>
<div className="flex flex-wrap items-center gap-2">
<div className="min-w-0">
<div className="typography-ui-label text-foreground">Move commits</div>
<div className="typography-micro text-muted-foreground truncate">
{sourceBranch} {targetBranch}
</div>
@@ -519,9 +518,7 @@ Important:
</div>
</div>
)}
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
</section>
);
};
@@ -4,6 +4,9 @@ import {
RiCheckboxBlankLine,
RiCheckboxLine,
RiExternalLinkLine,
RiGitClosePullRequestLine,
RiGitMergeLine,
RiGitPrDraftLine,
RiGitPullRequestLine,
RiLoader4Line,
} 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 => {
return branch
.replace(/^refs\/heads\//, '')
@@ -67,7 +92,6 @@ type PullRequestDraftSnapshot = {
title: string;
body: string;
draft: boolean;
isOpen: boolean;
additionalContext: string;
};
@@ -103,7 +127,8 @@ export const PullRequestSection: React.FC<{
directory: string;
branch: string;
baseBranch: string;
}> = ({ directory, branch, baseBranch }) => {
variant?: 'framed' | 'plain';
}> = ({ directory, branch, baseBranch, variant = 'framed' }) => {
const { github } = useRuntimeAPIs();
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
@@ -124,7 +149,6 @@ export const PullRequestSection: React.FC<{
[snapshotKey]
);
const [isOpen, setIsOpen] = React.useState(initialSnapshot?.isOpen ?? true);
const [isLoading, setIsLoading] = React.useState(false);
const [status, setStatus] = React.useState<GitHubPullRequestStatus | null>(null);
const [error, setError] = React.useState<string | null>(null);
@@ -401,7 +425,6 @@ export const PullRequestSection: React.FC<{
setTitle(snapshot?.title ?? branchToTitle(branch));
setBody(snapshot?.body ?? '');
setDraft(snapshot?.draft ?? false);
setIsOpen(snapshot?.isOpen ?? true);
void refresh();
}, [branch, refresh, snapshotKey]);
@@ -420,10 +443,9 @@ export const PullRequestSection: React.FC<{
title,
body,
draft,
isOpen,
additionalContext,
});
}, [snapshotKey, title, body, draft, isOpen, additionalContext, directory, branch]);
}, [snapshotKey, title, body, draft, additionalContext, directory, branch]);
const generateDescription = React.useCallback(async () => {
if (isGenerating) return;
@@ -538,16 +560,31 @@ export const PullRequestSection: React.FC<{
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 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 (
<Collapsible
open={isOpen}
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">
<section className={containerClassName}>
<div className={headerClassName}>
<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>
{pr ? (
<span className="typography-meta text-muted-foreground truncate">#{pr.number}</span>
@@ -562,16 +599,14 @@ export const PullRequestSection: React.FC<{
</span>
) : null}
</div>
</CollapsibleTrigger>
</div>
<CollapsibleContent>
<div className="border-t border-border/40">
<div className="flex flex-col gap-3 p-3">
{shouldShowConnectionNotice ? (
<div className="space-y-2">
<div className="typography-meta text-muted-foreground">
GitHub not connected. Connect your GitHub account in settings.
</div>
<div className={bodyClassName}>
{shouldShowConnectionNotice ? (
<div className="space-y-2">
<div className="typography-meta text-muted-foreground">
GitHub not connected. Connect your GitHub account in settings.
</div>
<Button variant="outline" size="sm" onClick={openGitHubSettings} className="w-fit">
Open settings
</Button>
@@ -599,30 +634,43 @@ export const PullRequestSection: React.FC<{
<div className="min-w-0">
<div className="typography-ui-label text-foreground truncate">{pr.title}</div>
<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' : ''}
{typeof pr.mergeableState === 'string' && pr.mergeableState ? ` · ${pr.mergeableState}` : ''}
{pr.state === 'open' && typeof pr.mergeableState === 'string' && pr.mergeableState && pr.mergeableState !== 'unknown'
? ` · ${pr.mergeableState}`
: ''}
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
{checks ? (
<div className="mt-2 space-y-2">
<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
variant="outline"
size="sm"
onClick={openChecksDialog}
disabled={isLoadingCheckDetails}
onClick={sendCommentsToChat}
className={checks ? 'justify-center sm:flex-1' : 'justify-center'}
>
{isLoadingCheckDetails ? <RiLoader4Line className="size-4 animate-spin" /> : null}
Check details
Send PR comments to chat
</Button>
) : null}
</div>
{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
</Button>
) : null}
<Button variant="outline" size="sm" onClick={sendCommentsToChat}>
Send PR comments to chat
</Button>
</div>
{canMerge && pr.draft ? (
<div className="typography-micro text-muted-foreground">
@@ -843,12 +891,10 @@ export const PullRequestSection: React.FC<{
</div>
</div>
)}
</div>
</div>
</CollapsibleContent>
</div>
<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>
<DialogTitle className="flex items-center gap-2">
<RiGitPullRequestLine className="h-5 w-5" />
@@ -859,7 +905,7 @@ export const PullRequestSection: React.FC<{
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto mt-2">
<div className="flex-1 min-h-0 overflow-y-auto mt-2">
{isLoadingCheckDetails ? (
<div className="text-center text-muted-foreground py-8 flex items-center justify-center gap-2">
<RiLoader4Line className="h-4 w-4 animate-spin" />
@@ -890,6 +936,6 @@ export const PullRequestSection: React.FC<{
</div>
</DialogContent>
</Dialog>
</Collapsible>
</section>
);
};
@@ -61,7 +61,7 @@ export const StashDialog: React.FC<StashDialogProps> = ({
<DialogTitle>Uncommitted Changes</DialogTitle>
</div>
<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?
</DialogDescription>
</DialogHeader>
@@ -72,7 +72,11 @@ export const StashDialog: React.FC<StashDialogProps> = ({
</p>
<ol className="list-decimal list-inside space-y-1 typography-meta text-foreground">
<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>}
</ol>
</div>
@@ -88,7 +92,7 @@ export const StashDialog: React.FC<StashDialogProps> = ({
className="typography-ui-label text-foreground cursor-pointer select-none"
onClick={() => !isProcessing && setRestoreAfter(!restoreAfter)}
>
Restore changes after {operation}
Restore changes after the {operation}
</span>
</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 };
}
const listUrl = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls`);
listUrl.searchParams.set('state', 'open');
listUrl.searchParams.set('head', `${repo.owner}:${branch}`);
listUrl.searchParams.set('per_page', '10');
const listNumberByHead = async (state: 'open' | 'closed'): Promise<number | null> => {
const url = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls`);
url.searchParams.set('state', state);
url.searchParams.set('head', `${repo.owner}:${branch}`);
url.searchParams.set('per_page', '10');
const listResp = await githubFetch(listUrl.toString(), accessToken);
if (listResp.status === 401) {
return { connected: false };
}
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 resp = await githubFetch(url.toString(), accessToken);
if (resp.status === 401) {
return null;
}
const openList = await jsonOrNull<Array<JsonRecord>>(openResp);
if (openResp.ok && Array.isArray(openList)) {
const match = openList.find((prItem) => {
const head = prItem?.head && typeof prItem.head === 'object' ? (prItem.head as JsonRecord) : null;
return readString(head?.ref) === branch;
});
if (match && typeof match.number === 'number') {
number = match.number;
}
const list = await jsonOrNull<Array<{ number: number }>>(resp);
return (resp.ok && Array.isArray(list) && list.length > 0) ? list[0].number : null;
};
const listNumberByHeadRef = async (state: 'open' | 'closed'): Promise<number | null> => {
const url = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls`);
url.searchParams.set('state', state);
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');
}
const merged = Boolean(prJson.merged);
const merged = Boolean(prJson.merged || prJson.merged_at);
const prState = readString(prJson.state);
const state = merged ? 'merged' : (prState === 'closed' ? 'closed' : 'open');
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 });
}
// Find PR for this branch (same-repo assumption)
const list = await octokit.rest.pulls.list({
owner: repo.owner,
repo: repo.repo,
state: 'open',
head: `${repo.owner}:${branch}`,
per_page: 10,
});
const listByHead = async (state) => {
const resp = await octokit.rest.pulls.list({
owner: repo.owner,
repo: repo.repo,
state,
head: `${repo.owner}:${branch}`,
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,
// fall back to listing open PRs and matching by head ref name.
if (!first) {
const openList = await octokit.rest.pulls.list({
owner: repo.owner,
repo: repo.repo,
state: 'open',
per_page: 100,
});
const matches = Array.isArray(openList?.data)
? openList.data.filter((pr) => pr?.head?.ref === branch)
: [];
first = matches[0] ?? null;
}
// PR status by branch:
// - Prefer open PRs.
// - If none, also surface closed/merged PRs.
// - Fork PR support: head owner != base owner -> head filter yields empty; fall back to matching head.ref.
let first = await listByHead('open');
if (!first) first = await listByHead('closed');
if (!first) first = await listByHeadRef('open');
if (!first) first = await listByHeadRef('closed');
if (!first) {
return res.json({ connected: true, repo, branch, pr: null, checks: null, canMerge: false });
}
@@ -5964,7 +5971,8 @@ async function main(options = {}) {
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({
connected: true,