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
|
- Polished chat expirience for longer session
|
||||||
- Fixed file link from git view to diff
|
- 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
|
## [1.2.4] - 2025-12-18
|
||||||
|
|
||||||
|
|||||||
@@ -452,7 +452,8 @@ pub async fn get_git_status(
|
|||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
// 1. Get porcelain status
|
// 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
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import React from 'react';
|
|||||||
import type { Session } from '@opencode-ai/sdk';
|
import type { Session } from '@opencode-ai/sdk';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -690,44 +689,73 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
|
|
||||||
if (editingId === session.id) {
|
if (editingId === session.id) {
|
||||||
return (
|
return (
|
||||||
<div key={session.id} className="flex flex-col rounded-lg border border-transparent px-2 py-2">
|
<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
|
<form
|
||||||
className="flex w-full items-center justify-between gap-2"
|
className="flex w-full items-center gap-2"
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
handleSaveEdit();
|
handleSaveEdit();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Input
|
<input
|
||||||
value={editTitle}
|
value={editTitle}
|
||||||
onChange={(event) => setEditTitle(event.target.value)}
|
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"
|
className="flex-1 min-w-0 bg-transparent typography-ui-label outline-none placeholder:text-muted-foreground"
|
||||||
autoFocus
|
autoFocus
|
||||||
placeholder="Rename session"
|
placeholder="Rename session"
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === 'Escape') handleCancelEdit();
|
if (event.key === 'Escape') handleCancelEdit();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-1">
|
<button
|
||||||
<Button size="icon" variant="ghost" className="h-6 w-6 p-0" type="submit">
|
type="submit"
|
||||||
<RiCheckLine className="h-4 w-4" />
|
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||||
</Button>
|
>
|
||||||
<Button
|
<RiCheckLine className="size-4" />
|
||||||
size="icon"
|
</button>
|
||||||
variant="ghost"
|
<button
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCancelEdit}
|
onClick={handleCancelEdit}
|
||||||
|
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<RiCloseLine className="h-4 w-4" />
|
<RiCloseLine className="size-4" />
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
<div className="flex items-center gap-2 pt-1 typography-micro text-muted-foreground/70 overflow-hidden">
|
<div className="flex items-center gap-2 typography-micro text-muted-foreground/60 min-w-0 overflow-hidden leading-tight">
|
||||||
<span className="flex-shrink-0">{formatDateLabel(session.time?.created || Date.now())}</span>
|
{hasChildren ? (
|
||||||
{session.share && (
|
<span className="inline-flex items-center justify-center flex-shrink-0">
|
||||||
<RiShare2Line className="h-3 w-3 text-[color:var(--status-info)] 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>
|
||||||
</div>
|
</div>
|
||||||
</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);
|
const git = simpleGit(directory);
|
||||||
|
|
||||||
try {
|
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([
|
const [stagedStatsRaw, workingStatsRaw] = await Promise.all([
|
||||||
git.raw(['diff', '--cached', '--numstat']).catch(() => ''),
|
git.raw(['diff', '--cached', '--numstat']).catch(() => ''),
|
||||||
|
|||||||
Reference in New Issue
Block a user