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:
Paolo Insogna
2026-05-24 00:49:38 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 9af0de0056
commit e16097b05d
42 changed files with 2987 additions and 923 deletions
@@ -97,11 +97,12 @@ export const PendingChangesBar: React.FC = React.memo(() => {
}
const store = useUIStore.getState();
const openStagedDiff = file.hasStagedChanges && !file.hasWorkingChanges;
if (!store.isMobile) {
store.openContextDiff(currentDirectory, file.relativePath);
store.openContextDiff(currentDirectory, file.relativePath, openStagedDiff);
return;
}
store.navigateToDiff(file.relativePath);
store.navigateToDiff(file.relativePath, openStagedDiff);
store.setRightSidebarOpen(false);
};
@@ -16,6 +16,8 @@ export interface GitChangedFile {
insertions: number;
deletions: number;
status: string;
hasStagedChanges: boolean;
hasWorkingChanges: boolean;
}
export type ChangedFileEntry = ChangedFile | GitChangedFile;
@@ -153,8 +155,12 @@ export const extractGitChangedFiles = (
): GitChangedFile[] => {
const result: GitChangedFile[] = [];
for (const file of files) {
const code = file.working_dir !== ' ' ? file.working_dir : file.index;
if (code === '!' || code === ' ') continue;
const indexStatus = file.index?.trim() ?? '';
const workingStatus = file.working_dir?.trim() ?? '';
const hasStagedChanges = Boolean(indexStatus && indexStatus !== '?');
const hasWorkingChanges = Boolean(workingStatus || indexStatus === '?');
const code = workingStatus || indexStatus;
if (!code || code === '!') continue;
const stats = diffStats?.[file.path];
result.push({
path: file.path.startsWith('/') ? file.path : (directory.endsWith('/') ? directory : directory + '/') + file.path,
@@ -162,6 +168,8 @@ export const extractGitChangedFiles = (
insertions: stats?.insertions ?? 0,
deletions: stats?.deletions ?? 0,
status: code,
hasStagedChanges,
hasWorkingChanges,
});
}
return result;
@@ -264,7 +264,7 @@ const getFileNameFromPath = (path: string | null): string | null => {
};
const getTabLabel = (
tab: { mode: ContextPanelMode; label: string | null; targetPath: string | null },
tab: { mode: ContextPanelMode; label: string | null; targetPath: string | null; stagedDiff?: boolean },
t: TranslateFn
): string => {
if (tab.label) {
@@ -288,6 +288,10 @@ const getTabLabel = (
return t('contextPanel.mode.preview');
}
if (tab.mode === 'diff') {
return tab.stagedDiff ? t('contextPanel.mode.stagedDiff') : t('contextPanel.mode.workingDiff');
}
return getModeLabel(tab.mode, t);
};
@@ -1554,7 +1558,6 @@ export const ContextPanel: React.FC = () => {
const setContextPanelWidth = useUIStore((state) => state.setContextPanelWidth);
const setActiveContextPanelTab = useUIStore((state) => state.setActiveContextPanelTab);
const reorderContextPanelTabs = useUIStore((state) => state.reorderContextPanelTabs);
const setPendingDiffFile = useUIStore((state) => state.setPendingDiffFile);
const setSelectedFilePath = useFilesViewTabsStore((state) => state.setSelectedPath);
const openContextPreview = useUIStore((state) => state.openContextPreview);
const { themeMode, lightThemeId, darkThemeId, currentTheme } = useThemeSystem();
@@ -1690,10 +1693,7 @@ export const ContextPanel: React.FC = () => {
return;
}
if (activeTab.mode === 'diff' && activeTab.targetPath) {
setPendingDiffFile(activeTab.targetPath);
}
}, [activeTab, directoryKey, setPendingDiffFile, setSelectedFilePath]);
}, [activeTab, directoryKey, setSelectedFilePath]);
const activeChatTabID = activeTab?.mode === 'chat' ? activeTab.id : null;
@@ -1797,7 +1797,18 @@ export const ContextPanel: React.FC = () => {
}), [effectiveDirectory, t, tabs]);
const activeNonChatContent = activeTab?.mode === 'diff'
? <DiffView hideStackedFileSidebar stackedDefaultCollapsedAll hideFileSelector pinSelectedFileHeaderToTopOnNavigate showOpenInEditorAction />
? (
<DiffView
key={activeTab.id}
hideStackedFileSidebar
stackedDefaultCollapsedAll
hideFileSelector
pinSelectedFileHeaderToTopOnNavigate
showOpenInEditorAction
diffScope={activeTab.stagedDiff ? 'staged' : 'working'}
targetFilePath={activeTab.targetPath}
/>
)
: activeTab?.mode === 'context'
? <ContextPanelContent />
: activeTab?.mode === 'plan'
+132 -22
View File
@@ -52,6 +52,7 @@ type FileEntry = GitStatus['files'][number] & {
};
type DiffData = { original: string; modified: string; isBinary?: boolean };
type DiffScope = 'all' | 'staged' | 'working';
const BinaryDiffPlaceholder = React.memo(() => {
const { t } = useI18n();
@@ -118,6 +119,16 @@ const isNewStatusFile = (file: GitStatus['files'][number]): boolean => {
return index === 'A' || workingDir === 'A' || index === '?' || workingDir === '?';
};
const isStagedStatusFile = (file: GitStatus['files'][number]): boolean => {
const indexCode = file.index?.trim();
return Boolean(indexCode && indexCode !== '?');
};
const isWorkingStatusFile = (file: GitStatus['files'][number]): boolean => {
const workingCode = file.working_dir?.trim();
return Boolean(workingCode) || file.index === '?';
};
const isAbsolutePath = (value: string): boolean => {
return value.startsWith('/') || value.startsWith('//') || /^[A-Za-z]:\//.test(value);
};
@@ -622,6 +633,8 @@ interface MultiFileDiffEntryProps {
showOpenInEditorAction?: boolean;
isOpeningInEditor?: boolean;
onOpenInEditor?: (filePath: string, diffData: DiffData | null) => void;
staged?: boolean;
stagedRevision?: number;
}
const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
@@ -639,6 +652,8 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
showOpenInEditorAction = false,
isOpeningInEditor = false,
onOpenInEditor,
staged = false,
stagedRevision = 0,
}) => {
const { t } = useI18n();
const { git } = useRuntimeAPIs();
@@ -656,6 +671,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
const [diffLoadError, setDiffLoadError] = React.useState<string | null>(null);
const [isLoading, setIsLoading] = React.useState(false);
const [forceRenderLarge, setForceRenderLarge] = React.useState(false);
const [stagedDiffData, setStagedDiffData] = React.useState<DiffData | null>(null);
const lastDiffRequestRef = React.useRef<string | null>(null);
const sectionRef = React.useRef<HTMLDivElement | null>(null);
@@ -663,9 +679,10 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
const renderSideBySide = layout === 'side-by-side';
const diffData = React.useMemo<DiffData | null>(() => {
if (staged) return stagedDiffData;
if (!cachedDiff) return null;
return { original: cachedDiff.original, modified: cachedDiff.modified, isBinary: cachedDiff.isBinary };
}, [cachedDiff]);
}, [cachedDiff, staged, stagedDiffData]);
const setSectionRef = React.useCallback((node: HTMLDivElement | null) => {
sectionRef.current = node;
@@ -716,6 +733,16 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
setHasBeenVisible(true);
}, [expandRequestNonce, expandRequestPath, file.path]);
React.useEffect(() => {
if (!staged) {
return;
}
setStagedDiffData(null);
setDiffLoadError(null);
lastDiffRequestRef.current = null;
}, [staged, stagedRevision]);
React.useEffect(() => {
if (!isExpanded || !hasBeenVisible) return;
if (!directory || diffData) {
@@ -724,7 +751,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
return;
}
const requestKey = `${directory}::${file.path}::${diffRetryNonce}`;
const requestKey = `${directory}::${file.path}::${staged ? `staged:${stagedRevision}` : 'unstaged'}::${diffRetryNonce}`;
if (lastDiffRequestRef.current === requestKey) {
return;
}
@@ -733,7 +760,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
setIsLoading(true);
let cancelled = false;
const fetchPromise = git.getGitFileDiff(directory, { path: file.path });
const fetchPromise = git.getGitFileDiff(directory, { path: file.path, staged });
const timeoutMs = DIFF_REQUEST_TIMEOUT_MS;
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
@@ -743,11 +770,16 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
.then((response) => {
if (cancelled) return;
setDiff(directory, file.path, {
const nextDiff = {
original: response.original ?? '',
modified: response.modified ?? '',
isBinary: response.isBinary,
});
};
if (staged) {
setStagedDiffData(nextDiff);
} else {
setDiff(directory, file.path, nextDiff);
}
setIsLoading(false);
})
.catch((error) => {
@@ -763,7 +795,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
lastDiffRequestRef.current = null;
}
};
}, [directory, diffData, diffRetryNonce, file.path, git, hasBeenVisible, isExpanded, setDiff]);
}, [directory, diffData, diffRetryNonce, file.path, git, hasBeenVisible, isExpanded, setDiff, staged, stagedRevision]);
const handleToggle = React.useCallback(() => {
handleOpenChange(!isExpanded);
@@ -936,6 +968,8 @@ interface DiffViewProps {
hideFileSelector?: boolean;
pinSelectedFileHeaderToTopOnNavigate?: boolean;
showOpenInEditorAction?: boolean;
diffScope?: DiffScope;
targetFilePath?: string | null;
}
export const DiffView: React.FC<DiffViewProps> = ({
@@ -944,6 +978,8 @@ export const DiffView: React.FC<DiffViewProps> = ({
hideFileSelector = false,
pinSelectedFileHeaderToTopOnNavigate = false,
showOpenInEditorAction = false,
diffScope = 'all',
targetFilePath = null,
}) => {
const { t } = useI18n();
const { git, files } = useRuntimeAPIs();
@@ -957,8 +993,14 @@ export const DiffView: React.FC<DiffViewProps> = ({
const ensureStatus = useGitStore((state) => state.ensureStatus);
const fetchStatus = useGitStore((state) => state.fetchStatus);
const setDiff = useGitStore((state) => state.setDiff);
const indexRevision = useGitStore(React.useCallback((state) => {
if (!effectiveDirectory) return 0;
return state.directories.get(effectiveDirectory)?.indexRevision ?? 0;
}, [effectiveDirectory]));
const [selectedFile, setSelectedFile] = React.useState<string | null>(null);
const [selectedFileStaged, setSelectedFileStaged] = React.useState(false);
const [selectedStagedDiffData, setSelectedStagedDiffData] = React.useState<DiffData | null>(null);
const [stackedExpandTarget, setStackedExpandTarget] = React.useState<string | null>(null);
const [stackedExpandRequestNonce, setStackedExpandRequestNonce] = React.useState(0);
const [pinnedStackedTarget, setPinnedStackedTarget] = React.useState<string | null>(null);
@@ -967,6 +1009,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
const lastDiffRequestRef = React.useRef<string | null>(null);
const pendingDiffFile = useUIStore((state) => state.pendingDiffFile);
const pendingDiffStaged = useUIStore((state) => state.pendingDiffStaged);
const setPendingDiffFile = useUIStore((state) => state.setPendingDiffFile);
const diffLayoutPreference = useUIStore((state) => state.diffLayoutPreference);
const diffFileLayout = useUIStore((state) => state.diffFileLayout);
@@ -977,6 +1020,8 @@ export const DiffView: React.FC<DiffViewProps> = ({
const setDiffViewMode = useUIStore((state) => state.setDiffViewMode);
const openContextFileAtLine = useUIStore((state) => state.openContextFileAtLine);
const diffWrapLines = diffWrapLinesStore;
const forcedStaged = diffScope === 'staged' ? true : diffScope === 'working' ? false : null;
const activeDiffStaged = forcedStaged ?? selectedFileStaged;
const isStackedView = diffViewMode === 'stacked';
const isMobileLayout = isMobile || screenWidth <= 768;
@@ -1085,8 +1130,14 @@ export const DiffView: React.FC<DiffViewProps> = ({
const changedFiles: FileEntry[] = React.useMemo(() => {
if (!status?.files) return [];
const diffStats = status.diffStats ?? {};
const includeFile = diffScope === 'staged'
? isStagedStatusFile
: diffScope === 'working'
? isWorkingStatusFile
: () => true;
return status.files
.filter(includeFile)
.map((file) => ({
...file,
insertions: diffStats[file.path]?.insertions ?? 0,
@@ -1094,7 +1145,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
isNew: isNewStatusFile(file),
}))
.sort((a, b) => a.path.localeCompare(b.path));
}, [status]);
}, [diffScope, status]);
const selectedFileEntry = React.useMemo(() => {
if (!selectedFile) return null;
@@ -1149,8 +1200,14 @@ export const DiffView: React.FC<DiffViewProps> = ({
// Handle pending diff file from external navigation
React.useEffect(() => {
if (diffScope !== 'all') {
return;
}
if (pendingDiffFile) {
setSelectedFile(pendingDiffFile);
setSelectedFileStaged(pendingDiffStaged);
setSelectedStagedDiffData(null);
setPendingDiffFile(null);
if (isStackedView) {
shouldPinAfterAlignRef.current = true;
@@ -1159,7 +1216,39 @@ export const DiffView: React.FC<DiffViewProps> = ({
setStackedExpandRequestNonce((nonce) => nonce + 1);
}
}
}, [isStackedView, pendingDiffFile, setPendingDiffFile]);
}, [diffScope, isStackedView, pendingDiffFile, pendingDiffStaged, setPendingDiffFile]);
React.useEffect(() => {
if (diffScope === 'all') {
return;
}
const normalizedTarget = targetFilePath?.trim();
if (!normalizedTarget) {
return;
}
setSelectedFile(normalizedTarget);
setSelectedFileStaged(diffScope === 'staged');
setSelectedStagedDiffData(null);
if (isStackedView) {
shouldPinAfterAlignRef.current = true;
pendingScrollTargetRef.current = normalizedTarget;
setStackedExpandTarget(normalizedTarget);
setStackedExpandRequestNonce((nonce) => nonce + 1);
}
}, [diffScope, isStackedView, targetFilePath]);
React.useEffect(() => {
if (!activeDiffStaged) {
return;
}
setSelectedStagedDiffData(null);
setDiffLoadError(null);
lastDiffRequestRef.current = null;
}, [activeDiffStaged, indexRevision]);
// Auto-select first file (skip if we have a pending file to consume)
React.useEffect(() => {
@@ -1355,6 +1444,8 @@ export const DiffView: React.FC<DiffViewProps> = ({
const handleSelectFile = React.useCallback((value: string) => {
setSelectedFile(value);
setSelectedFileStaged(false);
setSelectedStagedDiffData(null);
}, []);
const handleSelectFileAndScroll = React.useCallback((value: string) => {
@@ -1365,6 +1456,8 @@ export const DiffView: React.FC<DiffViewProps> = ({
pendingScrollTargetRef.current = null;
setSelectedFile(value);
setSelectedFileStaged(false);
setSelectedStagedDiffData(null);
if (!isStackedView) {
shouldPinAfterAlignRef.current = false;
@@ -1405,14 +1498,15 @@ export const DiffView: React.FC<DiffViewProps> = ({
const showFileSelector = !hideFileSelector && (!isStackedView || !showFileSidebar);
const selectedCachedDiff = useGitStore(React.useCallback((state) => {
if (!effectiveDirectory || !selectedFile) return null;
if (!effectiveDirectory || !selectedFile || activeDiffStaged) return null;
return state.directories.get(effectiveDirectory)?.diffCache.get(selectedFile) ?? null;
}, [effectiveDirectory, selectedFile]));
}, [activeDiffStaged, effectiveDirectory, selectedFile]));
const selectedDiffData = React.useMemo<DiffData | null>(() => {
if (activeDiffStaged) return selectedStagedDiffData;
if (!selectedCachedDiff) return null;
return { original: selectedCachedDiff.original, modified: selectedCachedDiff.modified, isBinary: selectedCachedDiff.isBinary };
}, [selectedCachedDiff]);
}, [activeDiffStaged, selectedCachedDiff, selectedStagedDiffData]);
const [openingEditorFilePath, setOpeningEditorFilePath] = React.useState<string | null>(null);
@@ -1433,6 +1527,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
try {
const patchResponse = await git.getGitDiff(effectiveDirectory, {
path: filePath,
staged: activeDiffStaged,
contextLines: 3,
});
targetLine = getFirstVisibleModifiedLineFromPatch(patchResponse.diff);
@@ -1443,13 +1538,15 @@ export const DiffView: React.FC<DiffViewProps> = ({
let diffForNavigation = cachedDiffData;
if (targetLine === null || !diffForNavigation) {
const response = await git.getGitFileDiff(effectiveDirectory, { path: filePath });
const response = await git.getGitFileDiff(effectiveDirectory, { path: filePath, staged: activeDiffStaged });
diffForNavigation = {
original: response.original ?? '',
modified: response.modified ?? '',
isBinary: response.isBinary,
};
setDiff(effectiveDirectory, filePath, diffForNavigation);
if (!activeDiffStaged) {
setDiff(effectiveDirectory, filePath, diffForNavigation);
}
}
const resolvedTargetLine = targetLine ?? ((diffForNavigation.isBinary || isImageFile(filePath))
@@ -1472,7 +1569,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
} finally {
setOpeningEditorFilePath((current) => (current === filePath ? null : current));
}
}, [effectiveDirectory, files, git, openContextFileAtLine, setDiff]);
}, [activeDiffStaged, effectiveDirectory, files, git, openContextFileAtLine, setDiff]);
const openSelectedFileInEditorAtChange = React.useCallback(async () => {
if (!selectedFile) {
@@ -1484,7 +1581,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
const isOpeningSelectedInEditor = Boolean(selectedFile && openingEditorFilePath === selectedFile);
const hasCurrentDiff = !!selectedCachedDiff;
const hasCurrentDiff = activeDiffStaged ? !!selectedStagedDiffData : !!selectedCachedDiff;
const isCurrentFileLoading = !isStackedView && !!selectedFile && !hasCurrentDiff;
React.useEffect(() => {
@@ -1499,19 +1596,19 @@ export const DiffView: React.FC<DiffViewProps> = ({
return;
}
if (selectedCachedDiff) {
if (activeDiffStaged ? selectedStagedDiffData : selectedCachedDiff) {
lastDiffRequestRef.current = null;
return;
}
const requestKey = `${effectiveDirectory}::${selectedFile}::${diffRetryNonce}`;
const requestKey = `${effectiveDirectory}::${selectedFile}::${activeDiffStaged ? `staged:${indexRevision}` : 'unstaged'}::${diffRetryNonce}`;
if (lastDiffRequestRef.current === requestKey) {
return;
}
lastDiffRequestRef.current = requestKey;
let cancelled = false;
const fetchPromise = git.getGitFileDiff(effectiveDirectory, { path: selectedFile });
const fetchPromise = git.getGitFileDiff(effectiveDirectory, { path: selectedFile, staged: activeDiffStaged });
const timeoutMs = DIFF_REQUEST_TIMEOUT_MS;
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
@@ -1521,11 +1618,16 @@ export const DiffView: React.FC<DiffViewProps> = ({
.then((response) => {
if (cancelled) return;
setDiff(effectiveDirectory, selectedFile, {
const nextDiff = {
original: response.original ?? '',
modified: response.modified ?? '',
isBinary: response.isBinary,
});
};
if (activeDiffStaged) {
setSelectedStagedDiffData(nextDiff);
} else {
setDiff(effectiveDirectory, selectedFile, nextDiff);
}
})
.catch((error) => {
if (cancelled) return;
@@ -1540,7 +1642,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
lastDiffRequestRef.current = null;
}
};
}, [effectiveDirectory, isStackedView, selectedFile, selectedCachedDiff, git, setDiff, diffRetryNonce]);
}, [activeDiffStaged, effectiveDirectory, indexRevision, isStackedView, selectedFile, selectedCachedDiff, selectedStagedDiffData, git, setDiff, diffRetryNonce]);
// Render only the selected diff viewer to prevent memory bloat with many files
const renderSelectedDiffViewer = () => {
@@ -1562,6 +1664,12 @@ export const DiffView: React.FC<DiffViewProps> = ({
if (!effectiveDirectory) return null;
const defaultExpandedCount = getStackedViewDefaultExpandedCount(changedFiles.length);
const getFileStaged = (path: string) => {
if (forcedStaged !== null) {
return forcedStaged;
}
return selectedFileStaged && path === selectedFile;
};
return (
<div className="flex flex-1 min-h-0 h-full gap-3 px-3 pb-3 pt-2">
@@ -1591,7 +1699,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
<div className="flex flex-col gap-3">
{changedFiles.map((file, index) => (
<MultiFileDiffEntry
key={file.path}
key={`${getFileStaged(file.path) ? 'staged' : 'unstaged'}:${file.path}`}
directory={effectiveDirectory}
file={file}
layout={getLayoutForFile(file)}
@@ -1608,6 +1716,8 @@ export const DiffView: React.FC<DiffViewProps> = ({
onOpenInEditor={(filePath, diffData) => {
void openFileInEditorAtChange(filePath, diffData);
}}
staged={getFileStaged(file.path)}
stagedRevision={indexRevision}
/>
))}
</div>
+252 -93
View File
@@ -2,7 +2,7 @@ import React from 'react';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useConfigStore } from '@/stores/useConfigStore';
import { useFireworksCelebration } from '@/contexts/FireworksContext';
import type { GitIdentityProfile, CommitFileEntry } from '@/lib/api/types';
import type { GitIdentityProfile, CommitFileEntry, GitStatus } from '@/lib/api/types';
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
import { useShallow } from 'zustand/react/shallow';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
@@ -47,7 +47,7 @@ import { IntegrateCommitsSection } from './git/IntegrateCommitsSection';
import { GitHeader } from './git/GitHeader';
import { StashesDialog } from './git/StashesDialog';
import { ChangesSection } from './git/ChangesSection';
import { ChangesPanel, type ChangesGroupConfig } from './git/ChangesPanel';
import { CommitSection } from './git/CommitSection';
import { GitEmptyState } from './git/GitEmptyState';
import { HistorySection } from './git/HistorySection';
@@ -56,6 +56,7 @@ import { ConflictDialog } from './git/ConflictDialog';
import { StashDialog } from './git/StashDialog';
import { InProgressOperationBanner } from './git/InProgressOperationBanner';
import { BranchIntegrationSection, type OperationLogEntry } from './git/BranchIntegrationSection';
import { createGitIndexMutationQueue, type GitIndexMutationDirection, type GitIndexMutationQueue } from './git/gitIndexMutationQueue';
import type { GitRemote } from '@/lib/gitApi';
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
import { cn } from '@/lib/utils';
@@ -74,13 +75,13 @@ type HistoryBranchDivider = {
} | null;
const GIT_ACTION_TAB_STORAGE_KEY = 'oc.git.actionTab';
const GIT_RECONCILE_DELAY_MS = 15000;
const isActionTab = (value: unknown): value is ActionTab =>
value === 'commit' || value === 'branch' || value === 'pr';
type GitViewSnapshot = {
directory?: string;
selectedPaths: string[];
commitMessage: string;
generatedHighlights: string[];
};
@@ -220,6 +221,17 @@ const gitViewSnapshots = new Map<string, GitViewSnapshot>();
const normalizePath = (value?: string | null): string =>
(value || '').replace(/\\/g, '/').replace(/\/+$/, '');
const isStagedStatusFile = (file: GitStatus['files'][number]): boolean => {
const indexStatus = file.index?.trim();
return Boolean(indexStatus && indexStatus !== '?');
};
const isUnstagedStatusFile = (file: GitStatus['files'][number]): boolean => {
const workingStatus = file.working_dir?.trim();
const indexStatus = file.index?.trim();
return Boolean(workingStatus || indexStatus === '?');
};
export const GitView: React.FC = () => {
const { t } = useI18n();
const { git } = useRuntimeAPIs();
@@ -304,11 +316,116 @@ export const GitView: React.FC = () => {
const fetchIdentity = useGitStore((state) => state.fetchIdentity);
const prefetchDiffs = useGitStore((state) => state.prefetchDiffs);
const setLogMaxCount = useGitStore((state) => state.setLogMaxCount);
const moveStatusPathsOptimistically = useGitStore((state) => state.moveStatusPathsOptimistically);
const restoreStatus = useGitStore((state) => state.restoreStatus);
const bumpIndexRevision = useGitStore((state) => state.bumpIndexRevision);
const isMobile = useUIStore((state) => state.isMobile);
const openContextDiff = useUIStore((state) => state.openContextDiff);
const navigateToDiff = useUIStore((state) => state.navigateToDiff);
const setRightSidebarOpen = useUIStore((state) => state.setRightSidebarOpen);
const previousBootstrapStatusRef = React.useRef<'pending' | 'ready' | 'failed' | null>(null);
const gitReconcileTimeoutRef = React.useRef<number | null>(null);
const gitMutationFlushTimeoutRef = React.useRef<number | null>(null);
const flushQueuedGitMutationsRef = React.useRef<(() => void) | null>(null);
const clearScheduledGitReconcile = React.useCallback(() => {
if (gitReconcileTimeoutRef.current === null) {
return;
}
window.clearTimeout(gitReconcileTimeoutRef.current);
gitReconcileTimeoutRef.current = null;
}, []);
const scheduleGitReconcile = React.useCallback((directory: string) => {
clearScheduledGitReconcile();
gitReconcileTimeoutRef.current = window.setTimeout(() => {
gitReconcileTimeoutRef.current = null;
if (normalizePath(directory) !== normalizePath(currentDirectory)) {
return;
}
void fetchStatus(directory, git, { silent: true });
}, GIT_RECONCILE_DELAY_MS);
}, [clearScheduledGitReconcile, currentDirectory, fetchStatus, git]);
React.useEffect(() => clearScheduledGitReconcile, [clearScheduledGitReconcile]);
const clearScheduledGitMutationFlush = React.useCallback(() => {
if (gitMutationFlushTimeoutRef.current === null) {
return;
}
window.clearTimeout(gitMutationFlushTimeoutRef.current);
gitMutationFlushTimeoutRef.current = null;
}, []);
const scheduleGitMutationFlush = React.useCallback(() => {
if (gitMutationFlushTimeoutRef.current !== null) {
return;
}
gitMutationFlushTimeoutRef.current = window.setTimeout(() => {
gitMutationFlushTimeoutRef.current = null;
flushQueuedGitMutationsRef.current?.();
}, 0);
}, []);
const runGitIndexMutation = React.useCallback(async (
directory: string,
direction: GitIndexMutationDirection,
paths: string[]
) => {
if (direction === 'stage') {
if (git.stageGitFiles) {
await git.stageGitFiles(directory, paths);
return;
}
await Promise.all(paths.map((filePath) => git.stageGitFile(directory, filePath)));
return;
}
if (git.unstageGitFiles) {
await git.unstageGitFiles(directory, paths);
return;
}
await Promise.all(paths.map((filePath) => git.unstageGitFile(directory, filePath)));
}, [git]);
const gitIndexMutationQueue = React.useMemo<GitIndexMutationQueue>(() => createGitIndexMutationQueue({
runMutation: ({ directory, direction, paths }) => runGitIndexMutation(directory, direction, paths),
onMutationComplete: ({ directory }) => {
bumpIndexRevision(directory);
scheduleGitReconcile(directory);
},
onMutationError: ({ directory, direction, rollback }, error) => {
rollback?.();
bumpIndexRevision(directory);
scheduleGitReconcile(directory);
const fallback = direction === 'stage'
? t('gitView.toast.stageFileFailed')
: t('gitView.toast.unstageFileFailed');
toast.error(error instanceof Error ? error.message : fallback);
},
onPathsComplete: (paths) => {
setMovingChangePaths((previous) => {
const updated = new Set(previous);
paths.forEach((path) => updated.delete(path));
return updated;
});
},
scheduleFlush: scheduleGitMutationFlush,
}), [bumpIndexRevision, runGitIndexMutation, scheduleGitMutationFlush, scheduleGitReconcile, t]);
React.useEffect(() => {
flushQueuedGitMutationsRef.current = gitIndexMutationQueue.flush;
return () => {
flushQueuedGitMutationsRef.current = null;
};
}, [gitIndexMutationQueue]);
React.useEffect(() => () => gitIndexMutationQueue.clear(), [gitIndexMutationQueue]);
React.useEffect(() => clearScheduledGitMutationFlush, [clearScheduledGitMutationFlush]);
React.useEffect(() => {
if (!currentDirectory) {
@@ -442,17 +559,15 @@ export const GitView: React.FC = () => {
}
}, []);
const [selectedPaths, setSelectedPaths] = React.useState<Set<string>>(
() => new Set(initialSnapshot?.selectedPaths ?? [])
);
const [hasUserAdjustedSelection, setHasUserAdjustedSelection] = React.useState(false);
const [revertingPaths, setRevertingPaths] = React.useState<Set<string>>(new Set());
const [movingChangePaths, setMovingChangePaths] = React.useState<Set<string>>(new Set());
const [isRevertingAll, setIsRevertingAll] = React.useState(false);
const [integrateRefreshKey, setIntegrateRefreshKey] = React.useState(0);
const [isGeneratingMessage, setIsGeneratingMessage] = React.useState(false);
const [generatedHighlights, setGeneratedHighlights] = React.useState<string[]>(
initialSnapshot?.generatedHighlights ?? []
);
const hasPendingIndexMutation = movingChangePaths.size > 0 || gitIndexMutationQueue.size() > 0 || gitIndexMutationQueue.isRunning();
const scrollActionPanelToBottom = React.useCallback(() => {
const scrollTarget = actionPanelScrollRef.current;
@@ -662,11 +777,10 @@ export const GitView: React.FC = () => {
if (!currentDirectory) return;
gitViewSnapshots.set(currentDirectory, {
directory: currentDirectory,
selectedPaths: Array.from(selectedPaths),
commitMessage,
generatedHighlights,
});
}, [commitMessage, currentDirectory, selectedPaths, generatedHighlights]);
}, [commitMessage, currentDirectory, generatedHighlights]);
React.useEffect(() => {
loadProfiles();
@@ -844,6 +958,16 @@ export const GitView: React.FC = () => {
return Array.from(unique.values()).sort((a, b) => a.path.localeCompare(b.path));
}, [status]);
const stagedChangeEntries = React.useMemo(
() => changeEntries.filter(isStagedStatusFile),
[changeEntries]
);
const unstagedChangeEntries = React.useMemo(
() => changeEntries.filter(isUnstagedStatusFile),
[changeEntries]
);
React.useEffect(() => {
if (!currentDirectory || changeEntries.length === 0) {
return;
@@ -860,7 +984,7 @@ export const GitView: React.FC = () => {
orderedPaths.push(path);
};
Array.from(selectedPaths).forEach(pushPath);
stagedChangeEntries.forEach((entry) => pushPath(entry.path));
visibleChangePaths.forEach(pushPath);
changeEntries.slice(0, GIT_DIFF_PRIORITY_BASELINE_LIMIT).forEach((entry) => pushPath(entry.path));
@@ -875,30 +999,7 @@ export const GitView: React.FC = () => {
return () => {
window.clearTimeout(timeoutId);
};
}, [changeEntries, currentDirectory, git, prefetchDiffs, selectedPaths, visibleChangePaths]);
React.useEffect(() => {
if (!status || changeEntries.length === 0) {
setSelectedPaths(new Set());
setHasUserAdjustedSelection(false);
return;
}
setSelectedPaths((previous) => {
const next = new Set<string>();
const previousSet = previous ?? new Set<string>();
for (const file of changeEntries) {
if (previousSet.has(file.path)) {
next.add(file.path);
} else if (!hasUserAdjustedSelection) {
next.add(file.path);
}
}
return next;
});
}, [status, changeEntries, hasUserAdjustedSelection]);
}, [changeEntries, currentDirectory, git, prefetchDiffs, stagedChangeEntries, visibleChangePaths]);
const handleSyncAction = async (action: Exclude<SyncAction, null>, remote?: GitRemote) => {
if (!currentDirectory) return;
@@ -1027,9 +1128,9 @@ export const GitView: React.FC = () => {
return;
}
const filesToCommit = Array.from(selectedPaths).sort();
const filesToCommit = stagedChangeEntries.map((file) => file.path).sort();
if (filesToCommit.length === 0) {
toast.error(t('gitView.toast.selectFileToCommit'));
toast.error(t('gitView.toast.stageFileToCommit'));
return;
}
@@ -1039,11 +1140,11 @@ export const GitView: React.FC = () => {
try {
await git.createGitCommit(currentDirectory, commitMessage.trim(), {
files: filesToCommit,
stageFiles: [],
});
bumpIndexRevision(currentDirectory);
toast.success(t('gitView.toast.commitCreated'));
setCommitMessage('');
setSelectedPaths(new Set());
setHasUserAdjustedSelection(false);
clearGeneratedHighlights();
await refreshStatusAndBranches();
@@ -1120,19 +1221,20 @@ export const GitView: React.FC = () => {
const handleGenerateCommitMessage = React.useCallback(async () => {
if (!currentDirectory) return;
if (selectedPaths.size === 0) {
toast.error(t('gitView.toast.selectFileToDescribe'));
const selectedFilePaths = stagedChangeEntries.map((file) => file.path).sort();
if (selectedFilePaths.length === 0) {
toast.error(t('gitView.toast.stageFileToDescribe'));
return;
}
console.error('[git-generation][browser] generate button clicked', {
directory: currentDirectory,
selectedFiles: selectedPaths.size,
selectedFiles: selectedFilePaths.length,
});
setIsGeneratingMessage(true);
try {
const { message } = await generateSessionCommitMessage(currentDirectory, Array.from(selectedPaths));
const { message } = await generateSessionCommitMessage(currentDirectory, selectedFilePaths);
const subject = message.subject?.trim() ?? '';
const highlights = Array.isArray(message.highlights) ? message.highlights : [];
@@ -1163,7 +1265,7 @@ export const GitView: React.FC = () => {
} finally {
setIsGeneratingMessage(false);
}
}, [currentDirectory, selectedPaths, settingsGitmojiEnabled, gitmojiEmojis, scrollActionPanelToBottom, t]);
}, [currentDirectory, stagedChangeEntries, settingsGitmojiEnabled, gitmojiEmojis, scrollActionPanelToBottom, t]);
const formatBlockingReason = (reason: ReturnType<typeof getMutationBlockingReasons>[number]): string => {
if (reason.reason === 'attention') {
@@ -1477,7 +1579,7 @@ export const GitView: React.FC = () => {
return globalIdentity ?? null;
}, [currentIdentity, profiles, globalIdentity]);
const selectedCount = selectedPaths.size;
const stagedCount = stagedChangeEntries.length;
const isBusy = isLoading || syncAction !== null || commitAction !== null;
const currentBranch = status?.current ?? null;
const canShowIntegrateCommitsSection = Boolean(
@@ -1571,29 +1673,25 @@ export const GitView: React.FC = () => {
}, [baseBranch, currentBranch, currentDirectory, git, log, logMaxCountLocal]);
// Keep these sections stable in layout; individual cards render placeholders when unavailable.
const toggleFileSelection = (path: string) => {
setSelectedPaths((previous) => {
const moveChangePaths = React.useCallback((paths: string[], direction: GitIndexMutationDirection) => {
if (!currentDirectory || paths.length === 0) return;
const uniquePaths = Array.from(new Set(paths));
setMovingChangePaths((previous) => {
const next = new Set(previous);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
uniquePaths.forEach((path) => next.add(path));
return next;
});
setHasUserAdjustedSelection(true);
};
const previousStatus = moveStatusPathsOptimistically(currentDirectory, uniquePaths, direction);
const selectAll = () => {
const next = new Set(changeEntries.map((file) => file.path));
setSelectedPaths(next);
setHasUserAdjustedSelection(true);
};
gitIndexMutationQueue.enqueue({
directory: currentDirectory,
direction,
paths: new Set(uniquePaths),
rollback: () => restoreStatus(currentDirectory, previousStatus),
});
const clearSelection = () => {
setSelectedPaths(new Set());
setHasUserAdjustedSelection(true);
};
scheduleGitMutationFlush();
}, [currentDirectory, gitIndexMutationQueue, moveStatusPathsOptimistically, restoreStatus, scheduleGitMutationFlush]);
const handleRevertFile = React.useCallback(
async (filePath: string) => {
@@ -1606,7 +1704,7 @@ export const GitView: React.FC = () => {
});
try {
await git.revertGitFile(currentDirectory, filePath);
await git.revertGitFile(currentDirectory, filePath, { scope: 'working' });
toast.success(t('gitView.toast.revertedFile', { path: filePath }));
await refreshStatusAndBranches(false);
} catch (err) {
@@ -1630,6 +1728,8 @@ export const GitView: React.FC = () => {
}
const uniquePaths = Array.from(new Set(paths));
const stagedPaths = new Set(stagedChangeEntries.map((entry) => entry.path));
const touchesStagedIndex = uniquePaths.some((path) => stagedPaths.has(path));
setIsRevertingAll(true);
setRevertingPaths((previous) => {
const next = new Set(previous);
@@ -1651,6 +1751,10 @@ export const GitView: React.FC = () => {
}
}));
if (touchesStagedIndex && failed.length < uniquePaths.length) {
bumpIndexRevision(currentDirectory);
}
await refreshStatusAndBranches(false);
if (failed.length === 0) {
@@ -1678,9 +1782,67 @@ export const GitView: React.FC = () => {
setIsRevertingAll(false);
}
},
[currentDirectory, git, isRevertingAll, refreshStatusAndBranches, t]
[bumpIndexRevision, currentDirectory, git, isRevertingAll, refreshStatusAndBranches, stagedChangeEntries, t]
);
const handleViewChangeDiff = React.useCallback((path: string, staged: boolean) => {
if (currentDirectory && !isMobile) {
openContextDiff(currentDirectory, path, staged);
return;
}
navigateToDiff(path, staged);
if (isMobile) {
setRightSidebarOpen(false);
}
}, [currentDirectory, isMobile, navigateToDiff, openContextDiff, setRightSidebarOpen]);
const openStashes = React.useCallback(() => setIsStashesDialogOpen(true), []);
const changeGroups = React.useMemo<ChangesGroupConfig[]>(() => {
const groups: ChangesGroupConfig[] = [];
if (stagedChangeEntries.length > 0) {
groups.push({
id: 'staged',
title: t('gitView.changes.stagedTitle'),
entries: stagedChangeEntries,
actionSymbol: '-',
actionAllLabel: t('gitView.changes.unstageAllAria'),
getActionLabel: (path) => t('gitView.changes.unstageFileAria', { path }),
onActionFile: (path) => void moveChangePaths([path], 'unstage'),
onActionAll: (paths) => void moveChangePaths(paths, 'unstage'),
onViewDiff: (path) => handleViewChangeDiff(path, true),
onRevertFile: handleRevertFile,
showRevertActions: false,
accent: true,
});
}
if (unstagedChangeEntries.length > 0) {
groups.push({
id: 'unstaged',
title: t('gitView.changes.title'),
entries: unstagedChangeEntries,
actionSymbol: '+',
actionAllLabel: t('gitView.changes.stageAllAria'),
getActionLabel: (path) => t('gitView.changes.stageFileAria', { path }),
onActionFile: (path) => void moveChangePaths([path], 'stage'),
onActionAll: (paths) => void moveChangePaths(paths, 'stage'),
onViewDiff: (path) => handleViewChangeDiff(path, false),
onRevertFile: handleRevertFile,
});
}
return groups;
}, [
handleRevertFile,
handleViewChangeDiff,
moveChangePaths,
stagedChangeEntries,
t,
unstagedChangeEntries,
]);
const handleInsertHighlights = React.useCallback((sourceHighlights: string[]) => {
if (sourceHighlights.length === 0) return;
const normalizedHighlights = sourceHighlights
@@ -2007,6 +2169,7 @@ export const GitView: React.FC = () => {
const currentBranch = status?.current;
const operation = stashDialogOperation;
const branch = stashDialogBranch;
const hadStagedChanges = (status?.files ?? []).some(isStagedStatusFile);
// Stash changes
try {
@@ -2014,6 +2177,9 @@ export const GitView: React.FC = () => {
message: `Auto-stash before ${operation} with ${branch}`,
includeUntracked: true,
});
if (hadStagedChanges) {
bumpIndexRevision(currentDirectory);
}
} catch (stashErr) {
const msg = stashErr instanceof Error ? stashErr.message : 'Failed to stash changes';
toast.error(msg);
@@ -2053,6 +2219,7 @@ export const GitView: React.FC = () => {
if (restoreAfter && operationSucceeded) {
try {
await git.stashPop(currentDirectory);
bumpIndexRevision(currentDirectory);
toast.success(t('gitView.toast.stashedRestored'));
} catch (popErr) {
const popMessage = popErr instanceof Error ? popErr.message : t('gitView.toast.restoreStashFailed');
@@ -2069,6 +2236,7 @@ export const GitView: React.FC = () => {
if (restoreAfter) {
try {
await git.stashPop(currentDirectory);
bumpIndexRevision(currentDirectory);
} catch {
// Ignore stash pop errors in this case
}
@@ -2076,7 +2244,7 @@ export const GitView: React.FC = () => {
throw err;
}
},
[currentDirectory, git, status, stashDialogOperation, stashDialogBranch, refreshStatusAndBranches, refreshLog, t]
[bumpIndexRevision, currentDirectory, git, status, stashDialogOperation, stashDialogBranch, refreshStatusAndBranches, refreshLog, t]
);
if (!currentDirectory) {
@@ -2155,6 +2323,7 @@ export const GitView: React.FC = () => {
isApplyingIdentity={isSettingIdentity}
isWorktreeMode={!!worktreeMetadata}
onOpenHistory={() => setIsHistoryDialogOpen(true)}
onOpenStashes={openStashes}
actionTabItems={actionTabItems}
activeActionTab={actionTab}
onSelectActionTab={(tabID) => setActionTab(tabID as ActionTab)}
@@ -2188,37 +2357,22 @@ export const GitView: React.FC = () => {
preventOverscroll
>
{actionTab === 'commit' ? (
<div className="space-y-4">
<div className="flex h-full min-h-0 flex-col gap-3">
{(changeEntries?.length ?? 0) > 0 ? (
<>
<ChangesSection
maxListHeightClassName="max-h-[40vh]"
changeEntries={changeEntries}
onVisiblePathsChange={setVisibleChangePaths}
selectedPaths={selectedPaths}
diffStats={status?.diffStats}
revertingPaths={revertingPaths}
onToggleFile={toggleFileSelection}
onSelectAll={selectAll}
onClearSelection={clearSelection}
onRevertAll={handleRevertAll}
onViewDiff={(path) => {
if (currentDirectory && !isMobile) {
openContextDiff(currentDirectory, path);
return;
}
navigateToDiff(path);
if (isMobile) {
setRightSidebarOpen(false);
}
}}
onRevertFile={handleRevertFile}
isRevertingAll={isRevertingAll}
onOpenStashes={() => setIsStashesDialogOpen(true)}
/>
<div className="min-h-0 flex-1 overflow-hidden">
<ChangesPanel
groups={changeGroups}
diffStats={status?.diffStats}
revertingPaths={revertingPaths}
isRevertingAll={isRevertingAll}
onVisiblePathsChange={setVisibleChangePaths}
onRevertAll={handleRevertAll}
/>
</div>
<CommitSection
selectedCount={selectedCount}
stagedCount={stagedCount}
commitMessage={commitMessage}
onCommitMessageChange={setCommitMessage}
generatedHighlights={generatedHighlights}
@@ -2228,6 +2382,7 @@ export const GitView: React.FC = () => {
onCommit={() => handleCommit({ pushAfter: false })}
onCommitAndPush={() => handleCommit({ pushAfter: true })}
commitAction={commitAction}
hasPendingIndexMutation={hasPendingIndexMutation}
gitmojiEnabled={settingsGitmojiEnabled}
onOpenGitmojiPicker={() => setIsGitmojiPickerOpen(true)}
/>
@@ -2339,8 +2494,12 @@ export const GitView: React.FC = () => {
onOpenChange={setIsStashesDialogOpen}
directory={currentDirectory}
hasUncommittedChanges={(status?.files?.length ?? 0) > 0}
hasStagedChanges={stagedChangeEntries.length > 0}
uncommittedFileCount={status?.files?.length ?? 0}
onChanged={async () => {
onChanged={async (change) => {
if (currentDirectory && change?.affectsIndex) {
bumpIndexRevision(currentDirectory);
}
await refreshStatusAndBranches(false);
await refreshLog();
}}
@@ -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';