Files
openchamber/packages/ui/src/components/views/git/BranchIntegrationSection.tsx
T
Bohdan Triapitsyn 14357257ae perf(ui): migrate icons to SVG sprite system
Replace @remixicon/react with a shared Icon component that renders
via <use href> references to a single hidden SVG sprite. This reduces
DOM node count by replacing inline SVGs with lightweight references.

- Create Icon component with sprite injection (packages/ui/src/components/icon/)
- Migrate all 164 files from @remixicon/react to Icon component
- Auto-generate sprite data from remixicon bundle (scripts/generate-icon-sprite.mjs)
- Add bun run icons:generate to package.json
- Move @remixicon/react to devDependencies
- Add icon usage instructions to theme-system skill
2026-05-13 13:26:35 +03:00

485 lines
17 KiB
TypeScript

import React from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Icon } from "@/components/icon/Icon";
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
type OperationType = 'merge' | 'rebase';
export interface OperationLogEntry {
message: string;
status: 'pending' | 'running' | 'done' | 'error';
timestamp: number;
}
interface BranchIntegrationSectionProps {
currentBranch: string | null | undefined;
localBranches: string[];
remoteBranches: string[];
onMerge: (branch: string) => void;
onRebase: (branch: string) => void;
disabled?: boolean;
isOperating?: boolean;
operationLogs?: OperationLogEntry[];
onOperationComplete?: () => void;
mode?: 'dialog' | 'inline';
defaultTargetBranch?: string;
}
export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> = ({
currentBranch,
localBranches,
remoteBranches,
onMerge,
onRebase,
disabled = false,
isOperating = false,
operationLogs = [],
onOperationComplete,
mode = 'dialog',
defaultTargetBranch,
}) => {
const { t } = useI18n();
const [dialogOpen, setDialogOpen] = React.useState(false);
const [operation, setOperation] = React.useState<OperationType>('merge');
const [selectedBranch, setSelectedBranch] = React.useState<string | null>(null);
const [branchDropdownOpen, setBranchDropdownOpen] = React.useState(false);
const [branchSearch, setBranchSearch] = React.useState('');
const searchInputRef = React.useRef<HTMLInputElement>(null);
const logContainerRef = React.useRef<HTMLDivElement>(null);
const isDisabled = disabled || isOperating;
const targetBranchLabel = currentBranch || t('gitView.branch.currentBranchFallback');
// Check if operation completed (all logs are done or error)
const operationCompleted = operationLogs.length > 0 &&
operationLogs.every(log => log.status === 'done' || log.status === 'error');
const hasError = operationLogs.some(log => log.status === 'error');
// Auto-scroll log container when new entries are added
React.useEffect(() => {
if (logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
}
}, [operationLogs]);
// Filter branches based on search
const filteredLocal = React.useMemo(() => {
const term = branchSearch.toLowerCase();
const remoteBranchNames = new Set(
remoteBranches
.map((branch) => branch.slice(branch.indexOf('/') + 1))
.filter(Boolean)
);
const filtered = localBranches.filter((branch) => branch !== currentBranch && !remoteBranchNames.has(branch));
if (!term) return filtered;
return filtered.filter((b) => b.toLowerCase().includes(term));
}, [branchSearch, localBranches, currentBranch, remoteBranches]);
const filteredRemote = React.useMemo(() => {
const term = branchSearch.toLowerCase();
if (!term) return remoteBranches;
return remoteBranches.filter((b) => b.toLowerCase().includes(term));
}, [branchSearch, remoteBranches]);
const resolveDefaultBranch = React.useCallback(() => {
if (!defaultTargetBranch) return null;
if (remoteBranches.includes(defaultTargetBranch)) return defaultTargetBranch;
if (localBranches.includes(defaultTargetBranch)) return defaultTargetBranch;
return null;
}, [defaultTargetBranch, localBranches, remoteBranches]);
const handleOpenDialog = () => {
setDialogOpen(true);
setSelectedBranch(resolveDefaultBranch());
setOperation('merge');
setBranchSearch('');
};
const handleSelectBranch = (branch: string) => {
setSelectedBranch(branch);
setBranchDropdownOpen(false);
setBranchSearch('');
};
const handleConfirm = () => {
if (!selectedBranch) return;
// Don't close dialog - keep it open to show progress
if (operation === 'merge') {
onMerge(selectedBranch);
} else {
onRebase(selectedBranch);
}
};
const handleCancel = () => {
// Don't allow cancel during operation
if (isOperating) return;
setSelectedBranch(null);
setOperation('merge');
setBranchSearch('');
setDialogOpen(false);
};
const handleClose = () => {
// Only allow closing when operation is complete or not started
if (isOperating && !operationCompleted) return;
if (operationCompleted) {
onOperationComplete?.();
}
setSelectedBranch(null);
setOperation('merge');
setBranchSearch('');
setDialogOpen(false);
};
React.useEffect(() => {
if (!branchDropdownOpen) {
setBranchSearch('');
}
}, [branchDropdownOpen]);
React.useEffect(() => {
if (mode !== 'inline' || selectedBranch) return;
setSelectedBranch(resolveDefaultBranch());
}, [mode, resolveDefaultBranch, selectedBranch]);
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' && (
<Icon name="loader-4" className="size-3.5 animate-spin text-primary" />
)}
{log.status === 'done' && (
<Icon name="check" className="size-3.5 text-success" />
)}
{log.status === 'error' && (
<Icon name="close" 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 ? t('gitView.common.close') : t('gitView.common.done')}
</Button>
</DialogFooter>
) : (
<div className="flex justify-end">
<Button variant="default" size="sm" onClick={handleClose}>
{hasError ? t('gitView.common.close') : t('gitView.common.done')}
</Button>
</div>
)
) : null}
</div>
);
const renderForm = () => (
<div className="space-y-4">
{/* Operation Selection */}
<div className="space-y-3">
<p className="typography-meta text-muted-foreground">{t('gitView.branch.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">
<Icon name="git-merge"
className={cn('size-4', operation === 'merge' ? 'text-primary' : 'text-muted-foreground')}
/>
<span
className={cn(
'typography-ui-label',
operation === 'merge' ? 'text-foreground' : 'text-muted-foreground'
)}
>
{t('gitView.operation.merge')}
</span>
</div>
<p className="typography-micro text-muted-foreground">
{t('gitView.branch.mergeDescription')}
</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">
<Icon name="git-branch"
className={cn('size-4', operation === 'rebase' ? 'text-primary' : 'text-muted-foreground')}
/>
<span
className={cn(
'typography-ui-label',
operation === 'rebase' ? 'text-foreground' : 'text-muted-foreground'
)}
>
{t('gitView.operation.rebase')}
</span>
</div>
<p className="typography-micro text-muted-foreground">
{t('gitView.branch.rebaseDescription')}
</p>
</button>
</div>
</div>
{/* Branch Selection */}
<div className="flex flex-col gap-3">
<p className="typography-meta text-muted-foreground">
{operation === 'merge'
? t('gitView.branch.branchToMergeInto', { branch: targetBranchLabel })
: t('gitView.branch.branchToRebaseOnto')}
</p>
<DropdownMenu open={branchDropdownOpen} onOpenChange={setBranchDropdownOpen} modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="lg" className="w-full justify-between">
<span className={cn('truncate', !selectedBranch && 'text-muted-foreground')}>
{selectedBranch || t('gitView.branch.selectBranch')}
</span>
<Icon name="arrow-down-s" className="size-4 opacity-60 shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
sideOffset={6}
className="w-[var(--anchor-width)] p-0 max-h-[min(var(--available-height),24rem)] flex flex-col overflow-hidden"
>
<Command className="h-full min-h-0">
<CommandInput
ref={searchInputRef}
placeholder={t('gitView.branch.searchPlaceholder')}
value={branchSearch}
onValueChange={setBranchSearch}
onKeyDown={(event) => event.stopPropagation()}
/>
<CommandList className="h-full min-h-0" disableHorizontal>
<CommandEmpty>{t('gitView.branch.empty')}</CommandEmpty>
{filteredLocal.length > 0 && (
<CommandGroup heading={t('gitView.branch.localBranches')}>
{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={t('gitView.branch.remoteBranches')}>
{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' ? (
<>
{t('gitView.branch.summaryMergePrefix')} <span className="font-mono text-foreground">{selectedBranch}</span> {t('gitView.branch.summaryMergeInfix')} <span className="font-mono text-foreground">{targetBranchLabel}</span>
</>
) : (
<>
{t('gitView.branch.summaryRebasePrefix')} <span className="font-mono text-foreground">{targetBranchLabel}</span> {t('gitView.branch.summaryRebaseInfix')} <span className="font-mono text-foreground">{selectedBranch}</span>
</>
)}
</p>
</div>
) : null}
{mode === 'dialog' ? (
<DialogFooter className="gap-2 pt-1">
<Button variant="ghost" size="sm" onClick={handleCancel}>
{t('gitView.common.cancel')}
</Button>
<Button
variant="default"
size="sm"
onClick={handleConfirm}
disabled={!selectedBranch}
className="gap-1.5"
>
{operation === 'merge' ? (
<>
<Icon name="git-merge" className="size-4" />
{t('gitView.operation.merge')}
</>
) : (
<>
<Icon name="git-branch" className="size-4" />
{t('gitView.operation.rebase')}
</>
)}
</Button>
</DialogFooter>
) : (
<div className="flex items-center gap-2 pt-1">
<Button variant="destructive" size="sm" onClick={handleCancel} disabled={isDisabled}>
{t('gitView.common.reset')}
</Button>
<div className="flex-1" />
<Button variant="default" size="sm" onClick={handleConfirm} disabled={isDisabled || !selectedBranch}>
{operation === 'merge' ? t('gitView.operation.merge') : t('gitView.operation.rebase')}
</Button>
</div>
)}
</div>
);
const body = isOperating ? renderOperating() : renderForm();
if (mode === 'inline') {
return (
<section className="border-0 bg-transparent rounded-none">
<header className="border-b border-border/40 px-0 py-3">
<div className="space-y-1">
<div className="typography-ui-header font-semibold text-foreground">{t('gitView.branch.updateTitle')}</div>
<div className="typography-micro text-muted-foreground">
{t('gitView.branch.updateDescriptionPrefix')}{' '}
<span className="font-mono text-foreground">{targetBranchLabel}</span>.
</div>
</div>
</header>
<div className="pt-3">{body}</div>
</section>
);
}
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={handleOpenDialog}
disabled={isDisabled}
>
{isOperating ? (
<Icon name="loader-4" className="size-4 animate-spin" />
) : (
<Icon name="git-merge" className="size-4" />
)}
<span>{t('gitView.branch.mergeRebase')}</span>
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={8}>
{t('gitView.branch.mergeRebaseTooltip')}
</TooltipContent>
</Tooltip>
<Dialog open={dialogOpen} onOpenChange={(open) => {
if (!open) {
handleClose();
} else {
setDialogOpen(true);
}
}}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t('gitView.branch.updateTitle')}</DialogTitle>
<DialogDescription>
{isOperating ? (
operationCompleted ? (
hasError ? t('gitView.branch.operationFailed') : t('gitView.branch.operationCompleted')
) : (
operation === 'merge' ? t('gitView.branch.mergingInProgress') : t('gitView.branch.rebasingInProgress')
)
) : (
<>
{t('gitView.branch.dialogDescriptionPrefix')}{' '}
<span className="font-mono text-foreground">{targetBranchLabel}</span>
.
</>
)}
</DialogDescription>
</DialogHeader>
{body}
</DialogContent>
</Dialog>
</>
);
};