feat(app): folder tree in the Changes view (#1918)

Group changed files in the Changes view into a collapsible directory
tree instead of a flat list, on every form factor — consistent with the
Files explorer, which already renders a directory tree on mobile.
Folders are collapsible and expanded by default, single-child directory
chains are compressed into one row (packages/app/src), and folder rows
show aggregate +/- stats.

Client-only, built entirely from the existing ParsedDiffFile paths:

- diff-tree.ts: pure build/compress/flatten of the directory hierarchy,
  keyed by full uncompressed dir path so collapse state survives
  compression changes as the diff mutates.
- diff-flat-items.ts: pure buildDiffFlatItems + sumHeightsBefore. The
  function retains a flat mode (showTree=false) as a tested seam for a
  possible future flat/tree toggle; sticky header indices are
  file-headers-only, derived from the post-collapse list.
- panel-store: diffCollapsedFoldersByWorkspace slice (collapsed-set, so
  folders default to expanded), persisted via version 12. The set is
  reconciled against the current diff so folders that leave and reappear
  default to expanded and the stored set stays bounded.

Presentation primitives (chevron, indent guides, indent constant) are
extracted into components/tree-primitives and shared with the Files
explorer, which drops its private copies (and a banned useUnistyles
call site). The two trees now render identical rows; only the
tree-building stays separate (server listings vs. diff paths).

The toolbar's expand-all stays file-diffs-only (renamed
allFileDiffsExpanded); a separate control collapses/expands all folders.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Christoph Leiter
2026-07-09 03:55:30 +08:00
committed by GitHub
co-authored by Claude Opus 4.8
parent cab0e4b3f8
commit 3259a69466
19 changed files with 1111 additions and 115 deletions
+266 -69
View File
@@ -36,6 +36,7 @@ import {
ChevronDown,
Columns2,
Download,
FolderTree,
GitCommitHorizontal,
GitMerge,
ListChevronsDownUp,
@@ -52,6 +53,12 @@ import {
type DiffLine,
type HighlightToken,
} from "@/git/use-diff-query";
import { buildDiffFlatItems, sumHeightsBefore, type DiffFlatItem } from "@/git/diff-flat-items";
import { buildDiffTree, collectDirPaths, compressSingleChildChains } from "@/git/diff-tree";
import { DiffFolderRow } from "@/git/diff-folder-row";
import { TreeIndentGuides, treeRowPaddingLeft } from "@/components/tree-primitives";
import { SvgXml } from "react-native-svg";
import { getFileIconSvg } from "@/components/material-file-icons";
import { useCheckoutStatusQuery } from "@/git/use-status-query";
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
@@ -191,6 +198,10 @@ function HighlightedText({
interface DiffFileSectionProps {
file: ParsedDiffFile;
isExpanded: boolean;
/** Tree indentation level (0 on the flat/mobile path). */
depth?: number;
/** Show the muted directory suffix (flat list); false inside the folder tree. */
showDir?: boolean;
onToggle: (path: string) => void;
onHeaderHeightChange?: (path: string, height: number) => void;
testID?: string;
@@ -889,6 +900,8 @@ function SplitDiffColumn({
const DiffFileHeader = memo(function DiffFileHeader({
file,
isExpanded,
depth = 0,
showDir = true,
onToggle,
onHeaderHeightChange,
testID,
@@ -940,13 +953,27 @@ const DiffFileHeader = memo(function DiffFileHeader({
[isExpanded],
);
const headerPressableStyle = useCallback(
(state: PressableStateCallbackType) =>
depth > 0
? [
fileHeaderPressableStyle(state),
inlineUnistylesStyle({ paddingLeft: treeRowPaddingLeft(depth) }),
]
: fileHeaderPressableStyle(state),
[depth],
);
const fileName = file.path.split("/").pop() ?? file.path;
return (
<View style={containerStyle} onLayout={handleLayout} testID={testID}>
<TreeIndentGuides depth={depth} />
<Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Pressable
testID={testID ? `${testID}-toggle` : undefined}
style={fileHeaderPressableStyle}
style={headerPressableStyle}
// Android: prevent parent pan/scroll gestures from canceling the tap release.
cancelable={false}
onPressIn={handlePressIn}
@@ -954,14 +981,25 @@ const DiffFileHeader = memo(function DiffFileHeader({
onPress={toggleExpanded}
>
<View style={styles.fileHeaderLeft}>
{showDir ? null : (
<View style={styles.fileIcon}>
<SvgXml xml={getFileIconSvg(fileName)} width={16} height={16} />
</View>
)}
<Text style={styles.fileName} numberOfLines={1}>
{file.path.split("/").pop()}
</Text>
<Text style={styles.fileDir} numberOfLines={1}>
{file.path.includes("/")
? ` ${file.path.slice(0, file.path.lastIndexOf("/"))}`
: ""}
{fileName}
</Text>
{showDir ? (
<Text style={styles.fileDir} numberOfLines={1}>
{file.path.includes("/")
? ` ${file.path.slice(0, file.path.lastIndexOf("/"))}`
: ""}
</Text>
) : (
// Flex spacer in tree mode (no dir suffix) so the New/Deleted badge
// stays right-aligned next to the diff stats, as in the flat list.
<View style={styles.fileDirSpacer} />
)}
{file.isNew && (
<View style={styles.newBadge}>
<Text style={styles.newBadgeText}>{t("workspace.git.diff.newFile")}</Text>
@@ -1191,6 +1229,7 @@ const ThemedPilcrow = withUnistyles(Pilcrow);
const ThemedWrapText = withUnistyles(WrapText);
const ThemedListChevronsDownUp = withUnistyles(ListChevronsDownUp);
const ThemedListChevronsUpDown = withUnistyles(ListChevronsUpDown);
const ThemedFolderTree = withUnistyles(FolderTree);
const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal);
const ThemedDownload = withUnistyles(Download);
const ThemedUpload = withUnistyles(Upload);
@@ -1307,32 +1346,68 @@ function DiffWhitespaceToggle({
interface DiffFilesToolbarProps {
wrapLines: boolean;
allExpanded: boolean;
// Controls file-DIFF bodies only (not folders). Named explicitly so the
// folder control below stays unambiguous (Codex item 4).
allFileDiffsExpanded: boolean;
showFolderControl: boolean;
allFoldersCollapsed: boolean;
isMobile: boolean;
wrapLinesToggleStyle: PressableStyleFn;
expandAllToggleStyle: PressableStyleFn;
folderToggleStyle: PressableStyleFn;
onToggleWrapLines: () => void;
onToggleExpandAll: () => void;
onToggleCollapseAllFolders: () => void;
}
function DiffFilesToolbar({
wrapLines,
allExpanded,
allFileDiffsExpanded,
showFolderControl,
allFoldersCollapsed,
isMobile,
wrapLinesToggleStyle,
expandAllToggleStyle,
folderToggleStyle,
onToggleWrapLines,
onToggleExpandAll,
onToggleCollapseAllFolders,
}: DiffFilesToolbarProps) {
const { t } = useTranslation();
const wrapLinesLabel = wrapLines
? t("workspace.git.diff.scrollLongLines")
: t("workspace.git.diff.wrapLongLines");
const expandAllLabel = allExpanded
const expandAllLabel = allFileDiffsExpanded
? t("workspace.git.diff.collapseAll")
: t("workspace.git.diff.expandAll");
const folderLabel = allFoldersCollapsed
? t("workspace.git.diff.expandAllFolders")
: t("workspace.git.diff.collapseAllFolders");
return (
<View style={styles.diffStatusButtons}>
{showFolderControl ? (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel={folderLabel}
testID="changes-toggle-folders"
style={folderToggleStyle}
onPress={onToggleCollapseAllFolders}
>
<ThemedFolderTree
size={isMobile ? 18 : 14}
uniProps={
allFoldersCollapsed ? foregroundIconColorMapping : foregroundMutedIconColorMapping
}
/>
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>{folderLabel}</Text>
</TooltipContent>
</Tooltip>
) : null}
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable style={wrapLinesToggleStyle} onPress={onToggleWrapLines}>
@@ -1348,8 +1423,13 @@ function DiffFilesToolbar({
</Tooltip>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable style={expandAllToggleStyle} onPress={onToggleExpandAll}>
{allExpanded ? (
<Pressable
accessibilityRole="button"
accessibilityLabel={expandAllLabel}
style={expandAllToggleStyle}
onPress={onToggleExpandAll}
>
{allFileDiffsExpanded ? (
<ThemedListChevronsDownUp
size={isMobile ? 18 : 14}
uniProps={foregroundMutedIconColorMapping}
@@ -1414,9 +1494,6 @@ function DiffRefreshButton({ isRefreshing, toggleStyle, onPress }: DiffRefreshBu
);
}
type DiffFlatItem =
| { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean }
| { type: "body"; file: ParsedDiffFile; fileIndex: number };
type DiffFlatItemLayoutGetter = NonNullable<FlatListProps<DiffFlatItem>["getItemLayout"]>;
function getUnifiedDiffLineCount(file: ParsedDiffFile): number {
@@ -1749,6 +1826,8 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
const expandAllToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []);
const folderToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []);
const refreshToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []);
const toast = useToast();
@@ -1892,6 +1971,29 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
(state) => state.setDiffExpandedPathsForWorkspace,
);
const expandedPaths = useMemo(() => new Set(expandedPathsArray ?? []), [expandedPathsArray]);
// The Changes view groups files into a directory tree on every form factor,
// consistent with the Files explorer (which is also a tree on mobile).
const collapsedFoldersArray = usePanelStore((state) =>
workspaceStateKey ? state.diffCollapsedFoldersByWorkspace[workspaceStateKey] : undefined,
);
const setDiffCollapsedFoldersForWorkspace = usePanelStore(
(state) => state.setDiffCollapsedFoldersForWorkspace,
);
// Build the directory tree once per files-change; collapse/expand toggles only
// re-flatten it (they don't change tree shape).
const compressedTree = useMemo(() => compressSingleChildChains(buildDiffTree(files)), [files]);
// Every directory path currently in the tree — used by "collapse all folders" and to
// filter stale collapse state.
const allFolderPaths = useMemo(() => collectDirPaths(compressedTree), [compressedTree]);
const allFolderPathSet = useMemo(() => new Set(allFolderPaths), [allFolderPaths]);
// Effective collapsed set: intersect the persisted paths with the folders actually
// present, purely at render (no store-syncing effect). A folder that left the diff and
// reappears defaults to expanded; toggles write back this pruned set, so the stored
// array stays bounded. (empty = all folders expanded, the default)
const collapsedFolders = useMemo(
() => new Set((collapsedFoldersArray ?? []).filter((path) => allFolderPathSet.has(path))),
[collapsedFoldersArray, allFolderPathSet],
);
const diffListRef = useRef<FlatList<DiffFlatItem>>(null);
const scrollbar = useWebScrollViewScrollbar(diffListRef, {
enabled: showDesktopWebScrollbar,
@@ -1900,26 +2002,22 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
const diffListViewportHeightRef = useRef(0);
const headerHeightByPathRef = useRef<Record<string, number>>({});
const bodyHeightByKeyRef = useRef<Record<string, number>>({});
// Folder rows are a distinct kind; keep their height out of headerHeightByPathRef
// (Codex item 6) so file/folder heights can't collide by path.
const folderRowHeightRef = useRef<number>(0);
const defaultHeaderHeightRef = useRef<number>(44);
const [heightVersion, setHeightVersion] = useState(0);
const diffBodyChromeHeight = BORDER_WIDTH[1] * 2;
const statusBodyHeightEstimate = diffBodyChromeHeight + SPACING[4] * 2 + diffBodyLineHeight;
const { flatItems, stickyHeaderIndices } = useMemo(() => {
const items: DiffFlatItem[] = [];
const stickyIndices: number[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
const isExpanded = expandedPaths.has(file.path);
items.push({ type: "header", file, fileIndex: i, isExpanded });
if (isExpanded) {
stickyIndices.push(items.length - 1);
}
if (isExpanded) {
items.push({ type: "body", file, fileIndex: i });
}
}
const { items, stickyHeaderIndices: stickyIndices } = buildDiffFlatItems({
files,
tree: compressedTree,
collapsedFolders,
expandedPaths,
});
return { flatItems: items, stickyHeaderIndices: stickyIndices };
}, [expandedPaths, files]);
}, [compressedTree, collapsedFolders, expandedPaths, files]);
const getBodyHeightKey = useCallback(
(file: ParsedDiffFile): string => {
@@ -1958,6 +2056,35 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
[diffBodyChromeHeight, diffBodyLineHeight, effectiveLayout, statusBodyHeightEstimate],
);
// Single height source of truth for both getItemLayout and the collapse
// scroll-anchor math. Folder rows use their own measured height (Codex item 6),
// falling back to the default header height before first measurement.
const getFlatItemHeight = useCallback(
(item: DiffFlatItem): number => {
if (item.type === "folder") {
return folderRowHeightRef.current || defaultHeaderHeightRef.current;
}
if (item.type === "header") {
return headerHeightByPathRef.current[item.file.path] ?? defaultHeaderHeightRef.current;
}
const bodyHeightKey = getBodyHeightKey(item.file);
return bodyHeightByKeyRef.current[bodyHeightKey] ?? estimateBodyHeight(item.file);
},
[estimateBodyHeight, getBodyHeightKey],
);
const handleFolderRowHeightChange = useCallback((height: number) => {
if (!Number.isFinite(height) || height <= 0) {
return;
}
const previousHeight = folderRowHeightRef.current;
if (previousHeight > 0 && Math.abs(previousHeight - height) <= DIFF_HEIGHT_CHANGE_EPSILON) {
return;
}
folderRowHeightRef.current = height;
setHeightVersion((version) => version + 1);
}, []);
const handleHeaderHeightChange = useCallback((path: string, height: number) => {
if (!Number.isFinite(height) || height <= 0) {
return;
@@ -2013,23 +2140,24 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
[scrollbar],
);
const computeHeaderOffset = useCallback(
(path: string): number => {
const defaultHeaderHeight = defaultHeaderHeightRef.current;
let offset = 0;
for (const file of files) {
if (file.path === path) {
break;
}
offset += headerHeightByPathRef.current[file.path] ?? defaultHeaderHeight;
if (expandedPaths.has(file.path)) {
const bodyHeightKey = getBodyHeightKey(file);
offset += bodyHeightByKeyRef.current[bodyHeightKey] ?? estimateBodyHeight(file);
}
// Offset of the first item matching `predicate`, walking the SAME flatItems
// list getFlatItemLayout uses so folder rows are counted (single source of
// truth — Codex item 5 / finding 2).
const computeItemOffset = useCallback(
(predicate: (item: DiffFlatItem) => boolean): number | null => {
const index = flatItems.findIndex(predicate);
if (index < 0) {
return null;
}
return Math.max(0, offset);
return sumHeightsBefore(flatItems, index, getFlatItemHeight);
},
[estimateBodyHeight, expandedPaths, files, getBodyHeightKey],
[flatItems, getFlatItemHeight],
);
const computeHeaderOffset = useCallback(
(path: string): number =>
computeItemOffset((item) => item.type === "header" && item.file.path === path) ?? 0,
[computeItemOffset],
);
const handleToggleExpanded = useCallback(
@@ -2067,7 +2195,41 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
[computeHeaderOffset, expandedPaths, setDiffExpandedPathsForWorkspace, workspaceStateKey],
);
const allExpanded = useMemo(() => {
const handleToggleFolder = useCallback(
(dirPath: string) => {
if (!workspaceStateKey) {
return;
}
const isCurrentlyCollapsed = collapsedFolders.has(dirPath);
// Collapsing hides the subtree below this row; anchor to the folder row
// first so the viewport doesn't jump to a dead offset (Codex item 5).
if (!isCurrentlyCollapsed) {
const targetOffset = computeItemOffset(
(item) => item.type === "folder" && item.dirPath === dirPath,
);
const folderHeight = folderRowHeightRef.current || defaultHeaderHeightRef.current;
if (
targetOffset !== null &&
shouldAnchorHeaderBeforeCollapse({
headerOffset: targetOffset,
headerHeight: folderHeight,
viewportOffset: diffListScrollOffsetRef.current,
viewportHeight: diffListViewportHeightRef.current,
})
) {
diffListRef.current?.scrollToOffset({ offset: targetOffset, animated: false });
}
}
const nextCollapsed = isCurrentlyCollapsed
? Array.from(collapsedFolders).filter((path) => path !== dirPath)
: [...collapsedFolders, dirPath];
setDiffCollapsedFoldersForWorkspace(workspaceStateKey, nextCollapsed);
},
[collapsedFolders, computeItemOffset, setDiffCollapsedFoldersForWorkspace, workspaceStateKey],
);
const allFileDiffsExpanded = useMemo(() => {
if (files.length === 0) return false;
return files.every((file) => expandedPaths.has(file.path));
}, [expandedPaths, files]);
@@ -2076,7 +2238,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
if (!workspaceStateKey) {
return;
}
if (allExpanded) {
if (allFileDiffsExpanded) {
setDiffExpandedPathsForWorkspace(workspaceStateKey, []);
} else {
setDiffExpandedPathsForWorkspace(
@@ -2084,15 +2246,51 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
files.map((file) => file.path),
);
}
}, [allExpanded, files, setDiffExpandedPathsForWorkspace, workspaceStateKey]);
}, [allFileDiffsExpanded, files, setDiffExpandedPathsForWorkspace, workspaceStateKey]);
// Membership, not count: collapsedFolders is already filtered to present folders, so
// "all collapsed" means every present folder path is in it.
const allFoldersCollapsed =
allFolderPaths.length > 0 && allFolderPaths.every((path) => collapsedFolders.has(path));
const handleToggleCollapseAllFolders = useCallback(() => {
if (!workspaceStateKey) {
return;
}
// Anchor to the top before collapsing everything so the viewport isn't stranded.
if (!allFoldersCollapsed) {
diffListRef.current?.scrollToOffset({ offset: 0, animated: false });
}
setDiffCollapsedFoldersForWorkspace(
workspaceStateKey,
allFoldersCollapsed ? [] : allFolderPaths,
);
}, [allFolderPaths, allFoldersCollapsed, setDiffCollapsedFoldersForWorkspace, workspaceStateKey]);
const renderFlatItem = useCallback(
({ item }: { item: DiffFlatItem }) => {
if (item.type === "folder") {
return (
<DiffFolderRow
dirPath={item.dirPath}
displayName={item.displayName}
depth={item.depth}
collapsed={item.collapsed}
additions={item.additions}
deletions={item.deletions}
onToggle={handleToggleFolder}
onHeightChange={handleFolderRowHeightChange}
testID={`diff-folder-${item.dirPath}`}
/>
);
}
if (item.type === "header") {
return (
<DiffFileHeader
file={item.file}
isExpanded={item.isExpanded}
depth={item.depth}
showDir={false}
onToggle={handleToggleExpanded}
onHeaderHeightChange={handleHeaderHeightChange}
testID={`diff-file-${item.fileIndex}`}
@@ -2117,40 +2315,24 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
diffTextMetricsStyle,
effectiveLayout,
handleBodyHeightChange,
handleFolderRowHeightChange,
handleHeaderHeightChange,
handleToggleExpanded,
handleToggleFolder,
reviewActions,
wrapLines,
],
);
const flatKeyExtractor = useCallback(
(item: DiffFlatItem) => `${item.type}-${item.file.path}`,
(item: DiffFlatItem) =>
item.type === "folder" ? `folder-${item.dirPath}` : `${item.type}-${item.file.path}`,
[],
);
const getFlatItemHeight = useCallback(
(item: DiffFlatItem): number => {
if (item.type === "header") {
return headerHeightByPathRef.current[item.file.path] ?? defaultHeaderHeightRef.current;
}
const bodyHeightKey = getBodyHeightKey(item.file);
return bodyHeightByKeyRef.current[bodyHeightKey] ?? estimateBodyHeight(item.file);
},
[estimateBodyHeight, getBodyHeightKey],
);
const getFlatItemLayout = useCallback<DiffFlatItemLayoutGetter>(
(_data, index) => {
let offset = 0;
for (let itemIndex = 0; itemIndex < index; itemIndex += 1) {
const item = flatItems[itemIndex];
if (item) {
offset += getFlatItemHeight(item);
}
}
const offset = sumHeightsBefore(flatItems, index, getFlatItemHeight);
const item = flatItems[index];
const length = item ? getFlatItemHeight(item) : 0;
return { length, offset, index };
@@ -2161,6 +2343,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
const flatExtraData = useMemo(
() => ({
expandedPathsArray,
collapsedFoldersArray,
effectiveLayout,
diffBodyTypographyKey,
heightVersion,
@@ -2169,6 +2352,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
}),
[
expandedPathsArray,
collapsedFoldersArray,
effectiveLayout,
diffBodyTypographyKey,
heightVersion,
@@ -2318,12 +2502,16 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
{files.length > 0 ? (
<DiffFilesToolbar
wrapLines={wrapLines}
allExpanded={allExpanded}
allFileDiffsExpanded={allFileDiffsExpanded}
showFolderControl={allFolderPaths.length > 0}
allFoldersCollapsed={allFoldersCollapsed}
isMobile={isMobile}
wrapLinesToggleStyle={wrapLinesToggleStyle}
expandAllToggleStyle={expandAllToggleStyle}
folderToggleStyle={folderToggleStyle}
onToggleWrapLines={handleToggleWrapLines}
onToggleExpandAll={handleToggleExpandAll}
onToggleCollapseAllFolders={handleToggleCollapseAllFolders}
/>
) : null}
{refreshSupported ? (
@@ -2574,6 +2762,11 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[1],
flexShrink: 0,
},
fileIcon: {
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
},
fileName: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
@@ -2588,6 +2781,10 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minWidth: 0,
},
fileDirSpacer: {
flex: 1,
minWidth: 0,
},
newBadge: {
backgroundColor: "rgba(46, 160, 67, 0.2)",
paddingHorizontal: theme.spacing[2],