mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user