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
+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>