mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
0fd9826c2c
* 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.
168 lines
4.3 KiB
TypeScript
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;
|
|
}
|