diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx
index a14954cc8..aee2cbf07 100644
--- a/packages/app/src/components/file-explorer-pane.tsx
+++ b/packages/app/src/components/file-explorer-pane.tsx
@@ -19,7 +19,6 @@ import * as Clipboard from "expo-clipboard";
import { SvgXml } from "react-native-svg";
import {
ChevronDown,
- ChevronRight,
Copy,
Download,
Eye,
@@ -28,6 +27,7 @@ import {
RotateCw,
} from "lucide-react-native";
import { getFileIconSvg } from "@/components/material-file-icons";
+import { TreeChevron, TreeIndentGuides, TREE_INDENT_PER_LEVEL } from "@/components/tree-primitives";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
import { useHosts } from "@/runtime/host-runtime";
@@ -55,8 +55,6 @@ const SORT_OPTIONS: { value: SortOption }[] = [
{ value: "size" },
];
-const INDENT_PER_LEVEL = 16;
-
function formatFileSize({ size }: { size: number }): string {
if (size < 1024) {
return `${size} B`;
@@ -129,7 +127,7 @@ function TreeRowItem({
const pressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.entryRow,
- { paddingLeft: theme.spacing[2] + depth * INDENT_PER_LEVEL },
+ { paddingLeft: theme.spacing[2] + depth * TREE_INDENT_PER_LEVEL },
(Boolean(hovered) || pressed || isSelected) && styles.entryRowActive,
],
[depth, isSelected, theme.spacing],
@@ -143,11 +141,6 @@ function TreeRowItem({
onDownloadEntry(entry);
}, [onDownloadEntry, entry]);
- const chevronStyle = useMemo(
- () => [styles.chevron, isExpanded && styles.chevronExpanded],
- [isExpanded],
- );
-
const copyLeading = useMemo(
() => ,
[theme.colors.foregroundMuted],
@@ -159,7 +152,7 @@ function TreeRowItem({
return (
- {depth > 0 && Array.from({ length: depth }, (_, i) => )}
+
{(() => {
@@ -167,11 +160,7 @@ function TreeRowItem({
return ;
}
if (loading) return ;
- return (
-
-
-
- );
+ return ;
})()}
@@ -1149,13 +1138,6 @@ const styles = StyleSheet.create((theme) => ({
entryRowActive: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
- indentGuide: {
- position: "absolute",
- top: 0,
- bottom: 0,
- width: 1,
- backgroundColor: theme.colors.surface2,
- },
entryInfo: {
flex: 1,
flexDirection: "row",
@@ -1163,16 +1145,6 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
minWidth: 0,
},
- chevron: {
- width: 16,
- height: 16,
- alignItems: "center",
- justifyContent: "center",
- flexShrink: 0,
- },
- chevronExpanded: {
- transform: [{ rotate: "90deg" }],
- },
entryIcon: {
flexShrink: 0,
},
@@ -1302,16 +1274,3 @@ const styles = StyleSheet.create((theme) => ({
}));
const TREE_PANE_CONTAINER_STYLE = [styles.treePane, styles.treePaneFill];
-
-interface IndentGuideProps {
- index: number;
-}
-
-function IndentGuide({ index }: IndentGuideProps) {
- const { theme } = useUnistyles();
- const guideStyle = useMemo(
- () => [styles.indentGuide, { left: theme.spacing[3] + index * INDENT_PER_LEVEL + 4 }],
- [index, theme.spacing],
- );
- return ;
-}
diff --git a/packages/app/src/components/tree-primitives.tsx b/packages/app/src/components/tree-primitives.tsx
new file mode 100644
index 000000000..26c71f2c2
--- /dev/null
+++ b/packages/app/src/components/tree-primitives.tsx
@@ -0,0 +1,82 @@
+import { useMemo } from "react";
+import { View } from "react-native";
+import { StyleSheet, withUnistyles } from "react-native-unistyles";
+import { ChevronRight } from "lucide-react-native";
+import { SPACING, type Theme } from "@/styles/theme";
+import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
+
+// Shared presentation primitives for the app's directory trees. Both the Files
+// explorer (server-loaded listings) and the Changes view (client-built from diff
+// paths) render different data, but their ROWS should look identical — same
+// indentation, guide lines, and chevron. Keep those here so the two trees can't
+// drift apart.
+export const TREE_INDENT_PER_LEVEL = 16;
+
+/** Left padding for a tree row at `depth`. Shared by folder rows and file headers
+ * in the Changes tree so their indentation can't drift apart. */
+export function treeRowPaddingLeft(depth: number): number {
+ return SPACING[3] + depth * TREE_INDENT_PER_LEVEL;
+}
+
+const foregroundMutedIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
+
+const ThemedChevronRight = withUnistyles(ChevronRight);
+
+/**
+ * Vertical guide lines connecting nested rows to their ancestors — one line per
+ * ancestor depth level, positioned absolutely within the (relative) row. Renders
+ * nothing at depth 0.
+ */
+export function TreeIndentGuides({ depth }: { depth: number }) {
+ const guides = useMemo(
+ () =>
+ Array.from({ length: depth }, (_, index) => ({
+ key: index,
+ style: [
+ styles.indentGuide,
+ inlineUnistylesStyle({ left: SPACING[3] + index * TREE_INDENT_PER_LEVEL + 4 }),
+ ],
+ })),
+ [depth],
+ );
+ return (
+ <>
+ {guides.map((guide) => (
+
+ ))}
+ >
+ );
+}
+
+/** Rotating disclosure chevron for a directory row (points right; rotates down when expanded). */
+export function TreeChevron({ expanded }: { expanded: boolean }) {
+ return (
+
+
+
+ );
+}
+
+const styles = StyleSheet.create((theme: Theme) => ({
+ indentGuide: {
+ position: "absolute",
+ top: 0,
+ bottom: 0,
+ width: 1,
+ backgroundColor: theme.colors.surface2,
+ },
+ chevron: {
+ width: 16,
+ height: 16,
+ alignItems: "center",
+ justifyContent: "center",
+ flexShrink: 0,
+ },
+ chevronExpanded: {
+ transform: [{ rotate: "90deg" }],
+ },
+}));
+
+// Stable module-level style ref so TreeChevron passes a constant array, not one created
+// per render — satisfies react-perf (no inline-array prop) without a per-render useMemo.
+const CHEVRON_EXPANDED_STYLE = [styles.chevron, styles.chevronExpanded];
diff --git a/packages/app/src/git/diff-flat-items.test.ts b/packages/app/src/git/diff-flat-items.test.ts
new file mode 100644
index 000000000..14c60f5c4
--- /dev/null
+++ b/packages/app/src/git/diff-flat-items.test.ts
@@ -0,0 +1,114 @@
+import { describe, expect, it } from "vitest";
+import { buildDiffFlatItems, sumHeightsBefore, type DiffFlatItem } from "./diff-flat-items";
+import type { ParsedDiffFile } from "@/git/use-diff-query";
+
+function createFile(path: string, additions = 1, deletions = 0): ParsedDiffFile {
+ return { path, isNew: false, isDeleted: false, additions, deletions, hunks: [] };
+}
+
+function summarize(items: DiffFlatItem[]): string[] {
+ return items.map((item) => {
+ if (item.type === "folder") {
+ return `${" ".repeat(item.depth)}[${item.displayName}]${item.collapsed ? " (collapsed)" : ""}`;
+ }
+ const base = item.file.path.split("/").pop();
+ return `${" ".repeat(item.depth)}${item.type === "body" ? "body:" : ""}${base}`;
+ });
+}
+
+describe("buildDiffFlatItems", () => {
+ const files = [createFile("src/app/a.ts"), createFile("src/app/nested/b.ts")];
+
+ it("emits a body and a sticky index for each expanded file", () => {
+ const { items, stickyHeaderIndices } = buildDiffFlatItems({
+ files: [createFile("a.ts"), createFile("b.ts")],
+ collapsedFolders: new Set(),
+ expandedPaths: new Set(["a.ts"]),
+ });
+ // Root-level files (no folder): header, body (for expanded a.ts), header
+ expect(summarize(items)).toEqual(["a.ts", "body:a.ts", "b.ts"]);
+ // sticky points at the header (index 0), not the body
+ expect(stickyHeaderIndices).toEqual([0]);
+ expect(items[0].type).toBe("header");
+ });
+
+ it("groups files under compressed folder rows, all expanded by default", () => {
+ const { items } = buildDiffFlatItems({
+ files,
+ collapsedFolders: new Set(),
+ expandedPaths: new Set(),
+ });
+ // dirs sort before files within a level: [nested] precedes a.ts
+ expect(summarize(items)).toEqual(["[src/app]", " [nested]", " b.ts", " a.ts"]);
+ });
+
+ it("collapsing a folder hides its descendants but keeps the row", () => {
+ const { items } = buildDiffFlatItems({
+ files,
+ collapsedFolders: new Set(["src/app/nested"]),
+ expandedPaths: new Set(),
+ });
+ expect(summarize(items)).toEqual(["[src/app]", " [nested] (collapsed)", " a.ts"]);
+ });
+
+ it("collapsing an ancestor hides everything below it", () => {
+ const { items } = buildDiffFlatItems({
+ files,
+ collapsedFolders: new Set(["src/app"]),
+ expandedPaths: new Set(),
+ });
+ expect(summarize(items)).toEqual(["[src/app] (collapsed)"]);
+ });
+
+ it("derives sticky indices file-headers-only from the post-collapse list", () => {
+ // dirs-first: [src/app], [nested], b.ts, a.ts. Expanding a.ts puts its
+ // header at index 3, with the body right after.
+ const { items, stickyHeaderIndices } = buildDiffFlatItems({
+ files,
+ collapsedFolders: new Set(),
+ expandedPaths: new Set(["src/app/a.ts"]),
+ });
+ expect(summarize(items)).toEqual([
+ "[src/app]",
+ " [nested]",
+ " b.ts",
+ " a.ts",
+ " body:a.ts",
+ ]);
+ expect(stickyHeaderIndices).toEqual([3]);
+ for (const idx of stickyHeaderIndices) {
+ expect(items[idx].type).toBe("header");
+ }
+ });
+
+ it("maps tree file rows back to their original files array index", () => {
+ const { items } = buildDiffFlatItems({
+ files,
+ collapsedFolders: new Set(),
+ expandedPaths: new Set(),
+ });
+ // Tree order is b.ts (fileIndex 1) then a.ts (fileIndex 0)
+ const headers = items.filter((i) => i.type === "header");
+ expect(headers.map((h) => (h.type === "header" ? h.fileIndex : -1))).toEqual([1, 0]);
+ });
+});
+
+describe("sumHeightsBefore", () => {
+ const items = buildDiffFlatItems({
+ files: [createFile("src/a.ts"), createFile("src/b.ts")],
+ collapsedFolders: new Set(),
+ expandedPaths: new Set(),
+ }).items; // [folder, a.ts, b.ts]
+
+ const heightFor = (item: DiffFlatItem) => (item.type === "folder" ? 10 : 20);
+
+ it("sums the heights of items before the index, counting folder rows", () => {
+ expect(sumHeightsBefore(items, 0, heightFor)).toBe(0);
+ expect(sumHeightsBefore(items, 1, heightFor)).toBe(10); // folder above a.ts
+ expect(sumHeightsBefore(items, 2, heightFor)).toBe(30); // folder + a.ts above b.ts
+ });
+
+ it("clamps an out-of-range index to the list length", () => {
+ expect(sumHeightsBefore(items, 999, heightFor)).toBe(50);
+ });
+});
diff --git a/packages/app/src/git/diff-flat-items.ts b/packages/app/src/git/diff-flat-items.ts
new file mode 100644
index 000000000..2947f017f
--- /dev/null
+++ b/packages/app/src/git/diff-flat-items.ts
@@ -0,0 +1,112 @@
+import type { ParsedDiffFile } from "@/git/use-diff-query";
+import {
+ buildDiffTree,
+ compressSingleChildChains,
+ flattenDiffTree,
+ type DiffTreeDirNode,
+} from "@/git/diff-tree";
+
+// The row model for the Changes FlatList. `fileIndex` is the file's index within
+// the (path-sorted) `files` array — a stable identity for testIDs/keys that is
+// independent of folder rows and collapse state. `depth` drives indentation.
+export type DiffFlatItem =
+ | {
+ type: "folder";
+ dirPath: string;
+ displayName: string;
+ depth: number;
+ collapsed: boolean;
+ additions: number;
+ deletions: number;
+ }
+ | { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean; depth: number }
+ | { type: "body"; file: ParsedDiffFile; fileIndex: number; depth: number };
+
+export interface DiffFlatItemsResult {
+ items: DiffFlatItem[];
+ /** Indices into `items` of expanded file headers — folder rows are never sticky. */
+ stickyHeaderIndices: number[];
+}
+
+export interface BuildDiffFlatItemsInput {
+ files: ParsedDiffFile[];
+ /** Full uncompressed directory paths that are collapsed (empty = all expanded). */
+ collapsedFolders: ReadonlySet;
+ /** File paths whose diff body is expanded. */
+ expandedPaths: ReadonlySet;
+ /**
+ * Pre-built compressed tree (from the same `files`). Pass it to avoid rebuilding
+ * the tree on every collapse toggle; omitted callers (tests) build it internally.
+ */
+ tree?: DiffTreeDirNode;
+}
+
+/**
+ * Build the FlatList item model plus its sticky-header indices: files grouped
+ * under directory rows, single-child chains compressed, descendants of collapsed
+ * folders omitted.
+ *
+ * Sticky indices are derived from the FINAL (post-collapse) item list and only
+ * ever point at expanded FILE headers, so folder rows can't stack or collide in
+ * the sticky header stack.
+ */
+export function buildDiffFlatItems({
+ files,
+ collapsedFolders,
+ expandedPaths,
+ tree,
+}: BuildDiffFlatItemsInput): DiffFlatItemsResult {
+ const items: DiffFlatItem[] = [];
+ const stickyHeaderIndices: number[] = [];
+
+ const pushFile = (file: ParsedDiffFile, fileIndex: number, depth: number): void => {
+ const isExpanded = expandedPaths.has(file.path);
+ items.push({ type: "header", file, fileIndex, isExpanded, depth });
+ if (isExpanded) {
+ stickyHeaderIndices.push(items.length - 1);
+ items.push({ type: "body", file, fileIndex, depth });
+ }
+ };
+
+ const indexByPath = new Map(files.map((file, index) => [file.path, index]));
+ const compressedTree = tree ?? compressSingleChildChains(buildDiffTree(files));
+ const rows = flattenDiffTree(compressedTree, collapsedFolders);
+
+ for (const row of rows) {
+ if (row.kind === "folder") {
+ items.push({
+ type: "folder",
+ dirPath: row.dirPath,
+ displayName: row.displayName,
+ depth: row.depth,
+ collapsed: collapsedFolders.has(row.dirPath),
+ additions: row.additions,
+ deletions: row.deletions,
+ });
+ continue;
+ }
+ const fileIndex = indexByPath.get(row.file.path);
+ if (fileIndex === undefined) {
+ // Should never happen: the tree is built from the same `files` array.
+ continue;
+ }
+ pushFile(row.file, fileIndex, row.depth);
+ }
+
+ return { items, stickyHeaderIndices };
+}
+
+/** Cumulative height of every item before `index`. Single source of truth for
+ * FlatList's getItemLayout AND the collapse scroll-anchor math. */
+export function sumHeightsBefore(
+ items: DiffFlatItem[],
+ index: number,
+ heightFor: (item: DiffFlatItem) => number,
+): number {
+ let offset = 0;
+ const end = Math.min(index, items.length);
+ for (let i = 0; i < end; i++) {
+ offset += heightFor(items[i]);
+ }
+ return Math.max(0, offset);
+}
diff --git a/packages/app/src/git/diff-folder-row.tsx b/packages/app/src/git/diff-folder-row.tsx
new file mode 100644
index 000000000..cd0cbe5f5
--- /dev/null
+++ b/packages/app/src/git/diff-folder-row.tsx
@@ -0,0 +1,124 @@
+import { useCallback, useMemo } from "react";
+import {
+ View,
+ Text,
+ Pressable,
+ type LayoutChangeEvent,
+ type PressableStateCallbackType,
+} from "react-native";
+import { StyleSheet } from "react-native-unistyles";
+import { DiffStat } from "@/components/diff-stat";
+import { TreeChevron, TreeIndentGuides, treeRowPaddingLeft } from "@/components/tree-primitives";
+import { type Theme } from "@/styles/theme";
+import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
+
+interface DiffFolderRowProps {
+ /** full uncompressed directory path — the collapse identity */
+ dirPath: string;
+ displayName: string;
+ depth: number;
+ collapsed: boolean;
+ additions: number;
+ deletions: number;
+ onToggle: (dirPath: string) => void;
+ onHeightChange?: (height: number) => void;
+ testID?: string;
+}
+
+function folderRowPressableStyle({
+ hovered,
+ pressed,
+}: PressableStateCallbackType & { hovered?: boolean }) {
+ // Subtle background highlight on hover/press, matching the Files explorer rows
+ // (entryRowActive) — no opacity darken.
+ return [styles.folderRow, (Boolean(hovered) || pressed) && styles.folderRowActive];
+}
+
+export function DiffFolderRow({
+ dirPath,
+ displayName,
+ depth,
+ collapsed,
+ additions,
+ deletions,
+ onToggle,
+ onHeightChange,
+ testID,
+}: DiffFolderRowProps) {
+ const handlePress = useCallback(() => {
+ onToggle(dirPath);
+ }, [dirPath, onToggle]);
+
+ const handleLayout = useCallback(
+ (event: LayoutChangeEvent) => {
+ onHeightChange?.(event.nativeEvent.layout.height);
+ },
+ [onHeightChange],
+ );
+
+ const leftStyle = useMemo(
+ () => [styles.left, inlineUnistylesStyle({ paddingLeft: treeRowPaddingLeft(depth) })],
+ [depth],
+ );
+
+ const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]);
+
+ return (
+
+
+
+
+
+
+ {displayName}
+
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create((theme: Theme) => ({
+ container: {
+ overflow: "hidden",
+ },
+ folderRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ paddingRight: theme.spacing[2],
+ paddingVertical: theme.spacing[2],
+ gap: theme.spacing[1],
+ minWidth: 0,
+ },
+ folderRowActive: {
+ backgroundColor: theme.colors.surfaceSidebarHover,
+ },
+ left: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing[1],
+ flex: 1,
+ minWidth: 0,
+ },
+ right: {
+ flexDirection: "row",
+ alignItems: "center",
+ flexShrink: 0,
+ },
+ folderName: {
+ fontSize: theme.fontSize.sm,
+ fontWeight: theme.fontWeight.normal,
+ color: theme.colors.foreground,
+ flexShrink: 1,
+ minWidth: 0,
+ },
+}));
diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx
index de0fc1224..135925193 100644
--- a/packages/app/src/git/diff-pane.tsx
+++ b/packages/app/src/git/diff-pane.tsx
@@ -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 (
+
+ {showDir ? null : (
+
+
+
+ )}
- {file.path.split("/").pop()}
-
-
- {file.path.includes("/")
- ? ` ${file.path.slice(0, file.path.lastIndexOf("/"))}`
- : ""}
+ {fileName}
+ {showDir ? (
+
+ {file.path.includes("/")
+ ? ` ${file.path.slice(0, file.path.lastIndexOf("/"))}`
+ : ""}
+
+ ) : (
+ // 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.
+
+ )}
{file.isNew && (
{t("workspace.git.diff.newFile")}
@@ -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 (
+ {showFolderControl ? (
+
+
+
+
+
+
+
+ {folderLabel}
+
+
+ ) : null}
@@ -1348,8 +1423,13 @@ function DiffFilesToolbar({
-
- {allExpanded ? (
+
+ {allFileDiffsExpanded ? (
["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>(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>({});
const bodyHeightByKeyRef = useRef>({});
+ // 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(0);
const defaultHeaderHeightRef = useRef(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 (
+
+ );
+ }
if (item.type === "header") {
return (
`${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(
(_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 ? (
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],
diff --git a/packages/app/src/git/diff-tree.test.ts b/packages/app/src/git/diff-tree.test.ts
new file mode 100644
index 000000000..89c11d8a5
--- /dev/null
+++ b/packages/app/src/git/diff-tree.test.ts
@@ -0,0 +1,149 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildDiffTree,
+ collectDirPaths,
+ compressSingleChildChains,
+ flattenDiffTree,
+ type DiffTreeRow,
+} from "./diff-tree";
+import type { ParsedDiffFile } from "@/git/use-diff-query";
+
+function createFile(path: string, additions = 1, deletions = 0): ParsedDiffFile {
+ return {
+ path,
+ isNew: false,
+ isDeleted: false,
+ additions,
+ deletions,
+ hunks: [],
+ };
+}
+
+// The Changes tree is fed the already-path-sorted diff files; build against that.
+function tree(paths: Array) {
+ return buildDiffTree(paths.map((p) => (typeof p === "string" ? createFile(p) : p)));
+}
+
+function compressed(paths: Array) {
+ return compressSingleChildChains(tree(paths));
+}
+
+function rowLabels(rows: DiffTreeRow[]): string[] {
+ return rows.map((row) =>
+ row.kind === "folder"
+ ? `${" ".repeat(row.depth)}[${row.displayName}]`
+ : `${" ".repeat(row.depth)}${row.file.path.split("/").pop()}`,
+ );
+}
+
+describe("buildDiffTree", () => {
+ it("returns an empty root for no files", () => {
+ const root = tree([]);
+ expect(root.dirPath).toBe("");
+ expect(root.children).toEqual([]);
+ });
+
+ it("places root-level files directly under the root with no folder", () => {
+ const root = tree(["README.md", "package.json"]);
+ expect(root.children.every((c) => c.kind === "file")).toBe(true);
+ expect(root.children.map((c) => (c.kind === "file" ? c.name : c.dirPath))).toEqual([
+ "README.md",
+ "package.json",
+ ]);
+ });
+
+ it("nests files under their directory, keyed by full path", () => {
+ const root = tree(["src/a.ts", "src/nested/b.ts"]);
+ expect(root.children).toHaveLength(1);
+ const src = root.children[0];
+ expect(src.kind).toBe("dir");
+ if (src.kind !== "dir") return;
+ expect(src.dirPath).toBe("src");
+ const nested = src.children.find((c) => c.kind === "dir");
+ expect(nested?.kind === "dir" && nested.dirPath).toBe("src/nested");
+ });
+
+ it("orders directories before files within a level, each alphabetically", () => {
+ // Input alpha-by-full-path puts the file 'src/a.ts' before dir 'src/z/...',
+ // but rendering wants dirs first.
+ const root = tree(["src/a.ts", "src/z/deep.ts", "src/m/mid.ts"]);
+ const src = root.children[0];
+ if (src.kind !== "dir") throw new Error("expected dir");
+ expect(
+ src.children.map((c) => (c.kind === "dir" ? `dir:${c.name}` : `file:${c.name}`)),
+ ).toEqual(["dir:m", "dir:z", "file:a.ts"]);
+ });
+});
+
+describe("compressSingleChildChains", () => {
+ it("collapses a single-child chain into one row keyed by the deepest dir", () => {
+ const rows = flattenDiffTree(compressed(["packages/app/src/git/diff-pane.tsx"]), new Set());
+ expect(rowLabels(rows)).toEqual(["[packages/app/src/git]", " diff-pane.tsx"]);
+ const folder = rows[0];
+ expect(folder.kind === "folder" && folder.dirPath).toBe("packages/app/src/git");
+ });
+
+ it("stops compressing where a directory has multiple children", () => {
+ const rows = flattenDiffTree(
+ compressed(["packages/app/a.ts", "packages/server/b.ts"]),
+ new Set(),
+ );
+ // "packages" has two dir children, so it stays its own row.
+ expect(rowLabels(rows)).toEqual([
+ "[packages]",
+ " [app]",
+ " a.ts",
+ " [server]",
+ " b.ts",
+ ]);
+ });
+
+ it("compresses a chain that ends in a file-bearing directory", () => {
+ const rows = flattenDiffTree(compressed(["a/b/c/one.ts", "a/b/c/two.ts"]), new Set());
+ expect(rowLabels(rows)).toEqual(["[a/b/c]", " one.ts", " two.ts"]);
+ });
+
+ it("does not merge the virtual root with a single top-level dir's siblings", () => {
+ const rows = flattenDiffTree(compressed(["only/deep/file.ts"]), new Set());
+ expect(rows[0].kind === "folder" && rows[0].displayName).toBe("only/deep");
+ });
+});
+
+describe("flattenDiffTree", () => {
+ it("expands everything when the collapsed set is empty", () => {
+ const rows = flattenDiffTree(compressed(["src/a.ts", "src/b.ts"]), new Set());
+ expect(rowLabels(rows)).toEqual(["[src]", " a.ts", " b.ts"]);
+ });
+
+ it("omits descendants of a collapsed directory but keeps the folder row", () => {
+ const root = compressed(["src/a.ts", "src/b.ts"]);
+ const rows = flattenDiffTree(root, new Set(["src"]));
+ expect(rowLabels(rows)).toEqual(["[src]"]);
+ });
+
+ it("collapsing a parent hides nested folders and files", () => {
+ const root = compressed(["src/deep/a.ts", "src/top.ts"]);
+ const rows = flattenDiffTree(root, new Set(["src"]));
+ expect(rowLabels(rows)).toEqual(["[src]"]);
+ });
+
+ it("sums descendant additions/deletions onto the folder row", () => {
+ const root = compressed([createFile("src/a.ts", 3, 1), createFile("src/nested/b.ts", 5, 2)]);
+ const rows = flattenDiffTree(root, new Set());
+ const srcFolder = rows.find((r) => r.kind === "folder" && r.dirPath === "src");
+ expect(srcFolder).toMatchObject({ additions: 8, deletions: 3 });
+ });
+
+ it("reports full aggregate stats even when the folder is collapsed", () => {
+ const root = compressed([createFile("src/a.ts", 3, 1), createFile("src/b.ts", 4, 0)]);
+ const rows = flattenDiffTree(root, new Set(["src"]));
+ expect(rows[0]).toMatchObject({ kind: "folder", additions: 7, deletions: 1 });
+ });
+});
+
+describe("collectDirPaths", () => {
+ it("returns every logical directory path in the compressed tree", () => {
+ const root = compressed(["packages/app/a.ts", "packages/server/b.ts"]);
+ expect(collectDirPaths(root).sort()).toEqual(["packages", "packages/app", "packages/server"]);
+ });
+});
diff --git a/packages/app/src/git/diff-tree.ts b/packages/app/src/git/diff-tree.ts
new file mode 100644
index 000000000..4725690b4
--- /dev/null
+++ b/packages/app/src/git/diff-tree.ts
@@ -0,0 +1,207 @@
+import type { ParsedDiffFile } from "@/git/use-diff-query";
+
+// Builds a directory hierarchy from the flat, path-sorted `ParsedDiffFile[]` the
+// Changes view renders. The tree renders on every form factor, consistent with
+// the Files explorer.
+//
+// Directory nodes are keyed by their FULL uncompressed path (e.g. "packages/app/src").
+// That path is the stable identity used to persist folder-collapse state, so the
+// state survives path-compression changes as the diff mutates: if a compressed
+// row later splits because a sibling appears, the logical directories keep the
+// same keys.
+
+export interface DiffTreeFileNode {
+ kind: "file";
+ file: ParsedDiffFile;
+ /** basename, e.g. "diff-pane.tsx" */
+ name: string;
+}
+
+export interface DiffTreeDirNode {
+ kind: "dir";
+ /** full uncompressed directory path, e.g. "packages/app/src"; "" for the virtual root */
+ dirPath: string;
+ /** display label; a compressed chain joins segments, e.g. "packages/app/src" */
+ name: string;
+ children: DiffTreeNode[];
+}
+
+export type DiffTreeNode = DiffTreeFileNode | DiffTreeDirNode;
+
+export interface DiffTreeFolderRow {
+ kind: "folder";
+ /** full uncompressed path of the DEEPEST directory this row represents */
+ dirPath: string;
+ /** compressed display label, e.g. "packages/app/src" */
+ displayName: string;
+ depth: number;
+ additions: number;
+ deletions: number;
+}
+
+export interface DiffTreeFileRow {
+ kind: "file";
+ file: ParsedDiffFile;
+ depth: number;
+}
+
+export type DiffTreeRow = DiffTreeFolderRow | DiffTreeFileRow;
+
+function sortTree(node: DiffTreeDirNode): void {
+ node.children.sort((a, b) => {
+ if (a.kind !== b.kind) {
+ // directories before files within a level
+ return a.kind === "dir" ? -1 : 1;
+ }
+ // Plain ASCII comparison, matching compareCheckoutDiffPaths (diff-order.ts)
+ // so the tree order is consistent with the rest of the Changes view.
+ if (a.name === b.name) return 0;
+ return a.name < b.name ? -1 : 1;
+ });
+ for (const child of node.children) {
+ if (child.kind === "dir") {
+ sortTree(child);
+ }
+ }
+}
+
+/** Build the (uncompressed) directory tree. Returns the virtual root (dirPath ""). */
+export function buildDiffTree(files: ParsedDiffFile[]): DiffTreeDirNode {
+ const root: DiffTreeDirNode = { kind: "dir", dirPath: "", name: "", children: [] };
+ const dirByPath = new Map([["", root]]);
+
+ function ensureDir(dirPath: string): DiffTreeDirNode {
+ const existing = dirByPath.get(dirPath);
+ if (existing) {
+ return existing;
+ }
+ const parts = dirPath.split("/");
+ const name = parts[parts.length - 1];
+ const parentPath = parts.slice(0, -1).join("/");
+ const parent = ensureDir(parentPath);
+ const node: DiffTreeDirNode = { kind: "dir", dirPath, name, children: [] };
+ parent.children.push(node);
+ dirByPath.set(dirPath, node);
+ return node;
+ }
+
+ for (const file of files) {
+ const parts = file.path.split("/");
+ const name = parts[parts.length - 1];
+ const dirPath = parts.slice(0, -1).join("/");
+ ensureDir(dirPath).children.push({ kind: "file", file, name });
+ }
+
+ sortTree(root);
+ return root;
+}
+
+// Collapse runs of single-child directories into one row, like VS Code / GitHub:
+// a directory whose only child is another directory absorbs it. The merged row
+// displays the joined segments ("packages/app/src") but keeps the DEEPEST
+// directory's full path as its identity.
+function compressNode(node: DiffTreeDirNode): DiffTreeDirNode {
+ let name = node.name;
+ let dirPath = node.dirPath;
+ let children = node.children.map((child) => (child.kind === "dir" ? compressNode(child) : child));
+ while (children.length === 1 && children[0].kind === "dir") {
+ const only = children[0];
+ name = name ? `${name}/${only.name}` : only.name;
+ dirPath = only.dirPath;
+ children = only.children;
+ }
+ return { kind: "dir", dirPath, name, children };
+}
+
+/**
+ * Compress single-child directory chains. The virtual root is never merged
+ * (it isn't rendered); only its subtrees are compressed.
+ */
+export function compressSingleChildChains(root: DiffTreeDirNode): DiffTreeDirNode {
+ return {
+ ...root,
+ children: root.children.map((child) => (child.kind === "dir" ? compressNode(child) : child)),
+ };
+}
+
+interface DirStats {
+ additions: number;
+ deletions: number;
+}
+
+const EMPTY_DIR_STATS: DirStats = { additions: 0, deletions: 0 };
+
+// Single post-order pass computing every directory node's aggregate stats from
+// its already-summed children — O(n), vs. re-walking each subtree per folder row.
+function computeDirStats(root: DiffTreeDirNode): Map {
+ const statsByNode = new Map();
+ function visit(node: DiffTreeDirNode): DirStats {
+ const stats: DirStats = { additions: 0, deletions: 0 };
+ for (const child of node.children) {
+ if (child.kind === "file") {
+ stats.additions += child.file.additions;
+ stats.deletions += child.file.deletions;
+ } else {
+ const childStats = visit(child);
+ stats.additions += childStats.additions;
+ stats.deletions += childStats.deletions;
+ }
+ }
+ statsByNode.set(node, stats);
+ return stats;
+ }
+ visit(root);
+ return statsByNode;
+}
+
+/**
+ * Flatten the (compressed) tree into depth-tagged rows for the list. Descendants
+ * of any directory whose `dirPath` is in `collapsed` are omitted; folder rows
+ * always carry the FULL aggregate stats of their subtree, collapsed or not.
+ */
+export function flattenDiffTree(
+ root: DiffTreeDirNode,
+ collapsed: ReadonlySet,
+): DiffTreeRow[] {
+ const statsByNode = computeDirStats(root);
+ const rows: DiffTreeRow[] = [];
+
+ function walk(node: DiffTreeDirNode, depth: number): void {
+ for (const child of node.children) {
+ if (child.kind === "file") {
+ rows.push({ kind: "file", file: child.file, depth });
+ continue;
+ }
+ const stats = statsByNode.get(child) ?? EMPTY_DIR_STATS;
+ rows.push({
+ kind: "folder",
+ dirPath: child.dirPath,
+ displayName: child.name,
+ depth,
+ additions: stats.additions,
+ deletions: stats.deletions,
+ });
+ if (!collapsed.has(child.dirPath)) {
+ walk(child, depth + 1);
+ }
+ }
+ }
+
+ walk(root, 0);
+ return rows;
+}
+
+/** Every directory path in the (compressed) tree — used for "collapse all folders". */
+export function collectDirPaths(root: DiffTreeDirNode): string[] {
+ const paths: string[] = [];
+ function walk(node: DiffTreeDirNode): void {
+ for (const child of node.children) {
+ if (child.kind === "dir") {
+ paths.push(child.dirPath);
+ walk(child);
+ }
+ }
+ }
+ walk(root);
+ return paths;
+}
diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts
index bbcf09d41..7f92d37ba 100644
--- a/packages/app/src/i18n/resources/ar.ts
+++ b/packages/app/src/i18n/resources/ar.ts
@@ -712,6 +712,8 @@ export const ar: TranslationResources = {
wrapLongLines: "لف الخطوط الطويلة",
collapseAll: "طي كافة الملفات",
expandAll: "قم بتوسيع كافة الملفات",
+ collapseAllFolders: "طي كافة المجلدات",
+ expandAllFolders: "توسيع كافة المجلدات",
refreshing: "منعش",
refresh: "ينعش",
refreshState: "تحديث بوابة وحالة GitHub",
diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts
index b7dbe7b3a..7d828db50 100644
--- a/packages/app/src/i18n/resources/en.ts
+++ b/packages/app/src/i18n/resources/en.ts
@@ -719,6 +719,8 @@ export const en = {
wrapLongLines: "Wrap long lines",
collapseAll: "Collapse all files",
expandAll: "Expand all files",
+ collapseAllFolders: "Collapse all folders",
+ expandAllFolders: "Expand all folders",
refreshing: "Refreshing",
refresh: "Refresh",
refreshState: "Refresh git and GitHub state",
diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts
index 16c73dc65..ee5865e30 100644
--- a/packages/app/src/i18n/resources/es.ts
+++ b/packages/app/src/i18n/resources/es.ts
@@ -739,6 +739,8 @@ export const es: TranslationResources = {
wrapLongLines: "Envolver largas filas",
collapseAll: "Contraer todos los archivos",
expandAll: "Expandir todos los archivos",
+ collapseAllFolders: "Contraer todas las carpetas",
+ expandAllFolders: "Expandir todas las carpetas",
refreshing: "Refrescante",
refresh: "Refrescar",
refreshState: "Actualizar el estado de git yGitHub",
diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts
index 8aff3dbe1..e4b910e5e 100644
--- a/packages/app/src/i18n/resources/fr.ts
+++ b/packages/app/src/i18n/resources/fr.ts
@@ -738,6 +738,8 @@ export const fr: TranslationResources = {
wrapLongLines: "Enroulez les longues lignes",
collapseAll: "Réduire tous les fichiers",
expandAll: "Développer tous les fichiers",
+ collapseAllFolders: "Réduire tous les dossiers",
+ expandAllFolders: "Développer tous les dossiers",
refreshing: "Rafraîchissant",
refresh: "Rafraîchir",
refreshState: "Actualiser l'état de git etGitHub",
diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts
index 5f58a6b6a..8b7833633 100644
--- a/packages/app/src/i18n/resources/ja.ts
+++ b/packages/app/src/i18n/resources/ja.ts
@@ -724,6 +724,8 @@ export const ja: TranslationResources = {
wrapLongLines: "長い行を折り返す",
collapseAll: "すべて折りたたむ",
expandAll: "すべて展開",
+ collapseAllFolders: "すべてのフォルダを折りたたむ",
+ expandAllFolders: "すべてのフォルダを展開",
refreshing: "更新中",
refresh: "更新",
refreshState: "gitとGitHubの状態を更新",
diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts
index b29003ec6..269d40793 100644
--- a/packages/app/src/i18n/resources/pt-BR.ts
+++ b/packages/app/src/i18n/resources/pt-BR.ts
@@ -730,6 +730,8 @@ export const ptBR: TranslationResources = {
wrapLongLines: "Quebrar linhas longas",
collapseAll: "Recolher todos os arquivos",
expandAll: "Expandir todos os arquivos",
+ collapseAllFolders: "Recolher todas as pastas",
+ expandAllFolders: "Expandir todas as pastas",
refreshing: "Atualizando",
refresh: "Atualizar",
refreshState: "Atualizar estado do git e do GitHub",
diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts
index 5450eec77..30ed6e31d 100644
--- a/packages/app/src/i18n/resources/ru.ts
+++ b/packages/app/src/i18n/resources/ru.ts
@@ -731,6 +731,8 @@ export const ru: TranslationResources = {
wrapLongLines: "Перенос длинных строк",
collapseAll: "Свернуть все файлы",
expandAll: "Развернуть все файлы",
+ collapseAllFolders: "Свернуть все папки",
+ expandAllFolders: "Развернуть все папки",
refreshing: "Освежающий",
refresh: "Обновить",
refreshState: "Обновить состояние git и GitHub.",
diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts
index 542fc835d..1128f66c9 100644
--- a/packages/app/src/i18n/resources/zh-CN.ts
+++ b/packages/app/src/i18n/resources/zh-CN.ts
@@ -706,6 +706,8 @@ export const zhCN: TranslationResources = {
wrapLongLines: "自动换行长行",
collapseAll: "折叠所有文件",
expandAll: "展开所有文件",
+ collapseAllFolders: "折叠所有文件夹",
+ expandAllFolders: "展开所有文件夹",
refreshing: "正在刷新",
refresh: "刷新",
refreshState: "刷新 git 和 GitHub 状态",
diff --git a/packages/app/src/stores/panel-store/index.ts b/packages/app/src/stores/panel-store/index.ts
index 3c04440ba..2fe2b9a28 100644
--- a/packages/app/src/stores/panel-store/index.ts
+++ b/packages/app/src/stores/panel-store/index.ts
@@ -72,6 +72,11 @@ export interface PanelState {
explorerTabByCheckout: Record;
expandedPathsByWorkspace: Record;
diffExpandedPathsByWorkspace: Record;
+ // Changes-view folder tree. Inverted semantics vs the fields above:
+ // this stores COLLAPSED directory paths (empty = all folders expanded), keyed
+ // by full uncompressed dir path, so folders default to expanded and new
+ // folders stay expanded as the diff changes.
+ diffCollapsedFoldersByWorkspace: Record;
sidebarWidth: number;
explorerWidth: number;
explorerSortOption: SortOption;
@@ -98,6 +103,7 @@ export interface PanelState {
setExplorerTabForCheckout: (params: ExplorerCheckoutContext & { tab: ExplorerTab }) => void;
setExpandedPathsForWorkspace: (workspaceKey: string, paths: string[]) => void;
setDiffExpandedPathsForWorkspace: (workspaceKey: string, paths: string[]) => void;
+ setDiffCollapsedFoldersForWorkspace: (workspaceKey: string, dirPaths: string[]) => void;
activateExplorerTabForCheckout: (checkout: ExplorerCheckoutContext) => void;
setSidebarWidth: (width: number) => void;
setExplorerWidth: (width: number) => void;
@@ -126,6 +132,7 @@ export const usePanelStore = create()(
explorerTabByCheckout: {},
expandedPathsByWorkspace: {},
diffExpandedPathsByWorkspace: {},
+ diffCollapsedFoldersByWorkspace: {},
sidebarWidth: DEFAULT_SIDEBAR_WIDTH,
explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH,
explorerSortOption: "name",
@@ -253,6 +260,13 @@ export const usePanelStore = create()(
[workspaceKey]: paths,
},
})),
+ setDiffCollapsedFoldersForWorkspace: (workspaceKey, dirPaths) =>
+ set((state) => ({
+ diffCollapsedFoldersByWorkspace: {
+ ...state.diffCollapsedFoldersByWorkspace,
+ [workspaceKey]: dirPaths,
+ },
+ })),
activateExplorerTabForCheckout: (checkout) =>
set((state) => ({
explorerTab: resolveExplorerTabForCheckout({
@@ -276,7 +290,7 @@ export const usePanelStore = create()(
}),
{
name: "panel-state",
- version: 11,
+ version: 12,
storage: createJSONStorage(() => AsyncStorage),
migrate: (persistedState, version) =>
migratePanelState(persistedState, version, { isWeb }) as unknown as PanelState,
@@ -287,6 +301,7 @@ export const usePanelStore = create()(
explorerTabByCheckout: state.explorerTabByCheckout,
expandedPathsByWorkspace: state.expandedPathsByWorkspace,
diffExpandedPathsByWorkspace: state.diffExpandedPathsByWorkspace,
+ diffCollapsedFoldersByWorkspace: state.diffCollapsedFoldersByWorkspace,
sidebarWidth: state.sidebarWidth,
explorerWidth: state.explorerWidth,
explorerSortOption: state.explorerSortOption,
diff --git a/packages/app/src/stores/panel-store/state.test.ts b/packages/app/src/stores/panel-store/state.test.ts
index 538f58900..2e7cbdeb6 100644
--- a/packages/app/src/stores/panel-store/state.test.ts
+++ b/packages/app/src/stores/panel-store/state.test.ts
@@ -103,6 +103,20 @@ describe("panel-store migration", () => {
expect(state.explorerShowHiddenFiles).toBe(true);
});
+
+ it("initializes diffCollapsedFoldersByWorkspace for pre-v12 state", () => {
+ const state = migratePanelState({}, 11, { isWeb: false });
+
+ expect(state.diffCollapsedFoldersByWorkspace).toEqual({});
+ });
+
+ it("preserves an existing diffCollapsedFoldersByWorkspace map", () => {
+ const state = migratePanelState({ diffCollapsedFoldersByWorkspace: { ws: ["src/app"] } }, 12, {
+ isWeb: false,
+ });
+
+ expect(state.diffCollapsedFoldersByWorkspace).toEqual({ ws: ["src/app"] });
+ });
});
describe("panel-store visibility selectors", () => {
diff --git a/packages/app/src/stores/panel-store/state.ts b/packages/app/src/stores/panel-store/state.ts
index 1bafd89a5..d43ad26ae 100644
--- a/packages/app/src/stores/panel-store/state.ts
+++ b/packages/app/src/stores/panel-store/state.ts
@@ -245,6 +245,13 @@ export function migratePanelState(
) {
state.diffExpandedPathsByWorkspace = {};
}
+ if (
+ version < 12 ||
+ typeof state.diffCollapsedFoldersByWorkspace !== "object" ||
+ !state.diffCollapsedFoldersByWorkspace
+ ) {
+ state.diffCollapsedFoldersByWorkspace = {};
+ }
if (typeof state.explorerShowHiddenFiles !== "boolean") {
state.explorerShowHiddenFiles = true;
}