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; }