feat: hierarchical folder tree in code review sidebar (#246)

Group changed files by directory with collapsible folders, aggregate
+/- stats per folder, and auto-expand on keyboard navigation.
Single-child directories collapse (e.g., src/utils/ as one node).
Falls back to flat list for single-directory diffs.

Closes #161

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michael Ramos
2026-03-07 17:20:08 -08:00
committed by GitHub
parent 93159240a7
commit 47bfc4bfd4
3 changed files with 368 additions and 54 deletions
+66 -54
View File
@@ -1,5 +1,7 @@
import React, { useEffect, useCallback } from 'react';
import React, { useEffect, useCallback, useState, useMemo } from 'react';
import { CodeAnnotation } from '@plannotator/ui/types';
import { buildFileTree, getAncestorPaths, getAllFolderPaths } from '../utils/buildFileTree';
import { FileTreeNodeItem } from './FileTreeNode';
interface DiffFile {
path: string;
@@ -78,10 +80,54 @@ export const FileTree: React.FC<FileTreeProps> = ({
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
// Get annotation count per file
const getAnnotationCount = (filePath: string) => {
return annotations.filter(a => a.filePath === filePath).length;
};
const annotationCountMap = useMemo(() => {
const map = new Map<string, number>();
for (const a of annotations) {
map.set(a.filePath, (map.get(a.filePath) ?? 0) + 1);
}
return map;
}, [annotations]);
const getAnnotationCount = useCallback((filePath: string) => {
return annotationCountMap.get(filePath) ?? 0;
}, [annotationCountMap]);
const tree = useMemo(() => buildFileTree(files), [files]);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [prevTree, setPrevTree] = useState(tree);
// Expand all folders when tree changes (initial render + diff switch)
if (tree !== prevTree) {
setPrevTree(tree);
setExpandedFolders(new Set(getAllFolderPaths(tree)));
}
// Auto-expand ancestors of the active file so j/k nav always reveals the target
useEffect(() => {
if (files[activeFileIndex]) {
const ancestors = getAncestorPaths(files[activeFileIndex].path);
setExpandedFolders(prev => {
const missing = ancestors.filter(p => !prev.has(p));
if (missing.length === 0) return prev;
const next = new Set(prev);
for (const p of missing) next.add(p);
return next;
});
}
}, [activeFileIndex, files]);
const handleToggleFolder = useCallback((path: string) => {
setExpandedFolders(prev => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
return next;
});
}, []);
return (
<aside className="border-r border-border bg-card/30 flex flex-col flex-shrink-0 overflow-hidden" style={{ width: width ?? 256 }}>
@@ -156,56 +202,22 @@ export const FileTree: React.FC<FileTreeProps> = ({
</div>
)}
{/* File list */}
{/* File tree */}
<div className="flex-1 overflow-y-auto p-2">
{files.map((file, index) => {
const annotationCount = getAnnotationCount(file.path);
const isActive = index === activeFileIndex;
const isViewed = viewedFiles.has(file.path);
const fileName = file.path.split('/').pop() || file.path;
if (hideViewedFiles && isViewed && !isActive) {
return null;
}
return (
<button
key={file.path}
onClick={() => onSelectFile(index)}
className={`file-tree-item w-full text-left group ${isActive ? 'active' : ''} ${annotationCount > 0 ? 'has-annotations' : ''}`}
>
<div className="flex items-center gap-1.5 flex-1 min-w-0">
<span
role="checkbox"
aria-checked={isViewed}
onClick={(e) => {
e.stopPropagation();
onToggleViewed?.(file.path);
}}
className="flex-shrink-0 p-0.5 rounded hover:bg-muted/50 cursor-pointer"
>
{isViewed ? (
<svg className="w-3.5 h-3.5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
) : (
<svg className="w-3.5 h-3.5 text-muted-foreground opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<circle cx="12" cy="12" r="9" />
</svg>
)}
</span>
<span className="truncate">{fileName}</span>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0 text-[10px]">
{annotationCount > 0 && (
<span className="text-primary font-medium">{annotationCount}</span>
)}
<span className="additions">+{file.additions}</span>
<span className="deletions">-{file.deletions}</span>
</div>
</button>
);
})}
{tree.map(node => (
<FileTreeNodeItem
key={node.type === 'file' ? node.path : `folder:${node.path}`}
node={node}
expandedFolders={expandedFolders}
onToggleFolder={handleToggleFolder}
activeFileIndex={activeFileIndex}
onSelectFile={onSelectFile}
viewedFiles={viewedFiles}
onToggleViewed={onToggleViewed}
hideViewedFiles={hideViewedFiles}
getAnnotationCount={getAnnotationCount}
/>
))}
</div>
{/* Footer */}
@@ -0,0 +1,141 @@
import React from 'react';
import type { FileTreeNode as TreeNode } from '../utils/buildFileTree';
interface FileTreeNodeProps {
node: TreeNode;
expandedFolders: Set<string>;
onToggleFolder: (path: string) => void;
activeFileIndex: number;
onSelectFile: (index: number) => void;
viewedFiles: Set<string>;
onToggleViewed?: (filePath: string) => void;
hideViewedFiles: boolean;
getAnnotationCount: (filePath: string) => number;
}
function hasVisibleChildren(
node: TreeNode,
viewedFiles: Set<string>,
activeFileIndex: number,
hideViewedFiles: boolean,
): boolean {
if (!hideViewedFiles) return true;
if (!node.children) return false;
return node.children.some(child => {
if (child.type === 'file') {
return child.fileIndex === activeFileIndex || !viewedFiles.has(child.path);
}
return hasVisibleChildren(child, viewedFiles, activeFileIndex, hideViewedFiles);
});
}
export const FileTreeNodeItem: React.FC<FileTreeNodeProps> = ({
node,
expandedFolders,
onToggleFolder,
activeFileIndex,
onSelectFile,
viewedFiles,
onToggleViewed,
hideViewedFiles,
getAnnotationCount,
}) => {
const paddingLeft = 8 + node.depth * 12;
if (node.type === 'folder') {
if (!hasVisibleChildren(node, viewedFiles, activeFileIndex, hideViewedFiles)) {
return null;
}
const isExpanded = expandedFolders.has(node.path);
return (
<>
<button
onClick={() => onToggleFolder(node.path)}
className="w-full flex items-center gap-1.5 py-1 px-2 text-[11px] text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors rounded-sm"
style={{ paddingLeft }}
>
<svg
className={`w-3 h-3 flex-shrink-0 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
<span className="truncate">{node.name}</span>
{(node.additions > 0 || node.deletions > 0) && (
<div className="flex items-center gap-1.5 ml-auto flex-shrink-0 text-[10px]">
<span className="additions">+{node.additions}</span>
<span className="deletions">-{node.deletions}</span>
</div>
)}
</button>
{isExpanded && node.children?.map(child => (
<FileTreeNodeItem
key={child.type === 'file' ? child.path : `folder:${child.path}`}
node={child}
expandedFolders={expandedFolders}
onToggleFolder={onToggleFolder}
activeFileIndex={activeFileIndex}
onSelectFile={onSelectFile}
viewedFiles={viewedFiles}
onToggleViewed={onToggleViewed}
hideViewedFiles={hideViewedFiles}
getAnnotationCount={getAnnotationCount}
/>
))}
</>
);
}
// File node
const isActive = node.fileIndex === activeFileIndex;
const isViewed = viewedFiles.has(node.path);
const annotationCount = getAnnotationCount(node.path);
if (hideViewedFiles && isViewed && !isActive) {
return null;
}
return (
<button
onClick={() => onSelectFile(node.fileIndex!)}
className={`file-tree-item w-full text-left group ${isActive ? 'active' : ''} ${annotationCount > 0 ? 'has-annotations' : ''}`}
style={{ paddingLeft: paddingLeft + 15 }}
>
<div className="flex items-center gap-1.5 flex-1 min-w-0">
<span
role="checkbox"
aria-checked={isViewed}
onClick={(e) => {
e.stopPropagation();
onToggleViewed?.(node.path);
}}
className="flex-shrink-0 p-0.5 rounded hover:bg-muted/50 cursor-pointer"
>
{isViewed ? (
<svg className="w-3.5 h-3.5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
) : (
<svg className="w-3.5 h-3.5 text-muted-foreground opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<circle cx="12" cy="12" r="9" />
</svg>
)}
</span>
<span className="truncate">{node.name}</span>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0 text-[10px]">
{annotationCount > 0 && (
<span className="text-primary font-medium">{annotationCount}</span>
)}
<span className="additions">+{node.file!.additions}</span>
<span className="deletions">-{node.file!.deletions}</span>
</div>
</button>
);
};
@@ -0,0 +1,161 @@
interface DiffFile {
path: string;
oldPath?: string;
patch: string;
additions: number;
deletions: number;
}
export interface FileTreeNode {
type: 'file' | 'folder';
name: string;
path: string;
depth: number;
fileIndex?: number;
file?: DiffFile;
children?: FileTreeNode[];
additions: number;
deletions: number;
}
interface TrieNode {
children: Map<string, TrieNode>;
file?: { index: number; data: DiffFile };
}
function buildTrie(files: DiffFile[]): TrieNode {
const root: TrieNode = { children: new Map() };
for (let i = 0; i < files.length; i++) {
const segments = files[i].path.split('/').filter(Boolean);
let current = root;
for (let j = 0; j < segments.length - 1; j++) {
if (!current.children.has(segments[j])) {
current.children.set(segments[j], { children: new Map() });
}
current = current.children.get(segments[j])!;
}
const fileName = segments[segments.length - 1];
const leaf: TrieNode = { children: new Map(), file: { index: i, data: files[i] } };
current.children.set(fileName, leaf);
}
return root;
}
function trieToNodes(trie: TrieNode, parentPath: string, depth: number): FileTreeNode[] {
const folders: FileTreeNode[] = [];
const fileNodes: FileTreeNode[] = [];
for (const [name, child] of trie.children) {
const fullPath = parentPath ? `${parentPath}/${name}` : name;
if (child.file) {
fileNodes.push({
type: 'file',
name,
path: child.file.data.path,
depth,
fileIndex: child.file.index,
file: child.file.data,
additions: child.file.data.additions,
deletions: child.file.data.deletions,
});
} else {
const children = trieToNodes(child, fullPath, depth + 1);
const additions = children.reduce((s, c) => s + c.additions, 0);
const deletions = children.reduce((s, c) => s + c.deletions, 0);
folders.push({
type: 'folder',
name,
path: fullPath,
depth,
children,
additions,
deletions,
});
}
}
folders.sort((a, b) => a.name.localeCompare(b.name));
fileNodes.sort((a, b) => a.name.localeCompare(b.name));
return [...folders, ...fileNodes];
}
function collapseSingleChild(nodes: FileTreeNode[]): FileTreeNode[] {
return nodes.map(node => {
if (node.type !== 'folder' || !node.children) return node;
let current = node;
while (
current.children &&
current.children.length === 1 &&
current.children[0].type === 'folder'
) {
const child = current.children[0];
current = {
...child,
name: `${current.name}/${child.name}`,
depth: node.depth,
};
}
return {
...current,
children: current.children ? collapseSingleChild(fixDepths(current.children, node.depth + 1)) : undefined,
};
});
}
function fixDepths(nodes: FileTreeNode[], depth: number): FileTreeNode[] {
return nodes.map(node => ({
...node,
depth,
children: node.children ? fixDepths(node.children, depth + 1) : undefined,
}));
}
export function buildFileTree(files: DiffFile[]): FileTreeNode[] {
if (files.length === 0) return [];
const trie = buildTrie(files);
let tree = trieToNodes(trie, '', 0);
tree = collapseSingleChild(tree);
// Flat fallback: if the tree is a single root folder with only file children, unwrap it
if (
tree.length === 1 &&
tree[0].type === 'folder' &&
tree[0].children?.every(c => c.type === 'file')
) {
return fixDepths(tree[0].children!, 0);
}
return tree;
}
export function getAncestorPaths(filePath: string): string[] {
const segments = filePath.split('/').filter(Boolean);
const paths: string[] = [];
for (let i = 1; i < segments.length; i++) {
paths.push(segments.slice(0, i).join('/'));
}
return paths;
}
export function getAllFolderPaths(nodes: FileTreeNode[]): string[] {
const paths: string[] = [];
for (const node of nodes) {
if (node.type === 'folder') {
paths.push(node.path);
if (node.children) {
paths.push(...getAllFolderPaths(node.children));
}
}
}
return paths;
}