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
+32 -8
View File
@@ -1711,16 +1711,23 @@ export const GitView: React.FC = () => {
[currentDirectory, refreshStatusAndBranches, git, t]
);
const handleRevertAll = React.useCallback(
async (paths: string[]) => {
if (!currentDirectory || paths.length === 0 || isRevertingAll) {
const handleRevertPaths = React.useCallback(
async (paths: string[], setGlobalReverting: boolean, scope: 'all' | 'working' = 'all') => {
if (!currentDirectory || paths.length === 0) {
return;
}
const uniquePaths = Array.from(new Set(paths));
if (isRevertingAll || uniquePaths.some((path) => revertingPaths.has(path))) {
return;
}
const stagedPaths = new Set(stagedChangeEntries.map((entry) => entry.path));
const touchesStagedIndex = uniquePaths.some((path) => stagedPaths.has(path));
setIsRevertingAll(true);
const touchesStagedIndex = scope === 'all' && uniquePaths.some((path) => stagedPaths.has(path));
if (setGlobalReverting) {
setIsRevertingAll(true);
}
setRevertingPaths((previous) => {
const next = new Set(previous);
uniquePaths.forEach((path) => next.add(path));
@@ -1732,7 +1739,7 @@ export const GitView: React.FC = () => {
try {
await Promise.all(uniquePaths.map(async (filePath) => {
try {
await git.revertGitFile(currentDirectory, filePath);
await git.revertGitFile(currentDirectory, filePath, { scope });
} catch (err) {
failed.push({
path: filePath,
@@ -1769,10 +1776,26 @@ export const GitView: React.FC = () => {
uniquePaths.forEach((path) => next.delete(path));
return next;
});
setIsRevertingAll(false);
if (setGlobalReverting) {
setIsRevertingAll(false);
}
}
},
[bumpIndexRevision, currentDirectory, git, isRevertingAll, refreshStatusAndBranches, stagedChangeEntries, t]
[bumpIndexRevision, currentDirectory, git, isRevertingAll, refreshStatusAndBranches, revertingPaths, stagedChangeEntries, t]
);
const handleRevertAll = React.useCallback(
async (paths: string[]) => {
await handleRevertPaths(paths, true);
},
[handleRevertPaths]
);
const handleRevertDirectory = React.useCallback(
async (paths: string[]) => {
await handleRevertPaths(paths, false, 'working');
},
[handleRevertPaths]
);
const handleViewChangeDiff = React.useCallback((path: string, staged: boolean) => {
@@ -2405,6 +2428,7 @@ export const GitView: React.FC = () => {
isRevertingAll={isRevertingAll}
onVisiblePathsChange={setVisibleChangePaths}
onRevertAll={handleRevertAll}
onRevertDirectory={handleRevertDirectory}
/>
</div>
@@ -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>
</>
);
};
+11 -5
View File
@@ -546,24 +546,30 @@ export const dict = {
'gitView.branch.updateTitle': 'Update branch',
'gitView.changes.changedFilesAria': 'Changed files',
'gitView.changes.clearSelectionAria': 'Clear file selection',
'gitView.changes.collapseDirectoryAria': 'Collapse Directory aria label',
'gitView.changes.expandDirectoryAria': 'Expand Directory aria label',
'gitView.changes.collapseDirectoryAria': 'Collapse directory {path}',
'gitView.changes.expandDirectoryAria': 'Expand directory {path}',
'gitView.changes.revertAll': 'Revert all',
'gitView.changes.revertAllDescriptionPlural': 'Revert {count} changed files? This cannot be undone.',
'gitView.changes.revertAllDescriptionSingle': 'Revert {count} changed file? This cannot be undone.',
'gitView.changes.revertAllDialogTitle': 'Revert all changes?',
'gitView.changes.revertFileAria': 'Revert File aria label',
'gitView.changes.revertDirectoryAria': 'Revert changes in {path}',
'gitView.changes.revertDirectory': 'Revert folder',
'gitView.changes.revertDirectoryDescriptionPlural': 'This will discard local changes in {count} files under {path}.',
'gitView.changes.revertDirectoryDescriptionSingle': 'This will discard local changes in {count} file under {path}.',
'gitView.changes.revertDirectoryDialogTitle': 'Revert folder changes?',
'gitView.changes.revertDirectoryTooltip': 'Revert folder changes',
'gitView.changes.revertFileAria': 'Revert changes in {path}',
'gitView.changes.revertFileTooltip': 'Revert changes',
'gitView.changes.reverting': 'Reverting...',
'gitView.changes.selectAllAria': 'Select all files',
'gitView.changes.selectFileAria': 'Select File aria label',
'gitView.changes.selectFileAria': 'Select {path}',
'gitView.changes.stagedTitle': 'Staged',
'gitView.changes.resizeSplitAria': 'Resize staged and unstaged changes',
'gitView.changes.stageAllAria': 'Stage all changes',
'gitView.changes.stageDirectoryAria': 'Stage all changes in {path}',
'gitView.changes.stageFileAria': 'Stage {path}',
'gitView.changes.title': 'Changes',
'gitView.changes.toggleDirectorySelectionAria': 'Toggle Directory Selection aria label',
'gitView.changes.toggleDirectorySelectionAria': 'Toggle selection for {path}',
'gitView.changes.unstageAllAria': 'Unstage all changes',
'gitView.changes.unstageDirectoryAria': 'Unstage all changes in {path}',
'gitView.changes.unstageFileAria': 'Unstage {path}',
+6
View File
@@ -553,6 +553,12 @@ export const dict: Record<I18nKey, string> = {
"gitView.changes.revertAllDescriptionPlural": "Se descartarán los cambios locales de todos los archivos de la lista.",
"gitView.changes.revertAllDescriptionSingle": "Se descartarán los cambios locales del archivo seleccionado.",
"gitView.changes.revertAllDialogTitle": "¿Revertir todos los cambios?",
"gitView.changes.revertDirectoryAria": "Revertir cambios en {path}",
"gitView.changes.revertDirectory": "Revertir carpeta",
"gitView.changes.revertDirectoryDescriptionPlural": "Se descartarán los cambios locales de {count} archivos en {path}.",
"gitView.changes.revertDirectoryDescriptionSingle": "Se descartarán los cambios locales de {count} archivo en {path}.",
"gitView.changes.revertDirectoryDialogTitle": "¿Revertir cambios de la carpeta?",
"gitView.changes.revertDirectoryTooltip": "Revertir cambios de la carpeta",
"gitView.changes.revertFileAria": "Revertir archivo",
"gitView.changes.revertFileTooltip": "Revertir cambios",
"gitView.changes.reverting": "Revertiendo...",
+6
View File
@@ -553,6 +553,12 @@ export const dict: Record<I18nKey, string> = {
'gitView.changes.revertAllDescriptionPlural': '선택한 파일 {count}개의 변경 사항을 되돌립니다.',
'gitView.changes.revertAllDescriptionSingle': '선택한 파일 {count}개의 변경 사항을 되돌립니다.',
'gitView.changes.revertAllDialogTitle': '모든 변경 사항을 되돌릴까요?',
'gitView.changes.revertDirectoryAria': '{path} 디렉터리의 변경 사항 되돌리기',
'gitView.changes.revertDirectory': '폴더 되돌리기',
'gitView.changes.revertDirectoryDescriptionPlural': '{path} 아래 파일 {count}개의 로컬 변경 사항을 버립니다.',
'gitView.changes.revertDirectoryDescriptionSingle': '{path} 아래 파일 {count}개의 로컬 변경 사항을 버립니다.',
'gitView.changes.revertDirectoryDialogTitle': '폴더 변경 사항을 되돌릴까요?',
'gitView.changes.revertDirectoryTooltip': '폴더 변경 사항 되돌리기',
'gitView.changes.revertFileAria': '파일 변경 사항 되돌리기',
'gitView.changes.revertFileTooltip': '변경 사항 되돌리기',
'gitView.changes.reverting': '되돌리는 중…',
+6
View File
@@ -1583,6 +1583,12 @@ export const dict: Record<I18nKey, string> = {
'gitView.changes.revertAllDescriptionPlural': 'Zmiany we wszystkich plikach z listy zostaną odrzucone.',
'gitView.changes.revertAllDescriptionSingle': 'Zmiany w wybranym pliku zostaną odrzucone.',
'gitView.changes.revertAllDialogTitle': 'Cofnąć wszystkie zmiany?',
'gitView.changes.revertDirectoryAria': 'Cofnij zmiany w katalogu {path}',
'gitView.changes.revertDirectory': 'Cofnij folder',
'gitView.changes.revertDirectoryDescriptionPlural': 'Zmiany lokalne w {count} plikach w katalogu {path} zostaną odrzucone.',
'gitView.changes.revertDirectoryDescriptionSingle': 'Zmiany lokalne w {count} pliku w katalogu {path} zostaną odrzucone.',
'gitView.changes.revertDirectoryDialogTitle': 'Cofnąć zmiany w folderze?',
'gitView.changes.revertDirectoryTooltip': 'Cofnij zmiany w folderze',
'gitView.changes.revertFileAria': 'Cofnij plik',
'gitView.changes.revertFileTooltip': 'Cofnij zmiany',
'gitView.changes.reverting': 'Cofanie...',
@@ -553,6 +553,12 @@ export const dict: Record<I18nKey, string> = {
"gitView.changes.revertAllDescriptionPlural": "As alterações locais de todos os arquivos da lista serão descartadas.",
"gitView.changes.revertAllDescriptionSingle": "As alterações locais do arquivo selecionado serão descartadas.",
"gitView.changes.revertAllDialogTitle": "Reverter todas as alterações?",
"gitView.changes.revertDirectoryAria": "Reverter alterações em {path}",
"gitView.changes.revertDirectory": "Reverter pasta",
"gitView.changes.revertDirectoryDescriptionPlural": "As alterações locais em {count} arquivos dentro de {path} serão descartadas.",
"gitView.changes.revertDirectoryDescriptionSingle": "As alterações locais em {count} arquivo dentro de {path} serão descartadas.",
"gitView.changes.revertDirectoryDialogTitle": "Reverter alterações da pasta?",
"gitView.changes.revertDirectoryTooltip": "Reverter alterações da pasta",
"gitView.changes.revertFileAria": "Revertir arquivo",
"gitView.changes.revertFileTooltip": "Revertir alterações",
"gitView.changes.reverting": "Revertiendo...",
+6
View File
@@ -553,6 +553,12 @@ export const dict: Record<I18nKey, string> = {
"gitView.changes.revertAllDescriptionPlural": "Скасувати зміни у вибраних файлах?",
"gitView.changes.revertAllDescriptionSingle": "Скасувати зміни у вибраному файлі?",
"gitView.changes.revertAllDialogTitle": "Скасувати всі зміни?",
"gitView.changes.revertDirectoryAria": "Скасувати зміни в каталозі {path}",
"gitView.changes.revertDirectory": "Скасувати папку",
"gitView.changes.revertDirectoryDescriptionPlural": "Локальні зміни в {count} файлах у {path} буде відкинуто.",
"gitView.changes.revertDirectoryDescriptionSingle": "Локальні зміни в {count} файлі у {path} буде відкинуто.",
"gitView.changes.revertDirectoryDialogTitle": "Скасувати зміни в папці?",
"gitView.changes.revertDirectoryTooltip": "Скасувати зміни в папці",
"gitView.changes.revertFileAria": "Скасувати зміни у файлі",
"gitView.changes.revertFileTooltip": "Скасувати зміни",
"gitView.changes.reverting": "Скасування...",
@@ -553,6 +553,12 @@ export const dict: Record<I18nKey, string> = {
'gitView.changes.revertAllDescriptionPlural': '这将丢弃列表中 {count} 个文件的本地更改。',
'gitView.changes.revertAllDescriptionSingle': '这将丢弃列表中 {count} 个文件的本地更改。',
'gitView.changes.revertAllDialogTitle': '还原所有更改?',
'gitView.changes.revertDirectoryAria': '还原 {path} 中的更改',
'gitView.changes.revertDirectory': '还原文件夹',
'gitView.changes.revertDirectoryDescriptionPlural': '这将丢弃 {path} 下 {count} 个文件的本地更改。',
'gitView.changes.revertDirectoryDescriptionSingle': '这将丢弃 {path} 下 {count} 个文件的本地更改。',
'gitView.changes.revertDirectoryDialogTitle': '还原文件夹更改?',
'gitView.changes.revertDirectoryTooltip': '还原文件夹更改',
'gitView.changes.revertFileAria': '还原 {path} 的更改',
'gitView.changes.revertFileTooltip': '还原更改',
'gitView.changes.reverting': '正在还原...',
@@ -566,6 +566,12 @@ export const dict: Record<I18nKey, string> = {
'gitView.changes.revertAllDescriptionPlural': '這將捨棄列表中 {count} 個檔案的本地變更。',
'gitView.changes.revertAllDescriptionSingle': '這將捨棄列表中 {count} 個檔案的本地變更。',
'gitView.changes.revertAllDialogTitle': '還原所有變更?',
'gitView.changes.revertDirectoryAria': '還原 {path} 中的變更',
'gitView.changes.revertDirectory': '還原資料夾',
'gitView.changes.revertDirectoryDescriptionPlural': '這將捨棄 {path} 下 {count} 個檔案的本地變更。',
'gitView.changes.revertDirectoryDescriptionSingle': '這將捨棄 {path} 下 {count} 個檔案的本地變更。',
'gitView.changes.revertDirectoryDialogTitle': '還原資料夾變更?',
'gitView.changes.revertDirectoryTooltip': '還原資料夾變更',
'gitView.changes.revertFileAria': '還原 {path} 的變更',
'gitView.changes.revertFileTooltip': '還原變更',
'gitView.changes.reverting': '正在還原...',
+1
View File
@@ -385,6 +385,7 @@
@media (display-mode: standalone) and (max-width: 768px) {
.pwa-dialog-content {
--pwa-dialog-padding: clamp(0.75rem, 3vw, 1.25rem);
top: auto;
/* Browsers that don't understand dvh ignore the second declaration and
keep the vh-based fallback. Newer browsers use 100dvh so Android
Chrome's collapsible URL bar doesn't push the dialog footer off-screen. */