feat: redesigned Git tab layout with improved organization
This commit is contained in:
+4
-1
@@ -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 & 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';
|
||||
@@ -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(() => ''),
|
||||
|
||||
Reference in New Issue
Block a user