feat: Redesign git changes to split stage/unstaged files. (#1359)
* 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>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
9af0de0056
commit
e16097b05d
@@ -1,6 +1,5 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import type { GitStatus } from '@/lib/api/types';
|
||||
@@ -40,26 +39,33 @@ function describeChange(file: GitStatus['files'][number]): ChangeDescriptor {
|
||||
|
||||
interface ChangeRowProps {
|
||||
file: GitStatus['files'][number];
|
||||
checked: boolean;
|
||||
onToggle: () => void;
|
||||
actionLabel: string;
|
||||
actionSymbol: '+' | '-';
|
||||
onAction: () => void;
|
||||
onViewDiff: () => void;
|
||||
onRevert: () => void;
|
||||
isReverting: boolean;
|
||||
stats?: { insertions: number; deletions: number };
|
||||
rowPaddingClassName?: string;
|
||||
indentPx?: number;
|
||||
/** Place the stage/unstage action at the row start (flat view) instead of the end (tree view). */
|
||||
actionAtStart?: boolean;
|
||||
showRevert?: boolean;
|
||||
}
|
||||
|
||||
export const ChangeRow = React.memo<ChangeRowProps>(function ChangeRow({
|
||||
file,
|
||||
checked,
|
||||
onToggle,
|
||||
actionLabel,
|
||||
actionSymbol,
|
||||
onAction,
|
||||
onViewDiff,
|
||||
onRevert,
|
||||
isReverting,
|
||||
stats,
|
||||
rowPaddingClassName,
|
||||
indentPx = 0,
|
||||
actionAtStart = false,
|
||||
showRevert = true,
|
||||
}) {
|
||||
const descriptor = useMemo(() => describeChange(file), [file]);
|
||||
const { t } = useI18n();
|
||||
@@ -71,13 +77,22 @@ export const ChangeRow = React.memo<ChangeRowProps>(function ChangeRow({
|
||||
(event: React.KeyboardEvent) => {
|
||||
if (event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onToggle();
|
||||
onAction();
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
onViewDiff();
|
||||
}
|
||||
},
|
||||
[onToggle, onViewDiff]
|
||||
[onAction, onViewDiff]
|
||||
);
|
||||
|
||||
const handleActionClick = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onAction();
|
||||
},
|
||||
[onAction]
|
||||
);
|
||||
|
||||
const handleRevertClick = useCallback(
|
||||
@@ -89,6 +104,18 @@ export const ChangeRow = React.memo<ChangeRowProps>(function ChangeRow({
|
||||
[onRevert]
|
||||
);
|
||||
|
||||
const actionButton = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleActionClick}
|
||||
className="flex size-5 shrink-0 items-center justify-center rounded typography-micro font-semibold text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]"
|
||||
aria-label={actionLabel}
|
||||
title={actionLabel}
|
||||
>
|
||||
{actionSymbol}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group flex items-center gap-2 py-1.5 hover:bg-sidebar/40 cursor-pointer ${rowPaddingClassName ?? 'px-3'}`}
|
||||
@@ -98,14 +125,7 @@ export const ChangeRow = React.memo<ChangeRowProps>(function ChangeRow({
|
||||
onKeyDown={handleKeyDown}
|
||||
style={indentPx > 0 ? { paddingLeft: `${indentPx}px` } : undefined}
|
||||
>
|
||||
<div className="flex size-5 shrink-0 items-center justify-center" onClick={(e) => { e.stopPropagation(); }}>
|
||||
<Checkbox
|
||||
size="sm"
|
||||
checked={checked}
|
||||
onChange={() => onToggle()}
|
||||
ariaLabel={t('gitView.changes.selectFileAria', { path: file.path })}
|
||||
/>
|
||||
</div>
|
||||
{actionAtStart ? actionButton : null}
|
||||
<span
|
||||
className="typography-micro font-semibold w-4 text-center uppercase"
|
||||
style={{ color: descriptor.color }}
|
||||
@@ -147,24 +167,27 @@ export const ChangeRow = React.memo<ChangeRowProps>(function ChangeRow({
|
||||
<span className="text-muted-foreground mx-0.5">/</span>
|
||||
<span style={{ color: 'var(--status-error)' }}>-{deletions}</span>
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRevertClick}
|
||||
disabled={isReverting}
|
||||
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 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label={t('gitView.changes.revertFileAria', { path: file.path })}
|
||||
>
|
||||
{isReverting ? (
|
||||
<Icon name="loader-4" className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Icon name="arrow-go-back" className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8}>{t('gitView.changes.revertFileTooltip')}</TooltipContent>
|
||||
</Tooltip>
|
||||
{showRevert ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRevertClick}
|
||||
disabled={isReverting}
|
||||
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 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label={t('gitView.changes.revertFileAria', { path: file.path })}
|
||||
>
|
||||
{isReverting ? (
|
||||
<Icon name="loader-4" className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Icon name="arrow-go-back" className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8}>{t('gitView.changes.revertFileTooltip')}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{actionAtStart ? null : actionButton}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
import React from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
||||
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { ChangeRow } from './ChangeRow';
|
||||
import {
|
||||
TREE_INDENT_PX,
|
||||
buildChangesTree,
|
||||
flattenChangesTree,
|
||||
type ChangesTreeDirectoryNode,
|
||||
type FlattenedTreeRow,
|
||||
} from './changesTree';
|
||||
import type { GitStatus } from '@/lib/api/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export interface ChangesGroupConfig {
|
||||
/** Stable id (e.g. 'staged' | 'unstaged'). */
|
||||
id: string;
|
||||
title: string;
|
||||
entries: GitStatus['files'];
|
||||
/** Per-file primary action: '+' stages, '-' unstages. */
|
||||
actionSymbol: '+' | '-';
|
||||
/** aria/title for the bulk header action (stage all / unstage all). */
|
||||
actionAllLabel: string;
|
||||
getActionLabel: (path: string) => string;
|
||||
onActionFile: (path: string) => void;
|
||||
onActionAll: (paths: string[]) => void;
|
||||
onViewDiff: (path: string) => void;
|
||||
onRevertFile: (path: string) => void;
|
||||
showRevertActions?: boolean;
|
||||
/** Visually mark this group as "ready to commit". */
|
||||
accent?: boolean;
|
||||
}
|
||||
|
||||
interface ChangesPanelProps {
|
||||
groups: ChangesGroupConfig[];
|
||||
diffStats: Record<string, { insertions: number; deletions: number }> | undefined;
|
||||
revertingPaths: Set<string>;
|
||||
isRevertingAll?: boolean;
|
||||
onVisiblePathsChange?: (paths: string[]) => void;
|
||||
/** Reverts every changed path across all groups; rendered once for the panel. */
|
||||
onRevertAll?: (paths: string[]) => Promise<void> | void;
|
||||
}
|
||||
|
||||
const CHANGE_LIST_VIRTUALIZE_THRESHOLD = 1000;
|
||||
const CHANGE_ROW_ESTIMATE_PX = 34;
|
||||
const VISIBLE_PREFETCH_LIMIT = 30;
|
||||
|
||||
const ROW_PADDING_CLASSNAME = 'pl-0 pr-2';
|
||||
|
||||
type PanelRow =
|
||||
| { type: 'header'; key: string; groupIndex: number }
|
||||
| { type: 'file'; key: string; groupIndex: number; file: GitStatus['files'][number]; depth: number }
|
||||
| { type: 'directory'; key: string; groupIndex: number; directory: ChangesTreeDirectoryNode; depth: number }
|
||||
| { type: 'revert-all'; key: string };
|
||||
|
||||
const expandedKey = (groupId: string, path: string): string => `${groupId} ${path}`;
|
||||
|
||||
export const ChangesPanel: React.FC<ChangesPanelProps> = ({
|
||||
groups,
|
||||
diffStats,
|
||||
revertingPaths,
|
||||
isRevertingAll = false,
|
||||
onVisiblePathsChange,
|
||||
onRevertAll,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const scrollRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const gitChangesViewMode = useUIStore((state) => state.gitChangesViewMode);
|
||||
const isTreeView = gitChangesViewMode === 'tree';
|
||||
|
||||
const visibleGroups = React.useMemo(() => groups.filter((group) => group.entries.length > 0), [groups]);
|
||||
|
||||
const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(new Set());
|
||||
const [expandedDirectories, setExpandedDirectories] = React.useState<Set<string>>(new Set());
|
||||
const [revertAllOpen, setRevertAllOpen] = React.useState(false);
|
||||
|
||||
const trees = React.useMemo(
|
||||
() => visibleGroups.map((group) => buildChangesTree(group.entries)),
|
||||
[visibleGroups]
|
||||
);
|
||||
|
||||
// Auto-expand every top-level directory the first time it appears (mirrors prior
|
||||
// ChangesSection behavior) while preserving user-collapsed nested directories.
|
||||
const topLevelDirectoryKeys = React.useMemo(() => {
|
||||
const keys: string[] = [];
|
||||
visibleGroups.forEach((group, index) => {
|
||||
Array.from(trees[index]?.children.values() ?? []).forEach((directory) => {
|
||||
keys.push(expandedKey(group.id, directory.path));
|
||||
});
|
||||
});
|
||||
return keys;
|
||||
}, [trees, visibleGroups]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isTreeView) {
|
||||
return;
|
||||
}
|
||||
setExpandedDirectories((previous) => {
|
||||
const next = new Set<string>();
|
||||
const topLevel = new Set(topLevelDirectoryKeys);
|
||||
previous.forEach((key) => {
|
||||
const path = key.slice(key.indexOf(' ') + 1);
|
||||
if (path.includes('/') || topLevel.has(key)) {
|
||||
next.add(key);
|
||||
}
|
||||
});
|
||||
topLevelDirectoryKeys.forEach((key) => next.add(key));
|
||||
return next;
|
||||
});
|
||||
}, [isTreeView, topLevelDirectoryKeys]);
|
||||
|
||||
const rows = React.useMemo<PanelRow[]>(() => {
|
||||
const result: PanelRow[] = [];
|
||||
|
||||
visibleGroups.forEach((group, groupIndex) => {
|
||||
result.push({ type: 'header', key: `header:${group.id}`, groupIndex });
|
||||
|
||||
if (collapsedGroups.has(group.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTreeView) {
|
||||
const expandedForGroup = new Set<string>();
|
||||
expandedDirectories.forEach((key) => {
|
||||
if (key.startsWith(`${group.id} `)) {
|
||||
expandedForGroup.add(key.slice(group.id.length + 1));
|
||||
}
|
||||
});
|
||||
const treeRows = flattenChangesTree(trees[groupIndex], expandedForGroup);
|
||||
treeRows.forEach((row: FlattenedTreeRow) => {
|
||||
if (row.kind === 'file') {
|
||||
result.push({
|
||||
type: 'file',
|
||||
key: `${group.id}:${row.key}`,
|
||||
groupIndex,
|
||||
file: row.file,
|
||||
depth: row.depth,
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
type: 'directory',
|
||||
key: `${group.id}:${row.key}`,
|
||||
groupIndex,
|
||||
directory: row.directory,
|
||||
depth: row.depth,
|
||||
});
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
group.entries.forEach((file) => {
|
||||
result.push({
|
||||
type: 'file',
|
||||
key: `${group.id}:file:${file.path}`,
|
||||
groupIndex,
|
||||
file,
|
||||
depth: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Revert-all lives as the final in-flow row beneath the last file, so it
|
||||
// scrolls with the list rather than sitting in a section header.
|
||||
if (onRevertAll && visibleGroups.length > 0) {
|
||||
result.push({ type: 'revert-all', key: 'revert-all' });
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [collapsedGroups, expandedDirectories, isTreeView, onRevertAll, trees, visibleGroups]);
|
||||
|
||||
const rowCount = rows.length;
|
||||
const shouldVirtualize = rowCount >= CHANGE_LIST_VIRTUALIZE_THRESHOLD;
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: rowCount,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => CHANGE_ROW_ESTIMATE_PX,
|
||||
overscan: 12,
|
||||
enabled: shouldVirtualize,
|
||||
});
|
||||
|
||||
// Remeasure when the container transitions from display:none (hidden tab) back
|
||||
// to visible layout, otherwise stale zero-height measurements render no rows.
|
||||
React.useEffect(() => {
|
||||
if (!shouldVirtualize) return;
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const observer = new ResizeObserver(() => rowVirtualizer.measure());
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [shouldVirtualize, rowVirtualizer]);
|
||||
|
||||
const totalSize = rowVirtualizer.getTotalSize();
|
||||
const virtualRows = React.useMemo(
|
||||
() => (shouldVirtualize && totalSize >= 0 ? rowVirtualizer.getVirtualItems() : []),
|
||||
[shouldVirtualize, rowVirtualizer, totalSize]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!onVisiblePathsChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collectFromRow = (row: PanelRow | undefined): string | null =>
|
||||
row && row.type === 'file' ? row.file.path : null;
|
||||
|
||||
if (rowCount === 0) {
|
||||
onVisiblePathsChange([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldVirtualize) {
|
||||
const paths: string[] = [];
|
||||
for (const row of rows) {
|
||||
if (row.type === 'file') {
|
||||
paths.push(row.file.path);
|
||||
if (paths.length >= VISIBLE_PREFETCH_LIMIT) break;
|
||||
}
|
||||
}
|
||||
onVisiblePathsChange(paths);
|
||||
return;
|
||||
}
|
||||
|
||||
onVisiblePathsChange(
|
||||
virtualRows
|
||||
.map((item) => collectFromRow(rows[item.index]))
|
||||
.filter((value): value is string => Boolean(value))
|
||||
);
|
||||
}, [onVisiblePathsChange, rowCount, rows, shouldVirtualize, virtualRows]);
|
||||
|
||||
const toggleGroupCollapsed = React.useCallback((groupId: string) => {
|
||||
setCollapsedGroups((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (next.has(groupId)) {
|
||||
next.delete(groupId);
|
||||
} else {
|
||||
next.add(groupId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleDirectoryExpanded = React.useCallback((groupId: string, path: string) => {
|
||||
setExpandedDirectories((previous) => {
|
||||
const next = new Set(previous);
|
||||
const key = expandedKey(groupId, path);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Every distinct changed path across groups (a partially-staged file appears in
|
||||
// both, so dedupe). One revert-all discards all working-tree changes at once.
|
||||
const allChangePaths = React.useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
visibleGroups.forEach((group) => group.entries.forEach((entry) => seen.add(entry.path)));
|
||||
return Array.from(seen);
|
||||
}, [visibleGroups]);
|
||||
const revertAllCount = allChangePaths.length;
|
||||
|
||||
const handleConfirmRevertAll = React.useCallback(async () => {
|
||||
if (!onRevertAll || isRevertingAll || allChangePaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
await onRevertAll(allChangePaths);
|
||||
setRevertAllOpen(false);
|
||||
}, [allChangePaths, isRevertingAll, onRevertAll]);
|
||||
|
||||
const renderHeader = React.useCallback(
|
||||
(group: ChangesGroupConfig, isFirst: boolean) => {
|
||||
const collapsed = collapsedGroups.has(group.id);
|
||||
const count = group.entries.length;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'sticky top-0 z-10 flex items-center gap-2 bg-sidebar py-2',
|
||||
ROW_PADDING_CLASSNAME,
|
||||
!isFirst && 'mt-1 border-t border-border/40'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => group.onActionAll(group.entries.map((entry) => entry.path))}
|
||||
className="flex size-5 shrink-0 items-center justify-center rounded typography-micro font-semibold text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]"
|
||||
aria-label={group.actionAllLabel}
|
||||
title={group.actionAllLabel}
|
||||
>
|
||||
{group.actionSymbol}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleGroupCollapsed(group.id)}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 rounded text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]"
|
||||
aria-expanded={!collapsed}
|
||||
>
|
||||
<h3 className="truncate typography-ui-header font-semibold text-foreground">{group.title}</h3>
|
||||
<span className="typography-meta text-muted-foreground">{count}</span>
|
||||
<Icon
|
||||
name="arrow-down-s"
|
||||
className={cn(
|
||||
'size-3.5 shrink-0 text-muted-foreground transition-transform',
|
||||
collapsed && '-rotate-90'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[collapsedGroups, toggleGroupCollapsed]
|
||||
);
|
||||
|
||||
const renderDirectory = React.useCallback(
|
||||
(group: ChangesGroupConfig, directory: ChangesTreeDirectoryNode, depth: number) => {
|
||||
const isExpanded = expandedDirectories.has(expandedKey(group.id, directory.path));
|
||||
return (
|
||||
<div
|
||||
className={cn('group flex items-center gap-2 py-1.5 hover:bg-sidebar/40', ROW_PADDING_CLASSNAME)}
|
||||
style={{ paddingLeft: `${depth * TREE_INDENT_PX}px` }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleDirectoryExpanded(group.id, directory.path)}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 rounded text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={
|
||||
isExpanded
|
||||
? t('gitView.changes.collapseDirectoryAria', { path: directory.path })
|
||||
: t('gitView.changes.expandDirectoryAria', { path: directory.path })
|
||||
}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<Icon name="folder-open-fill" className="h-4 w-4 flex-shrink-0 text-primary/60" />
|
||||
) : (
|
||||
<Icon name="folder-3-fill" className="h-4 w-4 flex-shrink-0 text-primary/60" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate typography-ui-label text-foreground" title={directory.path}>
|
||||
{directory.name}
|
||||
</span>
|
||||
<span className="ml-auto shrink-0 typography-micro text-muted-foreground">{directory.files.length}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => group.onActionAll(directory.files.map((file) => file.path))}
|
||||
className="flex size-5 shrink-0 items-center justify-center rounded typography-micro font-semibold text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]"
|
||||
aria-label={t(
|
||||
group.actionSymbol === '+' ? 'gitView.changes.stageDirectoryAria' : 'gitView.changes.unstageDirectoryAria',
|
||||
{ path: directory.path }
|
||||
)}
|
||||
title={t(
|
||||
group.actionSymbol === '+' ? 'gitView.changes.stageDirectoryAria' : 'gitView.changes.unstageDirectoryAria',
|
||||
{ path: directory.path }
|
||||
)}
|
||||
>
|
||||
{group.actionSymbol}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[expandedDirectories, t, toggleDirectoryExpanded]
|
||||
);
|
||||
|
||||
const renderRow = React.useCallback(
|
||||
(row: PanelRow, isFirstRow: boolean) => {
|
||||
if (row.type === 'revert-all') {
|
||||
return (
|
||||
<div className={cn('flex justify-end py-2', ROW_PADDING_CLASSNAME)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setRevertAllOpen(true)}
|
||||
disabled={isRevertingAll}
|
||||
className="gap-1.5 text-[var(--status-error)] hover:bg-[var(--status-error)]/10 hover:text-[var(--status-error)]"
|
||||
>
|
||||
<Icon name="arrow-go-back" className="size-3.5" />
|
||||
{t('gitView.changes.revertAll')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const group = visibleGroups[row.groupIndex];
|
||||
if (!group) return null;
|
||||
|
||||
if (row.type === 'header') {
|
||||
return renderHeader(group, isFirstRow);
|
||||
}
|
||||
|
||||
if (row.type === 'directory') {
|
||||
return renderDirectory(group, row.directory, row.depth);
|
||||
}
|
||||
|
||||
const file = row.file;
|
||||
return (
|
||||
<ChangeRow
|
||||
file={file}
|
||||
actionLabel={group.getActionLabel(file.path)}
|
||||
actionSymbol={group.actionSymbol}
|
||||
onAction={() => group.onActionFile(file.path)}
|
||||
stats={diffStats?.[file.path]}
|
||||
onViewDiff={() => group.onViewDiff(file.path)}
|
||||
onRevert={() => group.onRevertFile(file.path)}
|
||||
isReverting={revertingPaths.has(file.path) || isRevertingAll}
|
||||
rowPaddingClassName={ROW_PADDING_CLASSNAME}
|
||||
indentPx={row.depth * TREE_INDENT_PX}
|
||||
actionAtStart={!isTreeView}
|
||||
showRevert={group.showRevertActions !== false}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[diffStats, isRevertingAll, isTreeView, renderDirectory, renderHeader, revertingPaths, t, visibleGroups]
|
||||
);
|
||||
|
||||
// A divider is drawn above a file/directory row only when the row directly above
|
||||
// it belongs to the same group (so headers never get a spurious top border).
|
||||
const showDivider = React.useCallback(
|
||||
(index: number): boolean => {
|
||||
const row = rows[index];
|
||||
const previous = rows[index - 1];
|
||||
if (!row || !previous) return false;
|
||||
if (row.type !== 'file' && row.type !== 'directory') return false;
|
||||
if (previous.type !== 'file' && previous.type !== 'directory') return false;
|
||||
return previous.groupIndex === row.groupIndex;
|
||||
},
|
||||
[rows]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative flex h-full min-h-0 w-full flex-col overflow-hidden">
|
||||
<ScrollShadow
|
||||
ref={scrollRef}
|
||||
className="overlay-scrollbar-target overlay-scrollbar-container min-h-0 w-full flex-1 overflow-x-hidden overflow-y-auto"
|
||||
>
|
||||
{shouldVirtualize ? (
|
||||
<div className="relative w-full" style={{ height: `${rowVirtualizer.getTotalSize()}px` }}>
|
||||
{virtualRows.map((item) => {
|
||||
const row = rows[item.index];
|
||||
if (!row) return null;
|
||||
return (
|
||||
<div
|
||||
key={row.key}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
data-index={item.index}
|
||||
className={cn(
|
||||
'absolute left-0 top-0 w-full',
|
||||
showDivider(item.index) &&
|
||||
'before:pointer-events-none before:absolute before:left-0 before:right-2 before:top-0 before:border-t before:border-border/60'
|
||||
)}
|
||||
style={{ transform: `translateY(${item.start}px)` }}
|
||||
>
|
||||
{renderRow(row, item.index === 0)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div role="list" aria-label={t('gitView.changes.changedFilesAria')}>
|
||||
{rows.map((row, index) => (
|
||||
<div
|
||||
key={row.key}
|
||||
className={cn(
|
||||
'relative',
|
||||
showDivider(index) &&
|
||||
'before:pointer-events-none before:absolute before:left-0 before:right-2 before:top-0 before:border-t before:border-border/60'
|
||||
)}
|
||||
>
|
||||
{renderRow(row, index === 0)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollShadow>
|
||||
<OverlayScrollbar containerRef={scrollRef} disableHorizontal />
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={revertAllOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!isRevertingAll && !open) setRevertAllOpen(false);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('gitView.changes.revertAllDialogTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{revertAllCount === 1
|
||||
? t('gitView.changes.revertAllDescriptionSingle', { count: revertAllCount })
|
||||
: t('gitView.changes.revertAllDescriptionPlural', { count: revertAllCount })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={() => setRevertAllOpen(false)} disabled={isRevertingAll}>
|
||||
{t('gitView.common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => void handleConfirmRevertAll()}
|
||||
disabled={isRevertingAll}
|
||||
>
|
||||
{isRevertingAll ? t('gitView.changes.reverting') : t('gitView.changes.revertAll')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,573 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
||||
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { ChangeRow } from './ChangeRow';
|
||||
import type { GitStatus } from '@/lib/api/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
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;
|
||||
onRevertAll?: (paths: string[]) => Promise<void> | void;
|
||||
onViewDiff: (path: string) => void;
|
||||
onRevertFile: (path: string) => void;
|
||||
isRevertingAll?: boolean;
|
||||
maxListHeightClassName?: string;
|
||||
onVisiblePathsChange?: (paths: string[]) => void;
|
||||
onOpenStashes?: () => void;
|
||||
}
|
||||
|
||||
const CHANGE_LIST_VIRTUALIZE_THRESHOLD = 1000;
|
||||
const CHANGE_ROW_ESTIMATE_PX = 34;
|
||||
|
||||
type ChangesTreeDirectoryNode = {
|
||||
id: string;
|
||||
path: string;
|
||||
name: string;
|
||||
children: Map<string, ChangesTreeDirectoryNode>;
|
||||
directFiles: GitStatus['files'];
|
||||
files: GitStatus['files'];
|
||||
};
|
||||
|
||||
type FlattenedTreeRow =
|
||||
| {
|
||||
key: string;
|
||||
kind: 'directory';
|
||||
depth: number;
|
||||
directory: ChangesTreeDirectoryNode;
|
||||
}
|
||||
| {
|
||||
key: string;
|
||||
kind: 'file';
|
||||
depth: number;
|
||||
file: GitStatus['files'][number];
|
||||
};
|
||||
|
||||
const TREE_INDENT_PX = 14;
|
||||
|
||||
const normalizePathForTree = (value: string): string => value.replace(/\\/g, '/').replace(/^\/+/, '').trim();
|
||||
|
||||
const createDirectoryNode = (path: string, name: string): ChangesTreeDirectoryNode => ({
|
||||
id: `dir:${path}`,
|
||||
path,
|
||||
name,
|
||||
children: new Map(),
|
||||
directFiles: [],
|
||||
files: [],
|
||||
});
|
||||
|
||||
const buildChangesTree = (entries: GitStatus['files']): ChangesTreeDirectoryNode => {
|
||||
const root = createDirectoryNode('', '');
|
||||
|
||||
for (const file of entries) {
|
||||
const normalized = normalizePathForTree(file.path);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const segments = normalized.split('/').filter(Boolean);
|
||||
const directorySegments = segments.slice(0, -1);
|
||||
let current = root;
|
||||
current.files.push(file);
|
||||
|
||||
if (directorySegments.length > 0) {
|
||||
let currentPath = '';
|
||||
for (const segment of directorySegments) {
|
||||
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
|
||||
const existing = current.children.get(segment);
|
||||
if (existing) {
|
||||
existing.files.push(file);
|
||||
current = existing;
|
||||
continue;
|
||||
}
|
||||
|
||||
const created = createDirectoryNode(currentPath, segment);
|
||||
created.files.push(file);
|
||||
current.children.set(segment, created);
|
||||
current = created;
|
||||
}
|
||||
}
|
||||
|
||||
current.directFiles.push(file);
|
||||
}
|
||||
|
||||
return root;
|
||||
};
|
||||
|
||||
const flattenChangesTree = (
|
||||
root: ChangesTreeDirectoryNode,
|
||||
expandedDirectories: Set<string>,
|
||||
): FlattenedTreeRow[] => {
|
||||
const rows: FlattenedTreeRow[] = [];
|
||||
|
||||
const walk = (node: ChangesTreeDirectoryNode, depth: number) => {
|
||||
const directories = Array.from(node.children.values()).sort((a, b) => a.path.localeCompare(b.path));
|
||||
for (const directory of directories) {
|
||||
rows.push({
|
||||
key: directory.id,
|
||||
kind: 'directory',
|
||||
depth,
|
||||
directory,
|
||||
});
|
||||
|
||||
if (expandedDirectories.has(directory.path)) {
|
||||
walk(directory, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const directFiles = [...node.directFiles].sort((a, b) => a.path.localeCompare(b.path));
|
||||
|
||||
for (const file of directFiles) {
|
||||
rows.push({
|
||||
key: `file:${normalizePathForTree(file.path)}`,
|
||||
kind: 'file',
|
||||
depth,
|
||||
file,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
walk(root, 0);
|
||||
return rows;
|
||||
};
|
||||
|
||||
const getDirectorySelectionState = (
|
||||
directory: ChangesTreeDirectoryNode,
|
||||
selectedPaths: Set<string>
|
||||
): 'none' | 'partial' | 'all' => {
|
||||
if (directory.files.length === 0) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
let selectedCount = 0;
|
||||
for (const file of directory.files) {
|
||||
if (selectedPaths.has(file.path)) {
|
||||
selectedCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedCount === 0) return 'none';
|
||||
if (selectedCount === directory.files.length) return 'all';
|
||||
return 'partial';
|
||||
};
|
||||
|
||||
export const ChangesSection: React.FC<ChangesSectionProps> = ({
|
||||
changeEntries,
|
||||
selectedPaths,
|
||||
diffStats,
|
||||
revertingPaths,
|
||||
onToggleFile,
|
||||
onSelectAll,
|
||||
onClearSelection,
|
||||
onRevertAll,
|
||||
onViewDiff,
|
||||
onRevertFile,
|
||||
isRevertingAll = false,
|
||||
maxListHeightClassName,
|
||||
onVisiblePathsChange,
|
||||
onOpenStashes,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const scrollRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const gitChangesViewMode = useUIStore((state) => state.gitChangesViewMode);
|
||||
const isTreeView = gitChangesViewMode === 'tree';
|
||||
const selectedCount = selectedPaths.size;
|
||||
const totalCount = changeEntries.length;
|
||||
const [confirmRevertAllOpen, setConfirmRevertAllOpen] = React.useState(false);
|
||||
const treeRoot = React.useMemo(() => buildChangesTree(changeEntries), [changeEntries]);
|
||||
const [expandedDirectories, setExpandedDirectories] = React.useState<Set<string>>(new Set());
|
||||
|
||||
const topLevelDirectoryPaths = React.useMemo(
|
||||
() => Array.from(treeRoot.children.values()).map((directory) => directory.path),
|
||||
[treeRoot]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isTreeView) {
|
||||
return;
|
||||
}
|
||||
|
||||
setExpandedDirectories((previous) => {
|
||||
const next = new Set<string>();
|
||||
const validTopLevel = new Set(topLevelDirectoryPaths);
|
||||
|
||||
previous.forEach((path) => {
|
||||
if (path.includes('/')) {
|
||||
next.add(path);
|
||||
return;
|
||||
}
|
||||
if (validTopLevel.has(path)) {
|
||||
next.add(path);
|
||||
}
|
||||
});
|
||||
|
||||
topLevelDirectoryPaths.forEach((path) => next.add(path));
|
||||
return next;
|
||||
});
|
||||
}, [isTreeView, topLevelDirectoryPaths]);
|
||||
|
||||
const treeRows = React.useMemo(() => flattenChangesTree(treeRoot, expandedDirectories), [expandedDirectories, treeRoot]);
|
||||
const rowItems = React.useMemo(() => (isTreeView ? treeRows : changeEntries), [changeEntries, isTreeView, treeRows]);
|
||||
const rowCount = rowItems.length;
|
||||
const shouldVirtualize = rowCount >= CHANGE_LIST_VIRTUALIZE_THRESHOLD;
|
||||
const hasAnySelected = selectedCount > 0;
|
||||
const areAllSelected = totalCount > 0 && selectedCount === totalCount;
|
||||
const isPartiallySelected = hasAnySelected && !areAllSelected;
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: rowCount,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => CHANGE_ROW_ESTIMATE_PX,
|
||||
overscan: 10,
|
||||
enabled: shouldVirtualize,
|
||||
});
|
||||
|
||||
// Force virtualizer to remeasure when the scroll container transitions
|
||||
// from display:none (hidden tab via keep-alive) back to visible layout.
|
||||
// Without this, the virtualizer uses stale zero-height measurements and
|
||||
// renders no rows until the user scrolls.
|
||||
React.useEffect(() => {
|
||||
if (!shouldVirtualize) return;
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
rowVirtualizer.measure();
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [shouldVirtualize, rowVirtualizer]);
|
||||
|
||||
// Compute virtual rows with useMemo. We include totalSize as a dependency so
|
||||
// that when the ResizeObserver calls measure() — which clears the itemSizeCache
|
||||
// and recalculates — the size change invalidates the memo and getVirtualItems()
|
||||
// returns fresh rows. Using useMemo avoids calling getVirtualItems() directly in
|
||||
// the render body, which can trigger maybeNotify() → onChange() → useReducer
|
||||
// dispatch during render (React minified error #185).
|
||||
const totalSize = rowVirtualizer.getTotalSize();
|
||||
const virtualRows = React.useMemo(
|
||||
// totalSize invalidates the memo when the virtualizer recalculates after
|
||||
// measure/scroll, ensuring getVirtualItems() returns up-to-date rows.
|
||||
// Without it, the stable rowVirtualizer ref would never invalidate the memo
|
||||
// and rows would stay empty after measure().
|
||||
() => (shouldVirtualize && totalSize >= 0 ? rowVirtualizer.getVirtualItems() : []),
|
||||
[shouldVirtualize, rowVirtualizer, totalSize],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!onVisiblePathsChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (rowCount === 0) {
|
||||
onVisiblePathsChange([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const toVisiblePath = (item: GitStatus['files'][number] | FlattenedTreeRow): string | null => {
|
||||
if (!isTreeView) {
|
||||
return (item as GitStatus['files'][number]).path;
|
||||
}
|
||||
|
||||
const treeItem = item as FlattenedTreeRow;
|
||||
return treeItem.kind === 'file' ? treeItem.file.path : null;
|
||||
};
|
||||
|
||||
if (!shouldVirtualize) {
|
||||
onVisiblePathsChange(
|
||||
rowItems
|
||||
.slice(0, Math.min(30, rowCount))
|
||||
.map((item) => toVisiblePath(item))
|
||||
.filter((value): value is string => Boolean(value))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
onVisiblePathsChange(
|
||||
virtualRows
|
||||
.map((row) => rowItems[row.index])
|
||||
.map((item) => (item ? toVisiblePath(item) : null))
|
||||
.filter((value): value is string => Boolean(value))
|
||||
);
|
||||
}, [isTreeView, onVisiblePathsChange, rowCount, rowItems, shouldVirtualize, virtualRows]);
|
||||
|
||||
const containerClassName = 'flex flex-col flex-1 min-h-0';
|
||||
const headerClassName = 'flex items-center justify-between gap-2 px-0 py-3 border-b border-border/40';
|
||||
const scrollOuterClassName = `flex-1 min-h-0 pr-0 ${maxListHeightClassName ?? ''}`.trim();
|
||||
const rowPaddingClassName = 'pl-0 pr-2';
|
||||
|
||||
const toggleDirectoryExpanded = React.useCallback((path: string) => {
|
||||
setExpandedDirectories((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (next.has(path)) {
|
||||
next.delete(path);
|
||||
} else {
|
||||
next.add(path);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleDirectorySelection = React.useCallback((directory: ChangesTreeDirectoryNode) => {
|
||||
const state = getDirectorySelectionState(directory, selectedPaths);
|
||||
const shouldSelectAll = state !== 'all';
|
||||
|
||||
for (const file of directory.files) {
|
||||
const isSelected = selectedPaths.has(file.path);
|
||||
if (shouldSelectAll && !isSelected) {
|
||||
onToggleFile(file.path);
|
||||
} else if (!shouldSelectAll && isSelected) {
|
||||
onToggleFile(file.path);
|
||||
}
|
||||
}
|
||||
}, [onToggleFile, selectedPaths]);
|
||||
|
||||
const renderRow = React.useCallback((item: GitStatus['files'][number] | FlattenedTreeRow) => {
|
||||
if (!isTreeView) {
|
||||
const file = item as GitStatus['files'][number];
|
||||
return (
|
||||
<ChangeRow
|
||||
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) || isRevertingAll}
|
||||
rowPaddingClassName={rowPaddingClassName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const row = item as FlattenedTreeRow;
|
||||
|
||||
if (row.kind === 'file') {
|
||||
const file = row.file;
|
||||
return (
|
||||
<ChangeRow
|
||||
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) || isRevertingAll}
|
||||
rowPaddingClassName={rowPaddingClassName}
|
||||
indentPx={row.depth * TREE_INDENT_PX}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const directory = row.directory;
|
||||
const isExpanded = expandedDirectories.has(directory.path);
|
||||
const selectionState = getDirectorySelectionState(directory, selectedPaths);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('group flex items-center gap-2 py-1.5 hover:bg-sidebar/40', rowPaddingClassName)}
|
||||
style={{ paddingLeft: `${row.depth * TREE_INDENT_PX}px` }}
|
||||
>
|
||||
<div className="flex size-5 shrink-0 items-center justify-center">
|
||||
<Checkbox
|
||||
size="sm"
|
||||
checked={selectionState === 'all'}
|
||||
indeterminate={selectionState === 'partial'}
|
||||
onChange={() => toggleDirectorySelection(directory)}
|
||||
ariaLabel={t('gitView.changes.toggleDirectorySelectionAria', { path: directory.path })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleDirectoryExpanded(directory.path)}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 rounded text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={isExpanded
|
||||
? t('gitView.changes.collapseDirectoryAria', { path: directory.path })
|
||||
: t('gitView.changes.expandDirectoryAria', { path: directory.path })}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<Icon name="folder-open-fill" className="h-4 w-4 flex-shrink-0 text-primary/60" />
|
||||
) : (
|
||||
<Icon name="folder-3-fill" className="h-4 w-4 flex-shrink-0 text-primary/60" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate typography-ui-label text-foreground" title={directory.path}>
|
||||
{directory.name}
|
||||
</span>
|
||||
<span className="ml-auto shrink-0 typography-micro text-muted-foreground">{directory.files.length}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}, [
|
||||
diffStats,
|
||||
expandedDirectories,
|
||||
isRevertingAll,
|
||||
isTreeView,
|
||||
onRevertFile,
|
||||
onToggleFile,
|
||||
onViewDiff,
|
||||
revertingPaths,
|
||||
rowPaddingClassName,
|
||||
selectedPaths,
|
||||
t,
|
||||
toggleDirectoryExpanded,
|
||||
toggleDirectorySelection,
|
||||
]);
|
||||
|
||||
const handleConfirmRevertAll = React.useCallback(async () => {
|
||||
if (!onRevertAll || isRevertingAll || changeEntries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await onRevertAll(changeEntries.map((entry) => entry.path));
|
||||
setConfirmRevertAllOpen(false);
|
||||
}, [changeEntries, isRevertingAll, onRevertAll]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className={containerClassName}>
|
||||
<header className={headerClassName}>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">{t('gitView.changes.title')}</h3>
|
||||
{totalCount > 0 ? (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex h-6 items-center gap-1 rounded px-1.5',
|
||||
isRevertingAll && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
size="sm"
|
||||
checked={hasAnySelected}
|
||||
indeterminate={isPartiallySelected}
|
||||
disabled={isRevertingAll}
|
||||
onChange={() => (areAllSelected ? onClearSelection() : onSelectAll())}
|
||||
ariaLabel={areAllSelected ? t('gitView.changes.clearSelectionAria') : t('gitView.changes.selectAllAria')}
|
||||
/>
|
||||
<span className="typography-meta text-muted-foreground">{selectedCount}/{totalCount}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{onOpenStashes ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-6 px-1.5"
|
||||
onClick={onOpenStashes}
|
||||
aria-label={t('gitView.stashes.title')}
|
||||
title={t('gitView.stashes.title')}
|
||||
>
|
||||
<Icon name="archive-stack" className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pr-1">
|
||||
{totalCount > 0 && onRevertAll ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="xs"
|
||||
onClick={() => setConfirmRevertAllOpen(true)}
|
||||
disabled={isRevertingAll}
|
||||
>
|
||||
{t('gitView.changes.revertAll')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
<div className={cn('relative flex flex-col min-h-0 w-full overflow-hidden', scrollOuterClassName)}>
|
||||
<ScrollShadow
|
||||
ref={scrollRef}
|
||||
className="overlay-scrollbar-target overlay-scrollbar-container flex-1 min-h-0 w-full overflow-y-auto overflow-x-hidden"
|
||||
>
|
||||
{shouldVirtualize ? (
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
|
||||
>
|
||||
{virtualRows.map((row) => {
|
||||
const item = rowItems[row.index];
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = isTreeView
|
||||
? (item as FlattenedTreeRow).key
|
||||
: `file:${(item as GitStatus['files'][number]).path}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
data-index={row.index}
|
||||
className={cn(
|
||||
'absolute left-0 top-0 w-full',
|
||||
row.index > 0 && 'before:pointer-events-none before:absolute before:left-0 before:right-2 before:top-0 before:border-t before:border-border/60'
|
||||
)}
|
||||
style={{ transform: `translateY(${row.start}px)` }}
|
||||
>
|
||||
{renderRow(item)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div role="list" aria-label={t('gitView.changes.changedFilesAria')}>
|
||||
{rowItems.map((item, index) => (
|
||||
<div
|
||||
key={isTreeView ? (item as FlattenedTreeRow).key : `file:${(item as GitStatus['files'][number]).path}`}
|
||||
className={cn(
|
||||
'relative',
|
||||
index > 0 && 'before:pointer-events-none before:absolute before:left-0 before:right-2 before:top-0 before:border-t before:border-border/60'
|
||||
)}
|
||||
>
|
||||
{renderRow(item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollShadow>
|
||||
<OverlayScrollbar containerRef={scrollRef} disableHorizontal />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Dialog open={confirmRevertAllOpen} onOpenChange={(open) => { if (!isRevertingAll) setConfirmRevertAllOpen(open); }}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('gitView.changes.revertAllDialogTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{totalCount === 1
|
||||
? t('gitView.changes.revertAllDescriptionSingle', { count: totalCount })
|
||||
: t('gitView.changes.revertAllDescriptionPlural', { count: totalCount })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={() => setConfirmRevertAllOpen(false)} disabled={isRevertingAll}>
|
||||
{t('gitView.common.cancel')}
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={() => void handleConfirmRevertAll()} disabled={isRevertingAll}>
|
||||
{isRevertingAll ? t('gitView.changes.reverting') : t('gitView.changes.revertAll')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -9,7 +9,7 @@ import { useI18n } from '@/lib/i18n';
|
||||
type CommitAction = 'commit' | 'commitAndPush' | null;
|
||||
|
||||
interface CommitSectionProps {
|
||||
selectedCount: number;
|
||||
stagedCount: number;
|
||||
commitMessage: string;
|
||||
onCommitMessageChange: (value: string) => void;
|
||||
generatedHighlights: string[];
|
||||
@@ -19,12 +19,13 @@ interface CommitSectionProps {
|
||||
onCommit: () => void;
|
||||
onCommitAndPush: () => void;
|
||||
commitAction: CommitAction;
|
||||
hasPendingIndexMutation?: boolean;
|
||||
gitmojiEnabled: boolean;
|
||||
onOpenGitmojiPicker: () => void;
|
||||
}
|
||||
|
||||
export const CommitSection: React.FC<CommitSectionProps> = ({
|
||||
selectedCount,
|
||||
stagedCount,
|
||||
commitMessage,
|
||||
onCommitMessageChange,
|
||||
generatedHighlights,
|
||||
@@ -34,31 +35,31 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
|
||||
onCommit,
|
||||
onCommitAndPush,
|
||||
commitAction,
|
||||
hasPendingIndexMutation = false,
|
||||
gitmojiEnabled,
|
||||
onOpenGitmojiPicker,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const hasSelectedFiles = selectedCount > 0;
|
||||
const canCommit = commitMessage.trim() && hasSelectedFiles && commitAction === null;
|
||||
const hasStagedFiles = stagedCount > 0;
|
||||
const canCommit = commitMessage.trim() && hasStagedFiles && commitAction === null && !hasPendingIndexMutation;
|
||||
const { isMobile, hasTouchInput } = useDeviceInfo();
|
||||
|
||||
const containerClassName = 'border-0 bg-transparent rounded-none';
|
||||
const headerClassName = 'flex w-full items-center justify-between px-0 pt-2 pb-1';
|
||||
const headerClassName = 'flex w-full items-baseline gap-2 px-0 pt-2 pb-1';
|
||||
const contentClassName = 'flex flex-col gap-3 px-0 pt-1 pb-3';
|
||||
|
||||
return (
|
||||
<section className={containerClassName}>
|
||||
<div className={headerClassName}>
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">{t('gitView.commit.title')}</h3>
|
||||
{!hasStagedFiles ? (
|
||||
<span className="min-w-0 truncate typography-meta text-muted-foreground">
|
||||
{t('gitView.commit.stageFilesHint')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={contentClassName}>
|
||||
{!hasSelectedFiles ? (
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
{t('gitView.commit.selectFilesHint')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<AIHighlightsBox
|
||||
highlights={generatedHighlights}
|
||||
onInsert={onInsertHighlights}
|
||||
@@ -94,7 +95,8 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
|
||||
disabled={
|
||||
isGeneratingMessage ||
|
||||
commitAction !== null ||
|
||||
selectedCount === 0
|
||||
hasPendingIndexMutation ||
|
||||
stagedCount === 0
|
||||
}
|
||||
type="button"
|
||||
aria-label={t('gitView.commit.generateAria')}
|
||||
|
||||
@@ -38,6 +38,7 @@ interface GitHeaderProps {
|
||||
isApplyingIdentity: boolean;
|
||||
isWorktreeMode: boolean;
|
||||
onOpenHistory?: () => void;
|
||||
onOpenStashes?: () => void;
|
||||
actionTabItems?: SortableTabsStripItem[];
|
||||
activeActionTab?: string;
|
||||
onSelectActionTab?: (tabID: string) => void;
|
||||
@@ -197,6 +198,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
||||
isApplyingIdentity,
|
||||
isWorktreeMode,
|
||||
onOpenHistory,
|
||||
onOpenStashes,
|
||||
actionTabItems,
|
||||
activeActionTab,
|
||||
onSelectActionTab,
|
||||
@@ -208,20 +210,38 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
||||
|
||||
const managementButtons = (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{onOpenHistory ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 px-0"
|
||||
onClick={onOpenHistory}
|
||||
>
|
||||
<Icon name="history" className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8}>{t('gitView.history.title')}</TooltipContent>
|
||||
</Tooltip>
|
||||
{onOpenHistory || onOpenStashes ? (
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 px-0"
|
||||
aria-label={t('gitView.history.title')}
|
||||
>
|
||||
<Icon name="git-repository" className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8}>{t('gitView.history.title')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end">
|
||||
{onOpenHistory ? (
|
||||
<DropdownMenuItem onSelect={onOpenHistory}>
|
||||
<Icon name="history" className="size-4" />
|
||||
{t('gitView.history.title')}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onOpenStashes ? (
|
||||
<DropdownMenuItem onSelect={onOpenStashes}>
|
||||
<Icon name="archive-stack" className="size-4" />
|
||||
{t('gitView.stashes.title')}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,8 +15,9 @@ interface StashesDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
directory: string | null;
|
||||
hasUncommittedChanges: boolean;
|
||||
hasStagedChanges?: boolean;
|
||||
uncommittedFileCount: number;
|
||||
onChanged?: () => void | Promise<void>;
|
||||
onChanged?: (change?: { affectsIndex?: boolean }) => void | Promise<void>;
|
||||
}
|
||||
|
||||
type StashOperation = 'create' | `apply:${string}` | `pop:${string}` | `drop:${string}` | null;
|
||||
@@ -26,6 +27,7 @@ export const StashesDialog: React.FC<StashesDialogProps> = ({
|
||||
onOpenChange,
|
||||
directory,
|
||||
hasUncommittedChanges,
|
||||
hasStagedChanges = false,
|
||||
uncommittedFileCount,
|
||||
onChanged,
|
||||
}) => {
|
||||
@@ -73,9 +75,9 @@ export const StashesDialog: React.FC<StashesDialogProps> = ({
|
||||
return stashes.filter((stash) => `${stash.ref} ${stash.message} ${stash.relativeTime}`.toLowerCase().includes(normalized));
|
||||
}, [query, stashes]);
|
||||
|
||||
const refreshAfterChange = React.useCallback(async () => {
|
||||
const refreshAfterChange = React.useCallback(async (change?: { affectsIndex?: boolean }) => {
|
||||
await load();
|
||||
await onChanged?.();
|
||||
await onChanged?.(change);
|
||||
}, [load, onChanged]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
@@ -89,7 +91,7 @@ export const StashesDialog: React.FC<StashesDialogProps> = ({
|
||||
} else {
|
||||
toast.info(t('gitView.stashes.toast.noChanges'));
|
||||
}
|
||||
await refreshAfterChange();
|
||||
await refreshAfterChange({ affectsIndex: Boolean(result.created && hasStagedChanges) });
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t('gitView.stashes.toast.createFailed'));
|
||||
} finally {
|
||||
@@ -107,11 +109,11 @@ export const StashesDialog: React.FC<StashesDialogProps> = ({
|
||||
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();
|
||||
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();
|
||||
await refreshAfterChange({ affectsIndex: kind !== 'drop' });
|
||||
} finally {
|
||||
setOperation(null);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { GitStatus } from '@/lib/api/types';
|
||||
|
||||
export const TREE_INDENT_PX = 14;
|
||||
|
||||
export type ChangesTreeDirectoryNode = {
|
||||
id: string;
|
||||
path: string;
|
||||
name: string;
|
||||
children: Map<string, ChangesTreeDirectoryNode>;
|
||||
directFiles: GitStatus['files'];
|
||||
files: GitStatus['files'];
|
||||
};
|
||||
|
||||
export type FlattenedTreeRow =
|
||||
| {
|
||||
key: string;
|
||||
kind: 'directory';
|
||||
depth: number;
|
||||
directory: ChangesTreeDirectoryNode;
|
||||
}
|
||||
| {
|
||||
key: string;
|
||||
kind: 'file';
|
||||
depth: number;
|
||||
file: GitStatus['files'][number];
|
||||
};
|
||||
|
||||
export const normalizePathForTree = (value: string): string =>
|
||||
value.replace(/\\/g, '/').replace(/^\/+/, '').trim();
|
||||
|
||||
const createDirectoryNode = (path: string, name: string): ChangesTreeDirectoryNode => ({
|
||||
id: `dir:${path}`,
|
||||
path,
|
||||
name,
|
||||
children: new Map(),
|
||||
directFiles: [],
|
||||
files: [],
|
||||
});
|
||||
|
||||
export const buildChangesTree = (entries: GitStatus['files']): ChangesTreeDirectoryNode => {
|
||||
const root = createDirectoryNode('', '');
|
||||
|
||||
for (const file of entries) {
|
||||
const normalized = normalizePathForTree(file.path);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const segments = normalized.split('/').filter(Boolean);
|
||||
const directorySegments = segments.slice(0, -1);
|
||||
let current = root;
|
||||
current.files.push(file);
|
||||
|
||||
if (directorySegments.length > 0) {
|
||||
let currentPath = '';
|
||||
for (const segment of directorySegments) {
|
||||
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
|
||||
const existing = current.children.get(segment);
|
||||
if (existing) {
|
||||
existing.files.push(file);
|
||||
current = existing;
|
||||
continue;
|
||||
}
|
||||
|
||||
const created = createDirectoryNode(currentPath, segment);
|
||||
created.files.push(file);
|
||||
current.children.set(segment, created);
|
||||
current = created;
|
||||
}
|
||||
}
|
||||
|
||||
current.directFiles.push(file);
|
||||
}
|
||||
|
||||
return root;
|
||||
};
|
||||
|
||||
export const flattenChangesTree = (
|
||||
root: ChangesTreeDirectoryNode,
|
||||
expandedDirectories: Set<string>,
|
||||
): FlattenedTreeRow[] => {
|
||||
const rows: FlattenedTreeRow[] = [];
|
||||
|
||||
const walk = (node: ChangesTreeDirectoryNode, depth: number) => {
|
||||
const directories = Array.from(node.children.values()).sort((a, b) => a.path.localeCompare(b.path));
|
||||
for (const directory of directories) {
|
||||
rows.push({
|
||||
key: directory.id,
|
||||
kind: 'directory',
|
||||
depth,
|
||||
directory,
|
||||
});
|
||||
|
||||
if (expandedDirectories.has(directory.path)) {
|
||||
walk(directory, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const directFiles = [...node.directFiles].sort((a, b) => a.path.localeCompare(b.path));
|
||||
|
||||
for (const file of directFiles) {
|
||||
rows.push({
|
||||
key: `file:${normalizePathForTree(file.path)}`,
|
||||
kind: 'file',
|
||||
depth,
|
||||
file,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
walk(root, 0);
|
||||
return rows;
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { createGitIndexMutationQueue, type GitIndexMutationDirection } from './gitIndexMutationQueue';
|
||||
|
||||
type Deferred<T> = {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (error: unknown) => void;
|
||||
};
|
||||
|
||||
const createDeferred = <T>(): Deferred<T> => {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
const waitMicrotask = async () => {
|
||||
await Promise.resolve();
|
||||
};
|
||||
|
||||
describe('createGitIndexMutationQueue', () => {
|
||||
test('coalesces consecutive mutations with the same directory and direction', async () => {
|
||||
const calls: Array<{ direction: GitIndexMutationDirection; paths: string[] }> = [];
|
||||
const queue = createGitIndexMutationQueue({
|
||||
runMutation: async ({ direction, paths }) => {
|
||||
calls.push({ direction, paths });
|
||||
},
|
||||
onMutationComplete: () => {},
|
||||
onMutationError: () => {},
|
||||
onPathsComplete: () => {},
|
||||
scheduleFlush: () => queue.flush(),
|
||||
});
|
||||
|
||||
queue.enqueue({ directory: '/repo', direction: 'stage', paths: new Set(['a.ts']) });
|
||||
queue.enqueue({ directory: '/repo', direction: 'stage', paths: new Set(['b.ts', 'a.ts']) });
|
||||
queue.flush();
|
||||
await waitMicrotask();
|
||||
|
||||
expect(calls).toEqual([{ direction: 'stage', paths: ['a.ts', 'b.ts'] }]);
|
||||
});
|
||||
|
||||
test('serializes mutations and preserves alternating direction order', async () => {
|
||||
const first = createDeferred<void>();
|
||||
const calls: Array<{ direction: GitIndexMutationDirection; paths: string[] }> = [];
|
||||
let callCount = 0;
|
||||
|
||||
const queue = createGitIndexMutationQueue({
|
||||
runMutation: ({ direction, paths }) => {
|
||||
calls.push({ direction, paths });
|
||||
callCount += 1;
|
||||
return callCount === 1 ? first.promise : Promise.resolve();
|
||||
},
|
||||
onMutationComplete: () => {},
|
||||
onMutationError: () => {},
|
||||
onPathsComplete: () => {},
|
||||
scheduleFlush: () => queue.flush(),
|
||||
});
|
||||
|
||||
queue.enqueue({ directory: '/repo', direction: 'stage', paths: new Set(['a.ts']) });
|
||||
queue.enqueue({ directory: '/repo', direction: 'unstage', paths: new Set(['a.ts']) });
|
||||
queue.flush();
|
||||
queue.flush();
|
||||
await waitMicrotask();
|
||||
|
||||
expect(calls).toEqual([{ direction: 'stage', paths: ['a.ts'] }]);
|
||||
expect(queue.isRunning()).toBe(true);
|
||||
|
||||
first.resolve();
|
||||
await waitMicrotask();
|
||||
await waitMicrotask();
|
||||
|
||||
expect(calls).toEqual([
|
||||
{ direction: 'stage', paths: ['a.ts'] },
|
||||
{ direction: 'unstage', paths: ['a.ts'] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('reports errors, completes paths, and continues the queue', async () => {
|
||||
const errors: unknown[] = [];
|
||||
const completedPaths: string[][] = [];
|
||||
const completedDirections: GitIndexMutationDirection[] = [];
|
||||
let callCount = 0;
|
||||
|
||||
const queue = createGitIndexMutationQueue({
|
||||
runMutation: async ({ direction }) => {
|
||||
callCount += 1;
|
||||
if (callCount === 1) {
|
||||
throw new Error(`${direction} failed`);
|
||||
}
|
||||
},
|
||||
onMutationComplete: ({ direction }) => {
|
||||
completedDirections.push(direction);
|
||||
},
|
||||
onMutationError: (_mutation, error) => {
|
||||
errors.push(error);
|
||||
},
|
||||
onPathsComplete: (paths) => {
|
||||
completedPaths.push(paths);
|
||||
},
|
||||
scheduleFlush: () => queue.flush(),
|
||||
});
|
||||
|
||||
queue.enqueue({ directory: '/repo', direction: 'stage', paths: new Set(['a.ts']) });
|
||||
queue.enqueue({ directory: '/repo', direction: 'unstage', paths: new Set(['b.ts']) });
|
||||
queue.flush();
|
||||
await waitMicrotask();
|
||||
await waitMicrotask();
|
||||
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(completedDirections).toEqual(['unstage']);
|
||||
expect(completedPaths).toEqual([['a.ts'], ['b.ts']]);
|
||||
});
|
||||
|
||||
test('passes rollback callbacks to error handlers', async () => {
|
||||
let rollbackCalled = false;
|
||||
const queue = createGitIndexMutationQueue({
|
||||
runMutation: async () => {
|
||||
throw new Error('stage failed');
|
||||
},
|
||||
onMutationComplete: () => {},
|
||||
onMutationError: (mutation) => {
|
||||
mutation.rollback?.();
|
||||
},
|
||||
onPathsComplete: () => {},
|
||||
scheduleFlush: () => queue.flush(),
|
||||
});
|
||||
|
||||
queue.enqueue({
|
||||
directory: '/repo',
|
||||
direction: 'stage',
|
||||
paths: new Set(['a.ts']),
|
||||
rollback: () => {
|
||||
rollbackCalled = true;
|
||||
},
|
||||
});
|
||||
queue.flush();
|
||||
await waitMicrotask();
|
||||
|
||||
expect(rollbackCalled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
export type GitIndexMutationDirection = 'stage' | 'unstage';
|
||||
|
||||
export type QueuedGitIndexMutation = {
|
||||
directory: string;
|
||||
direction: GitIndexMutationDirection;
|
||||
paths: Set<string>;
|
||||
rollback?: () => void;
|
||||
};
|
||||
|
||||
type MutationSnapshot = {
|
||||
directory: string;
|
||||
direction: GitIndexMutationDirection;
|
||||
paths: string[];
|
||||
rollback?: () => void;
|
||||
};
|
||||
|
||||
type GitIndexMutationQueueOptions = {
|
||||
runMutation: (mutation: MutationSnapshot) => Promise<void>;
|
||||
onMutationComplete: (mutation: MutationSnapshot) => void;
|
||||
onMutationError: (mutation: MutationSnapshot, error: unknown) => void;
|
||||
onPathsComplete: (paths: string[]) => void;
|
||||
scheduleFlush: () => void;
|
||||
};
|
||||
|
||||
export type GitIndexMutationQueue = {
|
||||
enqueue: (mutation: QueuedGitIndexMutation) => void;
|
||||
flush: () => void;
|
||||
clear: () => void;
|
||||
size: () => number;
|
||||
isRunning: () => boolean;
|
||||
};
|
||||
|
||||
export const createGitIndexMutationQueue = ({
|
||||
runMutation,
|
||||
onMutationComplete,
|
||||
onMutationError,
|
||||
onPathsComplete,
|
||||
scheduleFlush,
|
||||
}: GitIndexMutationQueueOptions): GitIndexMutationQueue => {
|
||||
const queuedMutations: QueuedGitIndexMutation[] = [];
|
||||
let running = false;
|
||||
|
||||
const flush = () => {
|
||||
if (running) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextMutation = queuedMutations.shift();
|
||||
if (!nextMutation) {
|
||||
return;
|
||||
}
|
||||
|
||||
running = true;
|
||||
const snapshot: MutationSnapshot = {
|
||||
directory: nextMutation.directory,
|
||||
direction: nextMutation.direction,
|
||||
paths: Array.from(nextMutation.paths),
|
||||
rollback: nextMutation.rollback,
|
||||
};
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
await runMutation(snapshot);
|
||||
onMutationComplete(snapshot);
|
||||
} catch (error) {
|
||||
onMutationError(snapshot, error);
|
||||
} finally {
|
||||
onPathsComplete(snapshot.paths);
|
||||
running = false;
|
||||
if (queuedMutations.length > 0) {
|
||||
scheduleFlush();
|
||||
}
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
return {
|
||||
enqueue: (mutation) => {
|
||||
const lastMutation = queuedMutations[queuedMutations.length - 1];
|
||||
if (lastMutation?.directory === mutation.directory && lastMutation.direction === mutation.direction) {
|
||||
mutation.paths.forEach((path) => lastMutation.paths.add(path));
|
||||
return;
|
||||
}
|
||||
|
||||
queuedMutations.push(mutation);
|
||||
},
|
||||
flush,
|
||||
clear: () => {
|
||||
queuedMutations.length = 0;
|
||||
},
|
||||
size: () => queuedMutations.length,
|
||||
isRunning: () => running,
|
||||
};
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
export { GitHeader } from './GitHeader';
|
||||
export { GitEmptyState } from './GitEmptyState';
|
||||
export { ChangesSection } from './ChangesSection';
|
||||
export { ChangesPanel } from './ChangesPanel';
|
||||
export type { ChangesGroupConfig } from './ChangesPanel';
|
||||
export { ChangeRow } from './ChangeRow';
|
||||
export { CommitSection } from './CommitSection';
|
||||
export { CommitInput } from './CommitInput';
|
||||
|
||||
Reference in New Issue
Block a user