feat: redesigned Git tab layout with improved organization

This commit is contained in:
Bohdan Triapitsyn
2025-12-19 02:48:58 +02:00
parent 71574acf19
commit bbceccfb64
17 changed files with 1726 additions and 1058 deletions
+4 -1
View File
@@ -6,7 +6,10 @@ All notable changes to this project will be documented in this file.
- Polished chat expirience for longer session
- Fixed file link from git view to diff
- Enhancements to the inactive state management of the desktop app
- Redesigned Git tab layout with improved organization
- Fixed untracked files in new directories not showing individually
- Smoother session rename experience
## [1.2.4] - 2025-12-18
@@ -452,7 +452,8 @@ pub async fn get_git_status(
.map_err(|e| e.to_string())?;
// 1. Get porcelain status
let status_output = run_git(&["status", "--porcelain", "-b", "-z"], &path)
// Use -uall to show all untracked files individually, not just directories
let status_output = run_git(&["status", "--porcelain", "-b", "-z", "-uall"], &path)
.await
.map_err(|e| e.to_string())?;
@@ -2,7 +2,6 @@ import React from 'react';
import type { Session } from '@opencode-ai/sdk';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
DropdownMenu,
DropdownMenuContent,
@@ -690,44 +689,73 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
if (editingId === session.id) {
return (
<div key={session.id} className="flex flex-col rounded-lg border border-transparent px-2 py-2">
<form
className="flex w-full items-center justify-between gap-2"
onSubmit={(event) => {
event.preventDefault();
handleSaveEdit();
}}
>
<Input
value={editTitle}
onChange={(event) => setEditTitle(event.target.value)}
className="h-7 flex-1 border-none bg-transparent px-0 py-0 typography-micro focus-visible:ring-0 focus-visible:ring-offset-0"
autoFocus
placeholder="Rename session"
onKeyDown={(event) => {
if (event.key === 'Escape') handleCancelEdit();
<div
key={session.id}
className={cn(
'group relative flex items-center rounded-md px-1.5 py-1',
'dark:bg-accent/80 bg-primary/12',
depth > 0 && 'pl-[20px]',
)}
>
<div className="flex min-w-0 flex-1 flex-col gap-0">
<form
className="flex w-full items-center gap-2"
onSubmit={(event) => {
event.preventDefault();
handleSaveEdit();
}}
/>
<div className="flex items-center gap-1">
<Button size="icon" variant="ghost" className="h-6 w-6 p-0" type="submit">
<RiCheckLine className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 p-0"
>
<input
value={editTitle}
onChange={(event) => setEditTitle(event.target.value)}
className="flex-1 min-w-0 bg-transparent typography-ui-label outline-none placeholder:text-muted-foreground"
autoFocus
placeholder="Rename session"
onKeyDown={(event) => {
if (event.key === 'Escape') handleCancelEdit();
}}
/>
<button
type="submit"
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<RiCheckLine className="size-4" />
</button>
<button
type="button"
onClick={handleCancelEdit}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<RiCloseLine className="h-4 w-4" />
</Button>
<RiCloseLine className="size-4" />
</button>
</form>
<div className="flex items-center gap-2 typography-micro text-muted-foreground/60 min-w-0 overflow-hidden leading-tight">
{hasChildren ? (
<span className="inline-flex items-center justify-center flex-shrink-0">
{isExpanded ? (
<RiArrowDownSLine className="h-3 w-3" />
) : (
<RiArrowRightSLine className="h-3 w-3" />
)}
</span>
) : null}
<span className="flex-shrink-0">{formatDateLabel(session.time?.created || Date.now())}</span>
{session.share ? (
<RiShare2Line className="h-3 w-3 text-[color:var(--status-info)] flex-shrink-0" />
) : null}
{hasSummary && ((additions ?? 0) !== 0 || (deletions ?? 0) !== 0) ? (
<span className="flex-shrink-0 text-[0.7rem] leading-none">
<span className="text-[color:var(--status-success)]">+{Math.max(0, additions ?? 0)}</span>
<span className="text-muted-foreground/50">/</span>
<span className="text-destructive">-{Math.max(0, deletions ?? 0)}</span>
</span>
) : null}
{hasChildren ? (
<span className="truncate">
{node.children.length} {node.children.length === 1 ? 'task' : 'tasks'}
</span>
) : null}
</div>
</form>
<div className="flex items-center gap-2 pt-1 typography-micro text-muted-foreground/70 overflow-hidden">
<span className="flex-shrink-0">{formatDateLabel(session.time?.created || Date.now())}</span>
{session.share && (
<RiShare2Line className="h-3 w-3 text-[color:var(--status-info)] flex-shrink-0" />
)}
</div>
</div>
);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,56 @@
import React from 'react';
import { RiArrowDownLine } from '@remixicon/react';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
interface AIHighlightsBoxProps {
highlights: string[];
onInsert: () => void;
onClear: () => void;
}
export const AIHighlightsBox: React.FC<AIHighlightsBoxProps> = ({
highlights,
onInsert,
onClear,
}) => {
if (highlights.length === 0) {
return null;
}
const handleInsert = () => {
onInsert();
onClear();
};
return (
<div className="space-y-2 rounded-xl border border-border/60 bg-background/60 px-3 py-2">
<div className="flex items-center justify-between gap-2">
<p className="typography-micro text-muted-foreground">AI highlights</p>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={handleInsert}
aria-label="Insert highlights into commit message"
>
<RiArrowDownLine className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={8}>
Append highlights to commit message
</TooltipContent>
</Tooltip>
</div>
<ul className="space-y-1">
{highlights.map((highlight, index) => (
<li key={index} className="typography-meta text-foreground">
{highlight}
</li>
))}
</ul>
</div>
);
};
@@ -0,0 +1,272 @@
import React from 'react';
import {
RiGitBranchLine,
RiArrowDownSLine,
RiAddLine,
RiCloseLine,
RiLoader4Line,
} from '@remixicon/react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
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';
interface BranchInfo {
ahead?: number;
behind?: number;
}
interface BranchSelectorProps {
currentBranch: string | null | undefined;
localBranches: string[];
remoteBranches: string[];
branchInfo: Record<string, BranchInfo> | undefined;
onCheckout: (branch: string) => void;
onCreate: (name: string) => Promise<void>;
disabled?: boolean;
}
const sanitizeBranchNameInput = (value: string): string => {
return value
.trim()
.replace(/\s+/g, '-')
.replace(/[^A-Za-z0-9._/-]/g, '-')
.replace(/-+/g, '-')
.replace(/\/{2,}/g, '/')
.replace(/\/-+/g, '/')
.replace(/-+\//g, '/')
.replace(/^[-/]+/, '')
.replace(/[-/]+$/, '');
};
export const BranchSelector: React.FC<BranchSelectorProps> = ({
currentBranch,
localBranches,
remoteBranches,
branchInfo,
onCheckout,
onCreate,
disabled = false,
}) => {
const [isOpen, setIsOpen] = React.useState(false);
const [search, setSearch] = React.useState('');
const [showCreate, setShowCreate] = React.useState(false);
const [newBranchName, setNewBranchName] = React.useState('');
const [isCreating, setIsCreating] = React.useState(false);
const createInputRef = React.useRef<HTMLInputElement>(null);
const sanitizedNewBranch = React.useMemo(
() => sanitizeBranchNameInput(newBranchName),
[newBranchName]
);
const filteredLocal = React.useMemo(() => {
const term = search.toLowerCase();
if (!term) return localBranches;
return localBranches.filter((b) => b.toLowerCase().includes(term));
}, [search, localBranches]);
const filteredRemote = React.useMemo(() => {
const term = search.toLowerCase();
if (!term) return remoteBranches;
return remoteBranches.filter((b) => b.toLowerCase().includes(term));
}, [search, remoteBranches]);
const handleCheckout = (branch: string) => {
if (branch === currentBranch) {
setIsOpen(false);
return;
}
onCheckout(branch);
setIsOpen(false);
setSearch('');
};
const handleShowCreate = () => {
setShowCreate(true);
setTimeout(() => createInputRef.current?.focus(), 50);
};
const handleCreate = async () => {
if (!sanitizedNewBranch || isCreating) return;
setIsCreating(true);
try {
await onCreate(sanitizedNewBranch);
setNewBranchName('');
setShowCreate(false);
setIsOpen(false);
} finally {
setIsCreating(false);
}
};
const handleCancelCreate = () => {
setNewBranchName('');
setShowCreate(false);
};
React.useEffect(() => {
if (!isOpen) {
setSearch('');
setShowCreate(false);
setNewBranchName('');
}
}, [isOpen]);
return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="gap-1.5 px-2 py-1 h-8"
disabled={disabled}
>
<RiGitBranchLine className="size-4 text-primary" />
<span className="max-w-[140px] truncate font-medium">
{currentBranch || 'Detached HEAD'}
</span>
<RiArrowDownSLine className="size-4 opacity-60" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent sideOffset={8}>
Switch branch ({localBranches.length} local · {remoteBranches.length} remote)
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="start" className="w-72 p-0 max-h-[60vh] flex flex-col">
<Command className="h-full min-h-0">
<CommandInput
placeholder="Search branches..."
value={search}
onValueChange={setSearch}
/>
<CommandList
scrollbarClassName="overlay-scrollbar--flush overlay-scrollbar--dense overlay-scrollbar--zero"
disableHorizontal
>
<CommandEmpty>No branches found.</CommandEmpty>
<CommandGroup>
{!showCreate ? (
<CommandItem onSelect={handleShowCreate}>
<RiAddLine className="size-4" />
<span>Create new branch...</span>
</CommandItem>
) : (
<div className="flex items-center gap-2 px-2 py-1.5 rounded-lg">
<input
ref={createInputRef}
placeholder="New branch name"
value={newBranchName}
onChange={(e) => setNewBranchName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleCreate();
} else if (e.key === 'Escape') {
e.preventDefault();
handleCancelCreate();
}
}}
className="flex-1 min-w-0 bg-transparent typography-meta outline-none placeholder:text-muted-foreground"
/>
<button
type="button"
onClick={handleCreate}
disabled={!sanitizedNewBranch || isCreating}
className="shrink-0 text-muted-foreground hover:text-foreground disabled:opacity-50"
>
{isCreating ? (
<RiLoader4Line className="size-4 animate-spin" />
) : (
<RiAddLine className="size-4" />
)}
</button>
<button
type="button"
onClick={handleCancelCreate}
disabled={isCreating}
className="shrink-0 text-muted-foreground hover:text-foreground disabled:opacity-50"
>
<RiCloseLine className="size-4" />
</button>
</div>
)}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Local branches">
{filteredLocal.map((branch) => (
<CommandItem
key={`local-${branch}`}
onSelect={() => handleCheckout(branch)}
>
<span className="flex flex-1 flex-col">
<span className="typography-ui-label text-foreground">
{branch}
</span>
{(branchInfo?.[branch]?.ahead || branchInfo?.[branch]?.behind) && (
<span className="typography-micro text-muted-foreground">
{branchInfo[branch].ahead || 0} ahead ·{' '}
{branchInfo[branch].behind || 0} behind
</span>
)}
</span>
{currentBranch === branch && (
<span className="typography-micro text-primary">Current</span>
)}
</CommandItem>
))}
{filteredLocal.length === 0 && (
<CommandItem disabled className="justify-center">
<span className="typography-meta text-muted-foreground">
No local branches
</span>
</CommandItem>
)}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Remote branches">
{filteredRemote.map((branch) => (
<CommandItem
key={`remote-${branch}`}
onSelect={() => handleCheckout(branch)}
>
<span className="typography-ui-label text-foreground">{branch}</span>
</CommandItem>
))}
{filteredRemote.length === 0 && (
<CommandItem disabled className="justify-center">
<span className="typography-meta text-muted-foreground">
No remote branches
</span>
</CommandItem>
)}
</CommandGroup>
</CommandList>
</Command>
</DropdownMenuContent>
</DropdownMenu>
);
};
@@ -0,0 +1,140 @@
import React from 'react';
import {
RiCheckboxLine,
RiCheckboxBlankLine,
RiRefreshLine,
RiLoader4Line,
} from '@remixicon/react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import type { GitStatus } from '@/lib/api/types';
interface ChangeRowProps {
file: GitStatus['files'][number];
checked: boolean;
onToggle: () => void;
onViewDiff: () => void;
onRevert: () => void;
isReverting: boolean;
stats?: { insertions: number; deletions: number };
}
function describeChange(file: GitStatus['files'][number]) {
const rawCode =
file.index && file.index.trim() && file.index.trim() !== '?'
? file.index.trim()
: file.working_dir && file.working_dir.trim()
? file.working_dir.trim()
: file.index || file.working_dir || ' ';
const symbol = rawCode.trim().charAt(0) || rawCode.trim() || '·';
switch (symbol) {
case '?':
return { code: '?', color: 'var(--status-info)', description: 'Untracked file' };
case 'A':
return { code: 'A', color: 'var(--status-success)', description: 'New file' };
case 'D':
return { code: 'D', color: 'var(--status-error)', description: 'Deleted file' };
case 'R':
return { code: 'R', color: 'var(--status-info)', description: 'Renamed file' };
case 'C':
return { code: 'C', color: 'var(--status-info)', description: 'Copied file' };
default:
return { code: 'M', color: 'var(--status-warning)', description: 'Modified file' };
}
}
export const ChangeRow: React.FC<ChangeRowProps> = ({
file,
checked,
onToggle,
onViewDiff,
onRevert,
isReverting,
stats,
}) => {
const descriptor = React.useMemo(() => describeChange(file), [file]);
const indicatorLabel = descriptor.description ?? descriptor.code;
const insertions = stats?.insertions ?? 0;
const deletions = stats?.deletions ?? 0;
return (
<li>
<div
className="group flex items-center gap-2 px-3 py-1.5 hover:bg-sidebar/40 cursor-pointer"
role="button"
tabIndex={0}
onClick={onViewDiff}
onKeyDown={(event) => {
if (event.key === ' ') {
event.preventDefault();
onToggle();
} else if (event.key === 'Enter') {
event.preventDefault();
onViewDiff();
}
}}
>
<button
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onToggle();
}}
aria-pressed={checked}
aria-label={`Select ${file.path}`}
className="flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
{checked ? (
<RiCheckboxLine className="size-4 text-primary" />
) : (
<RiCheckboxBlankLine className="size-4" />
)}
</button>
<span
className="typography-micro font-semibold w-4 text-center uppercase"
style={{ color: descriptor.color }}
title={indicatorLabel}
aria-label={indicatorLabel}
>
{descriptor.code}
</span>
<span
className="flex-1 min-w-0 truncate typography-ui-label text-foreground"
style={{ direction: 'rtl', textAlign: 'left' }}
title={file.path}
>
{file.path}
</span>
<span className="shrink-0 typography-micro">
<span style={{ color: 'var(--status-success)' }}>+{insertions}</span>
<span className="text-muted-foreground mx-0.5">/</span>
<span style={{ color: 'var(--status-error)' }}>-{deletions}</span>
</span>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<button
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onRevert();
}}
disabled={isReverting}
className="flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground opacity-0 group-hover:opacity-100 hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50 transition-opacity"
aria-label={`Revert changes for ${file.path}`}
>
{isReverting ? (
<RiLoader4Line className="size-3.5 animate-spin" />
) : (
<RiRefreshLine className="size-3.5" />
)}
</button>
</TooltipTrigger>
<TooltipContent sideOffset={8}>Revert changes</TooltipContent>
</Tooltip>
</div>
</li>
);
};
@@ -0,0 +1,82 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { ChangeRow } from './ChangeRow';
import type { GitStatus } from '@/lib/api/types';
interface ChangesSectionProps {
changeEntries: GitStatus['files'];
selectedPaths: Set<string>;
diffStats: Record<string, { insertions: number; deletions: number }> | undefined;
revertingPaths: Set<string>;
onToggleFile: (path: string) => void;
onSelectAll: () => void;
onClearSelection: () => void;
onViewDiff: (path: string) => void;
onRevertFile: (path: string) => void;
}
export const ChangesSection: React.FC<ChangesSectionProps> = ({
changeEntries,
selectedPaths,
diffStats,
revertingPaths,
onToggleFile,
onSelectAll,
onClearSelection,
onViewDiff,
onRevertFile,
}) => {
const selectedCount = selectedPaths.size;
const totalCount = changeEntries.length;
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">
<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">
{selectedCount}/{totalCount}
</span>
{totalCount > 0 && (
<>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={onSelectAll}
>
All
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={onClearSelection}
disabled={selectedCount === 0}
>
None
</Button>
</>
)}
</div>
</header>
<ScrollableOverlay outerClassName="flex-1 min-h-0 max-h-[30vh]" className="w-full">
<ul className="divide-y divide-border/60">
{changeEntries.map((file) => (
<ChangeRow
key={file.path}
file={file}
checked={selectedPaths.has(file.path)}
stats={diffStats?.[file.path]}
onToggle={() => onToggleFile(file.path)}
onViewDiff={() => onViewDiff(file.path)}
onRevert={() => onRevertFile(file.path)}
isReverting={revertingPaths.has(file.path)}
/>
))}
</ul>
</ScrollableOverlay>
</section>
);
};
@@ -0,0 +1,74 @@
import React from 'react';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
interface CommitInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
}
export const CommitInput: React.FC<CommitInputProps> = ({
value,
onChange,
placeholder = 'Commit message',
disabled = false,
}) => {
const [isExpanded, setIsExpanded] = React.useState(false);
const hasMultipleLines = value.includes('\n');
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const shouldShowTextarea = isExpanded || hasMultipleLines;
const handleInputFocus = () => {
setIsExpanded(true);
};
const handleTextareaBlur = () => {
if (!hasMultipleLines && !value.trim()) {
setIsExpanded(false);
}
};
React.useEffect(() => {
if (shouldShowTextarea && textareaRef.current) {
textareaRef.current.focus();
const len = textareaRef.current.value.length;
textareaRef.current.setSelectionRange(len, len);
}
}, [shouldShowTextarea]);
if (shouldShowTextarea) {
return (
<Textarea
ref={textareaRef}
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={handleTextareaBlur}
placeholder={placeholder}
rows={4}
disabled={disabled}
className={cn(
'rounded-lg bg-background/80 resize-none min-h-[100px]',
disabled && 'opacity-50'
)}
/>
);
}
return (
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={handleInputFocus}
placeholder={placeholder}
disabled={disabled}
className={cn(
'rounded-lg bg-background/80',
disabled && 'opacity-50'
)}
/>
);
};
@@ -0,0 +1,151 @@
import React from 'react';
import {
RiGitCommitLine,
RiArrowUpLine,
RiAiGenerate2,
RiLoader4Line,
} from '@remixicon/react';
import {
Collapsible,
CollapsibleContent,
} from '@/components/ui/collapsible';
import { Button } from '@/components/ui/button';
import { ButtonLarge } from '@/components/ui/button-large';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { CommitInput } from './CommitInput';
import { AIHighlightsBox } from './AIHighlightsBox';
type CommitAction = 'commit' | 'commitAndPush' | null;
interface CommitSectionProps {
selectedCount: number;
commitMessage: string;
onCommitMessageChange: (value: string) => void;
generatedHighlights: string[];
onInsertHighlights: () => void;
onClearHighlights: () => void;
onGenerateMessage: () => void;
isGeneratingMessage: boolean;
onCommit: () => void;
onCommitAndPush: () => void;
commitAction: CommitAction;
isBusy: boolean;
}
export const CommitSection: React.FC<CommitSectionProps> = ({
selectedCount,
commitMessage,
onCommitMessageChange,
generatedHighlights,
onInsertHighlights,
onClearHighlights,
onGenerateMessage,
isGeneratingMessage,
onCommit,
onCommitAndPush,
commitAction,
isBusy,
}) => {
const hasSelectedFiles = selectedCount > 0;
const canCommit = commitMessage.trim() && hasSelectedFiles && commitAction === null;
return (
<Collapsible
open={hasSelectedFiles}
className="rounded-xl border border-border/60 bg-background/70 overflow-hidden"
>
<div className="flex w-full items-center justify-between px-3 py-2">
<h3 className="typography-ui-header font-semibold text-foreground">Commit</h3>
<span className="typography-meta text-muted-foreground">
{hasSelectedFiles
? `${selectedCount} file${selectedCount === 1 ? '' : 's'} selected`
: 'No files selected'}
</span>
</div>
<CollapsibleContent>
<div className="flex flex-col gap-3 p-3 pt-0">
<AIHighlightsBox
highlights={generatedHighlights}
onInsert={onInsertHighlights}
onClear={onClearHighlights}
/>
<CommitInput
value={commitMessage}
onChange={onCommitMessageChange}
placeholder="Commit message"
disabled={commitAction !== null}
/>
<div className="flex items-center gap-2">
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={onGenerateMessage}
disabled={
isGeneratingMessage ||
commitAction !== null ||
selectedCount === 0 ||
isBusy
}
aria-label="Generate commit message"
>
{isGeneratingMessage ? (
<RiLoader4Line className="size-4 animate-spin" />
) : (
<RiAiGenerate2 className="size-4 text-primary" />
)}
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={8}>
Generate commit message with AI
</TooltipContent>
</Tooltip>
<div className="flex-1" />
<ButtonLarge
variant="outline"
onClick={onCommit}
disabled={!canCommit || isGeneratingMessage}
>
{commitAction === 'commit' ? (
<>
<RiLoader4Line className="size-4 animate-spin" />
Committing...
</>
) : (
<>
<RiGitCommitLine className="size-4" />
Commit
</>
)}
</ButtonLarge>
<ButtonLarge
variant="default"
onClick={onCommitAndPush}
disabled={!canCommit || isGeneratingMessage}
>
{commitAction === 'commitAndPush' ? (
<>
<RiLoader4Line className="size-4 animate-spin" />
Pushing...
</>
) : (
<>
<RiArrowUpLine className="size-4" />
Commit &amp; Push
</>
)}
</ButtonLarge>
</div>
</div>
</CollapsibleContent>
</Collapsible>
);
};
@@ -0,0 +1,42 @@
import React from 'react';
import { RiGitCommitLine, RiArrowDownLine, RiLoader4Line } from '@remixicon/react';
import { Button } from '@/components/ui/button';
interface GitEmptyStateProps {
behind: number;
onPull: () => void;
isPulling: boolean;
}
export const GitEmptyState: React.FC<GitEmptyStateProps> = ({
behind,
onPull,
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" />
<p className="typography-ui-label font-semibold text-foreground mb-1">
Working tree clean
</p>
<p className="typography-meta text-muted-foreground mb-4">
All changes have been committed
</p>
{behind > 0 && (
<Button
variant="outline"
onClick={onPull}
disabled={isPulling}
>
{isPulling ? (
<RiLoader4Line className="size-4 animate-spin" />
) : (
<RiArrowDownLine className="size-4" />
)}
Pull {behind} commit{behind === 1 ? '' : 's'}
</Button>
)}
</div>
);
};
@@ -0,0 +1,244 @@
import React from 'react';
import {
RiArrowUpLine,
RiArrowDownLine,
RiArrowDownSLine,
RiLoader4Line,
RiGitBranchLine,
RiBriefcaseLine,
RiHomeLine,
RiGraduationCapLine,
RiCodeLine,
RiHeartLine,
RiUser3Line,
} from '@remixicon/react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { BranchSelector } from './BranchSelector';
import { SyncActions } from './SyncActions';
import type { GitStatus, GitIdentityProfile } from '@/lib/api/types';
type SyncAction = 'fetch' | 'pull' | 'push' | null;
interface GitHeaderProps {
status: GitStatus | null;
localBranches: string[];
remoteBranches: string[];
branchInfo: Record<string, { ahead?: number; behind?: number }> | undefined;
syncAction: SyncAction;
onFetch: () => void;
onPull: () => void;
onPush: () => void;
onCheckoutBranch: (branch: string) => void;
onCreateBranch: (name: string) => Promise<void>;
activeIdentityProfile: GitIdentityProfile | null;
availableIdentities: GitIdentityProfile[];
onSelectIdentity: (profile: GitIdentityProfile) => void;
isApplyingIdentity: boolean;
isWorktreeMode: boolean;
}
const IDENTITY_ICON_MAP: Record<
string,
React.ComponentType<React.ComponentProps<typeof RiGitBranchLine>>
> = {
branch: RiGitBranchLine,
briefcase: RiBriefcaseLine,
house: RiHomeLine,
graduation: RiGraduationCapLine,
code: RiCodeLine,
heart: RiHeartLine,
user: RiUser3Line,
};
const IDENTITY_COLOR_MAP: Record<string, string> = {
keyword: 'var(--syntax-keyword)',
error: 'var(--status-error)',
string: 'var(--syntax-string)',
function: 'var(--syntax-function)',
type: 'var(--syntax-type)',
success: 'var(--status-success)',
info: 'var(--status-info)',
warning: 'var(--status-warning)',
};
function getIdentityColor(token?: string | null) {
if (!token) {
return 'var(--primary)';
}
return IDENTITY_COLOR_MAP[token] || 'var(--primary)';
}
interface IdentityIconProps {
icon?: string | null;
className?: string;
colorToken?: string | null;
}
const IdentityIcon: React.FC<IdentityIconProps> = ({ icon, className, colorToken }) => {
const IconComponent = IDENTITY_ICON_MAP[icon ?? 'branch'] ?? RiUser3Line;
return (
<IconComponent
className={className}
style={{ color: getIdentityColor(colorToken) }}
/>
);
};
interface IdentityDropdownProps {
activeProfile: GitIdentityProfile | null;
identities: GitIdentityProfile[];
onSelect: (profile: GitIdentityProfile) => void;
isApplying: boolean;
}
const IdentityDropdown: React.FC<IdentityDropdownProps> = ({
activeProfile,
identities,
onSelect,
isApplying,
}) => {
const isDisabled = isApplying || identities.length === 0;
return (
<DropdownMenu>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="gap-1.5 px-2 py-1 h-8 typography-ui-label"
style={{ color: getIdentityColor(activeProfile?.color) }}
disabled={isDisabled}
>
{isApplying ? (
<RiLoader4Line className="size-4 animate-spin" />
) : (
<IdentityIcon
icon={activeProfile?.icon}
colorToken={activeProfile?.color}
className="size-4"
/>
)}
<span className="max-w-[120px] truncate hidden sm:inline">
{activeProfile?.name || 'No identity'}
</span>
<RiArrowDownSLine className="size-4 opacity-60" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent sideOffset={8} className="space-y-1">
<p className="typography-ui-label text-foreground">
{activeProfile?.userName || 'Unknown user'}
</p>
<p className="typography-meta text-muted-foreground">
{activeProfile?.userEmail || 'No email configured'}
</p>
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="w-64">
{identities.length === 0 ? (
<div className="px-2 py-1.5">
<p className="typography-meta text-muted-foreground">
No profiles available to apply.
</p>
</div>
) : (
identities.map((profile) => (
<DropdownMenuItem key={profile.id} onSelect={() => onSelect(profile)}>
<span className="flex items-center gap-2">
<IdentityIcon
icon={profile.icon}
colorToken={profile.color}
className="size-4"
/>
<span className="flex flex-col">
<span className="typography-ui-label text-foreground">
{profile.name}
</span>
<span className="typography-meta text-muted-foreground">
{profile.userEmail}
</span>
</span>
</span>
</DropdownMenuItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
);
};
export const GitHeader: React.FC<GitHeaderProps> = ({
status,
localBranches,
remoteBranches,
branchInfo,
syncAction,
onFetch,
onPull,
onPush,
onCheckoutBranch,
onCreateBranch,
activeIdentityProfile,
availableIdentities,
onSelectIdentity,
isApplyingIdentity,
isWorktreeMode,
}) => {
if (!status) {
return null;
}
return (
<header className="flex flex-wrap items-center gap-2 border-b border-border/40 px-3 py-2 bg-background">
{!isWorktreeMode && (
<BranchSelector
currentBranch={status.current}
localBranches={localBranches}
remoteBranches={remoteBranches}
branchInfo={branchInfo}
onCheckout={onCheckoutBranch}
onCreate={onCreateBranch}
/>
)}
{status.tracking && (
<div className="flex items-center gap-2 px-1.5 typography-meta text-muted-foreground">
<span className="flex items-center gap-0.5">
<RiArrowUpLine className="size-3.5 text-primary/70" />
<span className="font-semibold text-foreground">{status.ahead}</span>
</span>
<span className="flex items-center gap-0.5">
<RiArrowDownLine className="size-3.5 text-primary/70" />
<span className="font-semibold text-foreground">{status.behind}</span>
</span>
</div>
)}
<SyncActions
syncAction={syncAction}
onFetch={onFetch}
onPull={onPull}
onPush={onPush}
disabled={!status}
/>
<div className="flex-1" />
<IdentityDropdown
activeProfile={activeIdentityProfile}
identities={availableIdentities}
onSelect={onSelectIdentity}
isApplying={isApplyingIdentity}
/>
</header>
);
};
@@ -0,0 +1,160 @@
import React from 'react';
import { RiLoader4Line, RiFileCopyLine } from '@remixicon/react';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import type { GitLogEntry, CommitFileEntry } from '@/lib/api/types';
interface HistoryCommitRowProps {
entry: GitLogEntry;
isExpanded: boolean;
onToggle: () => void;
files: CommitFileEntry[];
isLoadingFiles: boolean;
onCopyHash: (hash: string) => void;
}
function formatCommitDate(date: string) {
const value = new Date(date);
if (Number.isNaN(value.getTime())) {
return date;
}
return value.toLocaleString(undefined, {
hour12: false,
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function getChangeTypeColor(changeType: string) {
switch (changeType) {
case 'A':
return 'text-emerald-500';
case 'D':
return 'text-red-500';
case 'M':
return 'text-amber-500';
case 'R':
return 'text-blue-500';
default:
return 'text-muted-foreground';
}
}
export const HistoryCommitRow: React.FC<HistoryCommitRowProps> = ({
entry,
isExpanded,
onToggle,
files,
isLoadingFiles,
onCopyHash,
}) => {
return (
<li>
<button
type="button"
onClick={onToggle}
className={cn(
'w-full flex items-start gap-3 px-3 py-2 text-left transition-colors',
isExpanded ? 'bg-sidebar/90' : 'hover:bg-sidebar/40'
)}
>
<div
className="h-2 w-2 translate-y-2 rounded-full shrink-0"
style={{ backgroundColor: 'var(--status-success)' }}
aria-hidden
/>
<div className="min-w-0 flex-1">
<p className="typography-ui-label font-medium text-foreground line-clamp-1">
{entry.message}
</p>
<div className="flex items-center gap-1 typography-meta text-muted-foreground">
<div className="flex items-center gap-1 min-w-0 truncate">
<span className="truncate min-w-[3ch]" title={entry.author_name}>
{entry.author_name}
</span>
<span className="shrink-0">·</span>
<span className="truncate min-w-0" title={formatCommitDate(entry.date)}>
{formatCommitDate(entry.date)}
</span>
</div>
<span className="shrink-0">·</span>
<code className="shrink-0 font-mono">
{entry.hash.slice(0, 8)}
</code>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-5 px-1 shrink-0"
onClick={(e) => {
e.stopPropagation();
onCopyHash(entry.hash);
}}
>
<RiFileCopyLine className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={8}>Copy SHA</TooltipContent>
</Tooltip>
</div>
</div>
</button>
{isExpanded && (
<div className="px-3 pb-2 pl-8 border-t border-border/40">
{isLoadingFiles ? (
<div className="flex items-center gap-2 py-2">
<RiLoader4Line className="size-4 animate-spin text-muted-foreground" />
<span className="typography-micro text-muted-foreground">Loading files...</span>
</div>
) : files.length === 0 ? (
<p className="typography-micro text-muted-foreground py-2">No files</p>
) : (
<ul className="space-y-0.5 py-2">
{files.map((file) => (
<li
key={file.path}
className="flex items-center gap-2 typography-micro"
>
<span
className={cn(
'font-semibold w-3 text-center',
getChangeTypeColor(file.changeType)
)}
>
{file.changeType}
</span>
<span className="truncate text-foreground min-w-0" title={file.path}>
{file.path}
</span>
{!file.isBinary && (
<span className="shrink-0">
<span style={{ color: 'var(--status-success)' }}>
+{file.insertions}
</span>
<span className="text-muted-foreground mx-0.5">/</span>
<span style={{ color: 'var(--status-error)' }}>
-{file.deletions}
</span>
</span>
)}
{file.isBinary && (
<span className="typography-micro text-muted-foreground shrink-0">
binary
</span>
)}
</li>
))}
</ul>
)}
</div>
)}
</li>
);
};
@@ -0,0 +1,126 @@
import React from 'react';
import { RiArrowUpSLine, RiArrowDownSLine } from '@remixicon/react';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { HistoryCommitRow } from './HistoryCommitRow';
import type { GitLogEntry, CommitFileEntry } from '@/lib/api/types';
const LOG_SIZE_OPTIONS = [
{ label: '25 commits', value: 25 },
{ label: '50 commits', value: 50 },
{ label: '100 commits', value: 100 },
];
interface HistorySectionProps {
log: { all: GitLogEntry[] } | null;
isLogLoading: boolean;
logMaxCount: number;
onLogMaxCountChange: (count: number) => void;
expandedCommitHashes: Set<string>;
onToggleCommit: (hash: string) => void;
commitFilesMap: Map<string, CommitFileEntry[]>;
loadingCommitHashes: Set<string>;
onCopyHash: (hash: string) => void;
}
export const HistorySection: React.FC<HistorySectionProps> = ({
log,
isLogLoading,
logMaxCount,
onLogMaxCountChange,
expandedCommitHashes,
onToggleCommit,
commitFilesMap,
loadingCommitHashes,
onCopyHash,
}) => {
const [isOpen, setIsOpen] = React.useState(true);
const commitCount = log?.all.length ?? 0;
if (!log) {
return null;
}
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">
<h3 className="typography-ui-header font-semibold text-foreground">History</h3>
<div className="flex items-center gap-2">
{isOpen && (
<div
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<Select
value={String(logMaxCount)}
onValueChange={(value) => onLogMaxCountChange(Number(value))}
disabled={isLogLoading}
>
<SelectTrigger
size="sm"
className="data-[size=sm]:h-auto h-7 min-h-7 w-auto justify-between px-2 py-0"
disabled={isLogLoading}
>
<SelectValue placeholder="Commits" />
</SelectTrigger>
<SelectContent>
{LOG_SIZE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={String(option.value)}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{isOpen ? (
<RiArrowUpSLine className="size-4 text-muted-foreground" />
) : (
<RiArrowDownSLine className="size-4 text-muted-foreground" />
)}
</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>
</Collapsible>
);
};
@@ -0,0 +1,93 @@
import React from 'react';
import {
RiRefreshLine,
RiArrowDownLine,
RiArrowUpLine,
RiLoader4Line,
} from '@remixicon/react';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
type SyncAction = 'fetch' | 'pull' | 'push' | null;
interface SyncActionsProps {
syncAction: SyncAction;
onFetch: () => void;
onPull: () => void;
onPush: () => void;
disabled: boolean;
}
export const SyncActions: React.FC<SyncActionsProps> = ({
syncAction,
onFetch,
onPull,
onPush,
disabled,
}) => {
const isDisabled = disabled || syncAction !== null;
return (
<div className="flex items-center gap-0.5">
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 px-2"
onClick={onFetch}
disabled={isDisabled}
>
{syncAction === 'fetch' ? (
<RiLoader4Line className="size-4 animate-spin" />
) : (
<RiRefreshLine className="size-4" />
)}
<span className="hidden sm:inline">Fetch</span>
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={8}>Fetch from remote</TooltipContent>
</Tooltip>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 px-2"
onClick={onPull}
disabled={isDisabled}
>
{syncAction === 'pull' ? (
<RiLoader4Line className="size-4 animate-spin" />
) : (
<RiArrowDownLine className="size-4" />
)}
<span className="hidden sm:inline">Pull</span>
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={8}>Pull changes</TooltipContent>
</Tooltip>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 px-2"
onClick={onPush}
disabled={isDisabled}
>
{syncAction === 'push' ? (
<RiLoader4Line className="size-4 animate-spin" />
) : (
<RiArrowUpLine className="size-4" />
)}
<span className="hidden sm:inline">Push</span>
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={8}>Push changes</TooltipContent>
</Tooltip>
</div>
);
};
@@ -0,0 +1,11 @@
export { GitHeader } from './GitHeader';
export { GitEmptyState } from './GitEmptyState';
export { ChangesSection } from './ChangesSection';
export { ChangeRow } from './ChangeRow';
export { CommitSection } from './CommitSection';
export { CommitInput } from './CommitInput';
export { AIHighlightsBox } from './AIHighlightsBox';
export { HistorySection } from './HistorySection';
export { HistoryCommitRow } from './HistoryCommitRow';
export { SyncActions } from './SyncActions';
export { BranchSelector } from './BranchSelector';
+2 -1
View File
@@ -137,7 +137,8 @@ export async function getStatus(directory) {
const git = simpleGit(directory);
try {
const status = await git.status();
// Use -uall to show all untracked files individually, not just directories
const status = await git.status(['-uall']);
const [stagedStatsRaw, workingStatsRaw] = await Promise.all([
git.raw(['diff', '--cached', '--numstat']).catch(() => ''),