Add folder-level revert action in Git changes tree (#1390)

* feat: add folder-level revert for git changes

* Fix bot comments. Fix confirmation dialog for mobile view.

* fix

---------

Co-authored-by: Konstantin Zolin <zolin_ka@vk.com>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
kostazol
2026-06-08 15:55:56 +03:00
committed by GitHub
co-authored by Konstantin Zolin Bohdan Triapitsyn
parent 7b1b3167a4
commit 9b52222ef1
11 changed files with 158 additions and 14 deletions
@@ -53,6 +53,7 @@ interface ChangesPanelProps {
onVisiblePathsChange?: (paths: string[]) => void;
/** Reverts every changed path across all groups; rendered once for the panel. */
onRevertAll?: (paths: string[]) => Promise<void> | void;
onRevertDirectory?: (paths: string[]) => Promise<void> | void;
}
const CHANGE_LIST_VIRTUALIZE_THRESHOLD = 1000;
@@ -67,6 +68,12 @@ type PanelRow =
| { type: 'directory'; key: string; groupIndex: number; directory: ChangesTreeDirectoryNode; depth: number }
| { type: 'revert-all'; key: string };
type PendingDirectoryRevert = {
path: string;
paths: string[];
count: number;
};
const expandedKey = (groupId: string, path: string): string => `${groupId} ${path}`;
export const ChangesPanel: React.FC<ChangesPanelProps> = ({
@@ -77,6 +84,7 @@ export const ChangesPanel: React.FC<ChangesPanelProps> = ({
headerBackgroundClassName = 'bg-sidebar',
onVisiblePathsChange,
onRevertAll,
onRevertDirectory,
}) => {
const { t } = useI18n();
const scrollRef = React.useRef<HTMLDivElement | null>(null);
@@ -88,6 +96,7 @@ export const ChangesPanel: React.FC<ChangesPanelProps> = ({
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 [pendingDirectoryRevert, setPendingDirectoryRevert] = React.useState<PendingDirectoryRevert | null>(null);
const trees = React.useMemo(
() => visibleGroups.map((group) => buildChangesTree(group.entries)),
@@ -276,6 +285,9 @@ export const ChangesPanel: React.FC<ChangesPanelProps> = ({
return Array.from(seen);
}, [visibleGroups]);
const revertAllCount = allChangePaths.length;
const isPendingDirectoryReverting = pendingDirectoryRevert
? isRevertingAll || pendingDirectoryRevert.paths.some((path) => revertingPaths.has(path))
: false;
const handleConfirmRevertAll = React.useCallback(async () => {
if (!onRevertAll || isRevertingAll || allChangePaths.length === 0) {
@@ -285,6 +297,14 @@ export const ChangesPanel: React.FC<ChangesPanelProps> = ({
setRevertAllOpen(false);
}, [allChangePaths, isRevertingAll, onRevertAll]);
const handleConfirmRevertDirectory = React.useCallback(async () => {
if (!onRevertDirectory || !pendingDirectoryRevert || isPendingDirectoryReverting) {
return;
}
await onRevertDirectory(pendingDirectoryRevert.paths);
setPendingDirectoryRevert(null);
}, [isPendingDirectoryReverting, onRevertDirectory, pendingDirectoryRevert]);
const renderHeader = React.useCallback(
(group: ChangesGroupConfig, isFirst: boolean) => {
const collapsed = collapsedGroups.has(group.id);
@@ -333,6 +353,8 @@ export const ChangesPanel: React.FC<ChangesPanelProps> = ({
const renderDirectory = React.useCallback(
(group: ChangesGroupConfig, directory: ChangesTreeDirectoryNode, depth: number) => {
const isExpanded = expandedDirectories.has(expandedKey(group.id, directory.path));
const directoryPaths = directory.files.map((file) => file.path);
const isDirectoryReverting = isRevertingAll || directoryPaths.some((path) => revertingPaths.has(path));
return (
<div
className={cn('group flex items-center gap-2 py-1.5 hover:bg-sidebar/40', ROW_PADDING_CLASSNAME)}
@@ -358,6 +380,22 @@ export const ChangesPanel: React.FC<ChangesPanelProps> = ({
</span>
<span className="ml-auto shrink-0 typography-micro text-muted-foreground">{directory.files.length}</span>
</button>
{group.showRevertActions !== false && onRevertDirectory ? (
<button
type="button"
onClick={() => setPendingDirectoryRevert({ path: directory.path, paths: directoryPaths, count: directoryPaths.length })}
disabled={isDirectoryReverting}
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-[var(--interactive-focus-ring)] disabled:cursor-not-allowed disabled:opacity-50"
aria-label={t('gitView.changes.revertDirectoryAria', { path: directory.path })}
title={t('gitView.changes.revertDirectoryTooltip')}
>
{isDirectoryReverting ? (
<Icon name="loader-4" className="size-3.5 animate-spin" />
) : (
<Icon name="arrow-go-back" className="size-3.5" />
)}
</button>
) : null}
<button
type="button"
onClick={() => group.onActionAll(directory.files.map((file) => file.path))}
@@ -376,7 +414,7 @@ export const ChangesPanel: React.FC<ChangesPanelProps> = ({
</div>
);
},
[expandedDirectories, t, toggleDirectoryExpanded]
[expandedDirectories, isRevertingAll, onRevertDirectory, revertingPaths, t, toggleDirectoryExpanded]
);
const renderRow = React.useCallback(
@@ -523,6 +561,39 @@ export const ChangesPanel: React.FC<ChangesPanelProps> = ({
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={!!pendingDirectoryRevert}
onOpenChange={(open) => {
if (!isPendingDirectoryReverting && !open) setPendingDirectoryRevert(null);
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t('gitView.changes.revertDirectoryDialogTitle')}</DialogTitle>
<DialogDescription>
{pendingDirectoryRevert
? pendingDirectoryRevert.count === 1
? t('gitView.changes.revertDirectoryDescriptionSingle', { count: pendingDirectoryRevert.count, path: pendingDirectoryRevert.path })
: t('gitView.changes.revertDirectoryDescriptionPlural', { count: pendingDirectoryRevert.count, path: pendingDirectoryRevert.path })
: null}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setPendingDirectoryRevert(null)} disabled={isPendingDirectoryReverting}>
{t('gitView.common.cancel')}
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => void handleConfirmRevertDirectory()}
disabled={isPendingDirectoryReverting || !pendingDirectoryRevert}
>
{isPendingDirectoryReverting ? t('gitView.changes.reverting') : t('gitView.changes.revertDirectory')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};