Files
backnotprop__plannotator/packages/review-editor/utils/buildFileTree.ts
Michael Ramos 0fd9826c2c feat(review): all-files diff view with unified scrolling (#640)
* feat(review): all-files diff view with lazy mounting

Renders all diffs in a single scrollable pane using individual FileDiff
components with sticky file headers, collapse toggles, and viewport-based
lazy mounting via IntersectionObserver.

For provenance purposes, this commit was AI assisted.

* feat(review): convert all-files view to dock panel + fixes

- Register AllFilesDiffView as a proper dock panel so it coexists
  with single-file tabs, AI chat, PR panels
- Remove viewMode toggle in favor of dock panel lifecycle
- Fix annotation toolbar positioning (mouse tracking)
- Fix annotation PR/scope filtering for stacked PRs
- Fix stale patch on file switch (deferred toolbar open)
- Fix conventional comments boolean comparison
- Fix SuggestionModal props to match DiffViewer pattern
- Wire reviewBase for correct hunk expansion context
- Fix pre-existing CSS bug on .has-annotations background
- Add Shift+V/A keyboard shortcuts for viewed/stage toggle
- Add [/] keyboard shortcuts for file-to-file scrolling
- Add keyboard shortcuts to settings panel
- Restore scroll-based file tree highlight via dock panel state

For provenance purposes, this commit was AI assisted.

* feat(review): sidebar toggles, keyboard shortcuts, click-drag selection

- Move sidebar tab buttons (annotations/AI/agents) to header bar
- Generalize useSidebar hook for both plan and review editors
- Remove internal tab state from ReviewSidebar, receive from parent
- Add file tree toggle button at far-left of header
- Add click-and-drag line selection in diff content area
- Change keyboard shortcuts from Shift+V/A to bare v/a/c
  v = toggle viewed (+ collapse in all-files mode)
  a = toggle git add/stage
  c = collapse/expand file (all-files mode)
- Widen DiffOptionsPopover to prevent word-alt line break
- Fix Shift+V/A targeting wrong file in all-files mode
- Default to all-files panel on initial load
- Hide dock headers when all-files panel is the only panel

For provenance purposes, this commit was AI assisted.

* feat(review): click-and-drag line selection in diff content area

Extract shared selection helpers to utils/diffSelection.ts and add
mouseup handlers to both DiffViewer and AllFilesDiffView so users can
click and drag across code lines to open the annotation toolbar —
not just the gutter.

For provenance purposes, this commit was AI assisted.

* fix(review): address AI review findings

- Gate all-files keyboard shortcuts on isActive to prevent double-toggle
  when a single-file tab is focused
- Fix getDiffSelection to iterate all diffs-container elements so
  click-drag selection works on files beyond the first
- Clear needsInitialDiffPanel after opening all-files panel to prevent
  forced reactivation on diff switches
- Clear activeFilePath and collapsedFiles when files change to prevent
  stale state across PR/diff switches
- Add file.patch to LazyFileDiff fetch deps so file contents refetch
  when the diff context changes
- Add toggleCollapse to keyboard effect dependency array

For provenance purposes, this commit was AI assisted.

* feat(review): add z to undo collapse with scroll-back

Press z to reopen the last collapsed file and scroll back to it.
Maintains a history stack so repeated z walks back through collapses.
Skips already-expanded entries. History clears on diff/PR switch.

For provenance purposes, this commit was AI assisted.

* fix(review): review findings + sidebar toggles + folder icon

- Fix stale context memo: add isAllFilesActive + handleAddAnnotationForFile
  to useMemo deps
- Fix v handler: clear activeFilePath after collapse, only collapse when
  marking viewed ON (not when toggling OFF)
- Fix click-drag copy regression: only trigger annotation toolbar for
  multi-line selections, preserve single-line/word selection for Cmd+C
- Fix LazyFileDiff: clear stale file contents before refetch
- Clean up App.tsx v/a handler: early return when isAllFilesActive
- Add Cmd+B (file tree) and Cmd+. (sidebar) keyboard shortcuts
- Add toggle menu items with kbd badges to Options dropdown
- Replace file tree icon with simple folder
- Add z (undo collapse) to keyboard shortcuts panel

For provenance purposes, this commit was AI assisted.

* chore: remove dead code from iterations

- Remove unused FileTreeIcon import and component file
- Remove unused AnnotationsIcon component from ReviewHeaderMenu
- Remove unused onClose prop and close button from FileTree

For provenance purposes, this commit was AI assisted.

* fix(review): gate v/a on diff panel, keep annotation selection in all-files

- Track isDiffPanelActive so v/a shortcuts only fire when viewing a
  single-file diff, not PR Comments/Summary/Agent panels
- Annotation selection stays in all-files mode instead of switching to
  a single-file tab — lets the scroll-to-annotation effect handle it

For provenance purposes, this commit was AI assisted.
2026-04-30 23:39:43 -07:00

168 lines
4.3 KiB
TypeScript

import type { DiffFile } from '../types';
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 getVisualFileOrder(nodes: FileTreeNode[]): number[] {
const order: number[] = [];
for (const node of nodes) {
if (node.type === 'file' && node.fileIndex != null) {
order.push(node.fileIndex);
} else if (node.children) {
order.push(...getVisualFileOrder(node.children));
}
}
return order;
}
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;
}