From 9b52222ef1c4cfd99d3bd29d960ad61e49f7ebd3 Mon Sep 17 00:00:00 2001 From: kostazol Date: Mon, 8 Jun 2026 19:55:56 +0700 Subject: [PATCH] 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 Co-authored-by: Bohdan Triapitsyn --- packages/ui/src/components/views/GitView.tsx | 40 ++++++++-- .../src/components/views/git/ChangesPanel.tsx | 73 ++++++++++++++++++- packages/ui/src/lib/i18n/messages/en.ts | 16 ++-- packages/ui/src/lib/i18n/messages/es.ts | 6 ++ packages/ui/src/lib/i18n/messages/ko.ts | 6 ++ packages/ui/src/lib/i18n/messages/pl.ts | 6 ++ packages/ui/src/lib/i18n/messages/pt-BR.ts | 6 ++ packages/ui/src/lib/i18n/messages/uk.ts | 6 ++ packages/ui/src/lib/i18n/messages/zh-CN.ts | 6 ++ packages/ui/src/lib/i18n/messages/zh-TW.ts | 6 ++ packages/ui/src/styles/mobile.css | 1 + 11 files changed, 158 insertions(+), 14 deletions(-) diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index 8a4e1108..9eb7ce31 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -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} /> diff --git a/packages/ui/src/components/views/git/ChangesPanel.tsx b/packages/ui/src/components/views/git/ChangesPanel.tsx index 2cd2d0f9..f3f85075 100644 --- a/packages/ui/src/components/views/git/ChangesPanel.tsx +++ b/packages/ui/src/components/views/git/ChangesPanel.tsx @@ -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; + onRevertDirectory?: (paths: string[]) => Promise | 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 = ({ @@ -77,6 +84,7 @@ export const ChangesPanel: React.FC = ({ headerBackgroundClassName = 'bg-sidebar', onVisiblePathsChange, onRevertAll, + onRevertDirectory, }) => { const { t } = useI18n(); const scrollRef = React.useRef(null); @@ -88,6 +96,7 @@ export const ChangesPanel: React.FC = ({ const [collapsedGroups, setCollapsedGroups] = React.useState>(new Set()); const [expandedDirectories, setExpandedDirectories] = React.useState>(new Set()); const [revertAllOpen, setRevertAllOpen] = React.useState(false); + const [pendingDirectoryRevert, setPendingDirectoryRevert] = React.useState(null); const trees = React.useMemo( () => visibleGroups.map((group) => buildChangesTree(group.entries)), @@ -276,6 +285,9 @@ export const ChangesPanel: React.FC = ({ 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 = ({ 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 = ({ 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 (
= ({ {directory.files.length} + {group.showRevertActions !== false && onRevertDirectory ? ( + + ) : null}
); }, - [expandedDirectories, t, toggleDirectoryExpanded] + [expandedDirectories, isRevertingAll, onRevertDirectory, revertingPaths, t, toggleDirectoryExpanded] ); const renderRow = React.useCallback( @@ -523,6 +561,39 @@ export const ChangesPanel: React.FC = ({ + + { + if (!isPendingDirectoryReverting && !open) setPendingDirectoryRevert(null); + }} + > + + + {t('gitView.changes.revertDirectoryDialogTitle')} + + {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} + + + + + + + + ); }; diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 3622918b..cc5641fa 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -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}', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 0b8ec886..93954178 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -553,6 +553,12 @@ export const dict: Record = { "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...", diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index c5a21adb..157c1bcb 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -553,6 +553,12 @@ export const dict: Record = { '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': '되돌리는 중…', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 841cdb9f..1cb2e326 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1583,6 +1583,12 @@ export const dict: Record = { '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...', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index e3dbc9ea..dd6ab2ef 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -553,6 +553,12 @@ export const dict: Record = { "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...", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 24c403c0..d75f153b 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -553,6 +553,12 @@ export const dict: Record = { "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": "Скасування...", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index f0fe33b8..daef3318 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -553,6 +553,12 @@ export const dict: Record = { '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': '正在还原...', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 3159a40d..ef3ecff9 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -566,6 +566,12 @@ export const dict: Record = { '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': '正在還原...', diff --git a/packages/ui/src/styles/mobile.css b/packages/ui/src/styles/mobile.css index 7b4daed9..20cb5b9c 100644 --- a/packages/ui/src/styles/mobile.css +++ b/packages/ui/src/styles/mobile.css @@ -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. */