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

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

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

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

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

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 03:55:30 +08:00

208 lines
6.6 KiB
TypeScript

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<string, DiffTreeDirNode>([["", 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<DiffTreeDirNode, DirStats> {
const statsByNode = new Map<DiffTreeDirNode, DirStats>();
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<string>,
): 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;
}