* feat: Redesign git changes to split stage/unstaged files. Signed-off-by: Paolo Insogna <paolo@cowtech.it> * fixup Signed-off-by: Paolo Insogna <paolo@cowtech.it> * fixup Signed-off-by: Paolo Insogna <paolo@cowtech.it> * refactor: streamline git changes panel * fix: label staged and working diff tabs * fix: isolate staged and working diff files * fix: scope staged and working diff updates * fix: scope git row revert to working changes --------- Signed-off-by: Paolo Insogna <paolo@cowtech.it> Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
199 lines
9.8 KiB
TypeScript
199 lines
9.8 KiB
TypeScript
import React from 'react';
|
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
|
import { toast } from '@/components/ui';
|
|
import { Icon } from "@/components/icon/Icon";
|
|
import { useI18n } from '@/lib/i18n';
|
|
import { cn } from '@/lib/utils';
|
|
import type { GitStashEntry } from '@/lib/api/types';
|
|
import { applyGitStash, countGitStashFiles, dropGitStash, listGitStashes, popGitStash, stashGitChanges } from '@/lib/gitApi';
|
|
|
|
interface StashesDialogProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
directory: string | null;
|
|
hasUncommittedChanges: boolean;
|
|
hasStagedChanges?: boolean;
|
|
uncommittedFileCount: number;
|
|
onChanged?: (change?: { affectsIndex?: boolean }) => void | Promise<void>;
|
|
}
|
|
|
|
type StashOperation = 'create' | `apply:${string}` | `pop:${string}` | `drop:${string}` | null;
|
|
|
|
export const StashesDialog: React.FC<StashesDialogProps> = ({
|
|
open,
|
|
onOpenChange,
|
|
directory,
|
|
hasUncommittedChanges,
|
|
hasStagedChanges = false,
|
|
uncommittedFileCount,
|
|
onChanged,
|
|
}) => {
|
|
const { t } = useI18n();
|
|
const [stashes, setStashes] = React.useState<GitStashEntry[]>([]);
|
|
const [isLoading, setIsLoading] = React.useState(false);
|
|
const [query, setQuery] = React.useState('');
|
|
const [message, setMessage] = React.useState('');
|
|
const [operation, setOperation] = React.useState<StashOperation>(null);
|
|
const [fileCounts, setFileCounts] = React.useState<Record<string, number>>({});
|
|
|
|
const load = React.useCallback(async () => {
|
|
if (!directory) return;
|
|
setIsLoading(true);
|
|
try {
|
|
const result = await listGitStashes(directory);
|
|
setStashes(result.stashes);
|
|
setFileCounts({});
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : t('gitView.stashes.toast.loadFailed'));
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [directory, t]);
|
|
|
|
React.useEffect(() => {
|
|
if (open) void load();
|
|
}, [load, open]);
|
|
|
|
React.useEffect(() => {
|
|
if (!open || !directory || stashes.length === 0) return;
|
|
let cancelled = false;
|
|
const refs = stashes.map((stash) => stash.ref);
|
|
void countGitStashFiles(directory, refs).then((result) => {
|
|
if (!cancelled) setFileCounts(result.counts);
|
|
}).catch(() => undefined);
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [directory, open, stashes]);
|
|
|
|
const filtered = React.useMemo(() => {
|
|
const normalized = query.trim().toLowerCase();
|
|
if (!normalized) return stashes;
|
|
return stashes.filter((stash) => `${stash.ref} ${stash.message} ${stash.relativeTime}`.toLowerCase().includes(normalized));
|
|
}, [query, stashes]);
|
|
|
|
const refreshAfterChange = React.useCallback(async (change?: { affectsIndex?: boolean }) => {
|
|
await load();
|
|
await onChanged?.(change);
|
|
}, [load, onChanged]);
|
|
|
|
const handleCreate = async () => {
|
|
if (!directory || operation) return;
|
|
setOperation('create');
|
|
try {
|
|
const result = await stashGitChanges(directory, { message: message.trim() || undefined });
|
|
if (result.created) {
|
|
toast.success(t('gitView.stashes.toast.created'));
|
|
setMessage('');
|
|
} else {
|
|
toast.info(t('gitView.stashes.toast.noChanges'));
|
|
}
|
|
await refreshAfterChange({ affectsIndex: Boolean(result.created && hasStagedChanges) });
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : t('gitView.stashes.toast.createFailed'));
|
|
} finally {
|
|
setOperation(null);
|
|
}
|
|
};
|
|
|
|
const runStashAction = async (stash: GitStashEntry, kind: 'apply' | 'pop' | 'drop') => {
|
|
if (!directory || operation) return;
|
|
if (kind === 'drop' && !window.confirm(t('gitView.stashes.confirm.drop', { ref: stash.ref }))) return;
|
|
setOperation(`${kind}:${stash.ref}`);
|
|
try {
|
|
if (kind === 'apply') await applyGitStash(directory, { ref: stash.ref });
|
|
if (kind === 'pop') await popGitStash(directory, { ref: stash.ref });
|
|
if (kind === 'drop') await dropGitStash(directory, { ref: stash.ref });
|
|
const successKey = kind === 'apply' ? 'gitView.stashes.toast.applySuccess' : kind === 'pop' ? 'gitView.stashes.toast.popSuccess' : 'gitView.stashes.toast.dropSuccess';
|
|
toast.success(t(successKey));
|
|
await refreshAfterChange({ affectsIndex: kind !== 'drop' });
|
|
} catch (error) {
|
|
const failedKey = kind === 'apply' ? 'gitView.stashes.toast.applyFailed' : kind === 'pop' ? 'gitView.stashes.toast.popFailed' : 'gitView.stashes.toast.dropFailed';
|
|
toast.error(error instanceof Error ? error.message : t(failedKey));
|
|
await refreshAfterChange({ affectsIndex: kind !== 'drop' });
|
|
} finally {
|
|
setOperation(null);
|
|
}
|
|
};
|
|
|
|
const isOperating = operation !== null;
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Icon name="archive-stack" className="h-5 w-5" />
|
|
{t('gitView.stashes.title')}
|
|
</DialogTitle>
|
|
<DialogDescription>{t('gitView.stashes.description')}</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="mt-2 rounded-lg border border-border/60 bg-[var(--surface-elevated)] p-3">
|
|
<div className="flex flex-col gap-2 sm:flex-row">
|
|
<Input
|
|
value={message}
|
|
onChange={(event) => setMessage(event.target.value)}
|
|
placeholder={t('gitView.stashes.messagePlaceholder')}
|
|
disabled={isOperating || !hasUncommittedChanges}
|
|
className="flex-1"
|
|
/>
|
|
<Button onClick={handleCreate} disabled={!hasUncommittedChanges || isOperating || !directory}>
|
|
{operation === 'create' ? <Icon name="loader-4" className="size-4 animate-spin" /> : <Icon name="inbox-archive" className="size-4" />}
|
|
{t('gitView.stashes.actions.stashCurrentWithCount', { count: uncommittedFileCount })}
|
|
</Button>
|
|
</div>
|
|
<p className="typography-meta mt-2 text-muted-foreground">{t('gitView.stashes.includeUntrackedHint')}</p>
|
|
</div>
|
|
|
|
<div className="relative mt-2">
|
|
<Icon name="search" className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t('gitView.stashes.searchPlaceholder')} className="pl-9" />
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto">
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center py-10 text-muted-foreground"><Icon name="loader-4" className="size-5 animate-spin" /></div>
|
|
) : filtered.length === 0 ? (
|
|
<div className="py-8 text-center text-muted-foreground">{query ? t('gitView.stashes.empty.search') : t('gitView.stashes.empty.list')}</div>
|
|
) : filtered.map((stash, index) => (
|
|
<div key={stash.ref} className="group flex items-center gap-2 rounded py-1.5 transition-colors hover:bg-interactive-hover/30">
|
|
<div className="min-w-0 flex-1 pl-2">
|
|
<p className="typography-small truncate text-foreground">{stash.message || t('gitView.stashes.untitled')}</p>
|
|
<p className="typography-meta truncate text-muted-foreground">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span>{index === 0 && !query.trim() ? t('gitView.stashes.latestLabel') : t('gitView.stashes.itemNumber', { number: index + 1 })}</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent sideOffset={6}>{stash.ref}</TooltipContent>
|
|
</Tooltip>
|
|
{' · '}{stash.relativeTime} · {typeof fileCounts[stash.ref] === 'number' ? t('gitView.stashes.fileCount', { count: fileCounts[stash.ref] }) : t('gitView.stashes.fileCountLoading')}
|
|
</p>
|
|
</div>
|
|
<div className="mr-2 flex shrink-0 items-center gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100">
|
|
<StashIconButton label={t('gitView.stashes.actions.apply')} loading={operation === `apply:${stash.ref}`} onClick={() => runStashAction(stash, 'apply')} disabled={isOperating}><Icon name="inbox-unarchive" className="size-4" /></StashIconButton>
|
|
<StashIconButton label={t('gitView.stashes.actions.pop')} loading={operation === `pop:${stash.ref}`} onClick={() => runStashAction(stash, 'pop')} disabled={isOperating}><Icon name="inbox-unarchive-fill" className="size-4" /></StashIconButton>
|
|
<StashIconButton label={t('gitView.stashes.actions.drop')} loading={operation === `drop:${stash.ref}`} onClick={() => runStashAction(stash, 'drop')} disabled={isOperating} destructive><Icon name="delete-bin" className="size-4" /></StashIconButton>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|
|
|
|
const StashIconButton: React.FC<{ label: string; loading: boolean; disabled: boolean; destructive?: boolean; onClick: () => void; children: React.ReactNode }> = ({ label, loading, disabled, destructive, onClick, children }) => (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button type="button" aria-label={label} aria-busy={loading || undefined} className={cn('flex h-6 w-6 items-center justify-center transition-colors disabled:opacity-50', destructive ? 'text-[var(--status-error)] hover:text-[var(--status-error)]' : 'text-muted-foreground hover:text-foreground')} onClick={onClick} disabled={disabled}>
|
|
{loading ? <Icon name="loader-4" className="size-4 animate-spin" /> : children}
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent sideOffset={6}>{label}</TooltipContent>
|
|
</Tooltip>
|
|
);
|