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
@@ -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>