add tree view (#906)
This commit is contained in:
@@ -50,6 +50,7 @@ interface ChangeRowProps {
|
||||
isReverting: boolean;
|
||||
stats?: { insertions: number; deletions: number };
|
||||
rowPaddingClassName?: string;
|
||||
indentPx?: number;
|
||||
}
|
||||
|
||||
export const ChangeRow = React.memo<ChangeRowProps>(function ChangeRow({
|
||||
@@ -61,6 +62,7 @@ export const ChangeRow = React.memo<ChangeRowProps>(function ChangeRow({
|
||||
isReverting,
|
||||
stats,
|
||||
rowPaddingClassName,
|
||||
indentPx = 0,
|
||||
}) {
|
||||
const descriptor = useMemo(() => describeChange(file), [file]);
|
||||
const indicatorLabel = descriptor.description;
|
||||
@@ -105,6 +107,7 @@ export const ChangeRow = React.memo<ChangeRowProps>(function ChangeRow({
|
||||
tabIndex={0}
|
||||
onClick={onViewDiff}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={indentPx > 0 ? { paddingLeft: `${indentPx}px` } : undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { RiCheckboxBlankLine, RiCheckboxLine } from '@remixicon/react';
|
||||
import { RiArrowDownSLine, RiArrowRightSLine, RiCheckboxBlankLine, RiCheckboxLine, RiSubtractLine } from '@remixicon/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -15,6 +15,7 @@ import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
|
||||
import { ChangeRow } from './ChangeRow';
|
||||
import type { GitStatus } from '@/lib/api/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
|
||||
interface ChangesSectionProps {
|
||||
changeEntries: GitStatus['files'];
|
||||
@@ -35,6 +36,137 @@ interface ChangesSectionProps {
|
||||
const CHANGE_LIST_VIRTUALIZE_THRESHOLD = 120;
|
||||
const CHANGE_ROW_ESTIMATE_PX = 34;
|
||||
|
||||
type ChangesTreeDirectoryNode = {
|
||||
id: string;
|
||||
path: string;
|
||||
name: string;
|
||||
children: Map<string, ChangesTreeDirectoryNode>;
|
||||
directFiles: GitStatus['files'];
|
||||
files: GitStatus['files'];
|
||||
};
|
||||
|
||||
type FlattenedTreeRow =
|
||||
| {
|
||||
key: string;
|
||||
kind: 'directory';
|
||||
depth: number;
|
||||
directory: ChangesTreeDirectoryNode;
|
||||
}
|
||||
| {
|
||||
key: string;
|
||||
kind: 'file';
|
||||
depth: number;
|
||||
file: GitStatus['files'][number];
|
||||
};
|
||||
|
||||
const TREE_INDENT_PX = 14;
|
||||
|
||||
const normalizePathForTree = (value: string): string => value.replace(/\\/g, '/').replace(/^\/+/, '').trim();
|
||||
|
||||
const createDirectoryNode = (path: string, name: string): ChangesTreeDirectoryNode => ({
|
||||
id: `dir:${path}`,
|
||||
path,
|
||||
name,
|
||||
children: new Map(),
|
||||
directFiles: [],
|
||||
files: [],
|
||||
});
|
||||
|
||||
const buildChangesTree = (entries: GitStatus['files']): ChangesTreeDirectoryNode => {
|
||||
const root = createDirectoryNode('', '');
|
||||
|
||||
for (const file of entries) {
|
||||
const normalized = normalizePathForTree(file.path);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const segments = normalized.split('/').filter(Boolean);
|
||||
const directorySegments = segments.slice(0, -1);
|
||||
let current = root;
|
||||
current.files.push(file);
|
||||
|
||||
if (directorySegments.length > 0) {
|
||||
let currentPath = '';
|
||||
for (const segment of directorySegments) {
|
||||
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
|
||||
const existing = current.children.get(segment);
|
||||
if (existing) {
|
||||
existing.files.push(file);
|
||||
current = existing;
|
||||
continue;
|
||||
}
|
||||
|
||||
const created = createDirectoryNode(currentPath, segment);
|
||||
created.files.push(file);
|
||||
current.children.set(segment, created);
|
||||
current = created;
|
||||
}
|
||||
}
|
||||
|
||||
current.directFiles.push(file);
|
||||
}
|
||||
|
||||
return root;
|
||||
};
|
||||
|
||||
const flattenChangesTree = (
|
||||
root: ChangesTreeDirectoryNode,
|
||||
expandedDirectories: Set<string>,
|
||||
): FlattenedTreeRow[] => {
|
||||
const rows: FlattenedTreeRow[] = [];
|
||||
|
||||
const walk = (node: ChangesTreeDirectoryNode, depth: number) => {
|
||||
const directories = Array.from(node.children.values()).sort((a, b) => a.path.localeCompare(b.path));
|
||||
for (const directory of directories) {
|
||||
rows.push({
|
||||
key: directory.id,
|
||||
kind: 'directory',
|
||||
depth,
|
||||
directory,
|
||||
});
|
||||
|
||||
if (expandedDirectories.has(directory.path)) {
|
||||
walk(directory, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const directFiles = [...node.directFiles].sort((a, b) => a.path.localeCompare(b.path));
|
||||
|
||||
for (const file of directFiles) {
|
||||
rows.push({
|
||||
key: `file:${normalizePathForTree(file.path)}`,
|
||||
kind: 'file',
|
||||
depth,
|
||||
file,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
walk(root, 0);
|
||||
return rows;
|
||||
};
|
||||
|
||||
const getDirectorySelectionState = (
|
||||
directory: ChangesTreeDirectoryNode,
|
||||
selectedPaths: Set<string>
|
||||
): 'none' | 'partial' | 'all' => {
|
||||
if (directory.files.length === 0) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
let selectedCount = 0;
|
||||
for (const file of directory.files) {
|
||||
if (selectedPaths.has(file.path)) {
|
||||
selectedCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedCount === 0) return 'none';
|
||||
if (selectedCount === directory.files.length) return 'all';
|
||||
return 'partial';
|
||||
};
|
||||
|
||||
export const ChangesSection: React.FC<ChangesSectionProps> = ({
|
||||
changeEntries,
|
||||
selectedPaths,
|
||||
@@ -51,16 +183,53 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
|
||||
onVisiblePathsChange,
|
||||
}) => {
|
||||
const scrollRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const gitChangesViewMode = useUIStore((state) => state.gitChangesViewMode);
|
||||
const isTreeView = gitChangesViewMode === 'tree';
|
||||
const selectedCount = selectedPaths.size;
|
||||
const totalCount = changeEntries.length;
|
||||
const [confirmRevertAllOpen, setConfirmRevertAllOpen] = React.useState(false);
|
||||
const shouldVirtualize = totalCount >= CHANGE_LIST_VIRTUALIZE_THRESHOLD;
|
||||
const treeRoot = React.useMemo(() => buildChangesTree(changeEntries), [changeEntries]);
|
||||
const [expandedDirectories, setExpandedDirectories] = React.useState<Set<string>>(new Set());
|
||||
|
||||
const topLevelDirectoryPaths = React.useMemo(
|
||||
() => Array.from(treeRoot.children.values()).map((directory) => directory.path),
|
||||
[treeRoot]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isTreeView) {
|
||||
return;
|
||||
}
|
||||
|
||||
setExpandedDirectories((previous) => {
|
||||
const next = new Set<string>();
|
||||
const validTopLevel = new Set(topLevelDirectoryPaths);
|
||||
|
||||
previous.forEach((path) => {
|
||||
if (path.includes('/')) {
|
||||
next.add(path);
|
||||
return;
|
||||
}
|
||||
if (validTopLevel.has(path)) {
|
||||
next.add(path);
|
||||
}
|
||||
});
|
||||
|
||||
topLevelDirectoryPaths.forEach((path) => next.add(path));
|
||||
return next;
|
||||
});
|
||||
}, [isTreeView, topLevelDirectoryPaths]);
|
||||
|
||||
const treeRows = React.useMemo(() => flattenChangesTree(treeRoot, expandedDirectories), [expandedDirectories, treeRoot]);
|
||||
const rowItems = React.useMemo(() => (isTreeView ? treeRows : changeEntries), [changeEntries, isTreeView, treeRows]);
|
||||
const rowCount = rowItems.length;
|
||||
const shouldVirtualize = rowCount >= CHANGE_LIST_VIRTUALIZE_THRESHOLD;
|
||||
const hasAnySelected = selectedCount > 0;
|
||||
const areAllSelected = totalCount > 0 && selectedCount === totalCount;
|
||||
const isPartiallySelected = hasAnySelected && !areAllSelected;
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: totalCount,
|
||||
count: rowCount,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => CHANGE_ROW_ESTIMATE_PX,
|
||||
overscan: 10,
|
||||
@@ -104,24 +273,161 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (totalCount === 0) {
|
||||
if (rowCount === 0) {
|
||||
onVisiblePathsChange([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const toVisiblePath = (item: GitStatus['files'][number] | FlattenedTreeRow): string | null => {
|
||||
if (!isTreeView) {
|
||||
return (item as GitStatus['files'][number]).path;
|
||||
}
|
||||
|
||||
const treeItem = item as FlattenedTreeRow;
|
||||
return treeItem.kind === 'file' ? treeItem.file.path : null;
|
||||
};
|
||||
|
||||
if (!shouldVirtualize) {
|
||||
onVisiblePathsChange(changeEntries.slice(0, Math.min(30, totalCount)).map((entry) => entry.path));
|
||||
onVisiblePathsChange(
|
||||
rowItems
|
||||
.slice(0, Math.min(30, rowCount))
|
||||
.map((item) => toVisiblePath(item))
|
||||
.filter((value): value is string => Boolean(value))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
onVisiblePathsChange(virtualRows.map((row) => changeEntries[row.index]?.path).filter((value): value is string => Boolean(value)));
|
||||
}, [changeEntries, onVisiblePathsChange, shouldVirtualize, totalCount, virtualRows]);
|
||||
onVisiblePathsChange(
|
||||
virtualRows
|
||||
.map((row) => rowItems[row.index])
|
||||
.map((item) => (item ? toVisiblePath(item) : null))
|
||||
.filter((value): value is string => Boolean(value))
|
||||
);
|
||||
}, [isTreeView, onVisiblePathsChange, rowCount, rowItems, shouldVirtualize, virtualRows]);
|
||||
|
||||
const containerClassName = 'flex flex-col flex-1 min-h-0';
|
||||
const headerClassName = 'flex items-center justify-between gap-2 px-0 py-3 border-b border-border/40';
|
||||
const scrollOuterClassName = `flex-1 min-h-0 pr-0 ${maxListHeightClassName ?? ''}`.trim();
|
||||
const rowPaddingClassName = 'pl-0 pr-2';
|
||||
|
||||
const toggleDirectoryExpanded = React.useCallback((path: string) => {
|
||||
setExpandedDirectories((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (next.has(path)) {
|
||||
next.delete(path);
|
||||
} else {
|
||||
next.add(path);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleDirectorySelection = React.useCallback((directory: ChangesTreeDirectoryNode) => {
|
||||
const state = getDirectorySelectionState(directory, selectedPaths);
|
||||
const shouldSelectAll = state !== 'all';
|
||||
|
||||
for (const file of directory.files) {
|
||||
const isSelected = selectedPaths.has(file.path);
|
||||
if (shouldSelectAll && !isSelected) {
|
||||
onToggleFile(file.path);
|
||||
} else if (!shouldSelectAll && isSelected) {
|
||||
onToggleFile(file.path);
|
||||
}
|
||||
}
|
||||
}, [onToggleFile, selectedPaths]);
|
||||
|
||||
const renderRow = React.useCallback((item: GitStatus['files'][number] | FlattenedTreeRow) => {
|
||||
if (!isTreeView) {
|
||||
const file = item as GitStatus['files'][number];
|
||||
return (
|
||||
<ChangeRow
|
||||
file={file}
|
||||
checked={selectedPaths.has(file.path)}
|
||||
stats={diffStats?.[file.path]}
|
||||
onToggle={() => onToggleFile(file.path)}
|
||||
onViewDiff={() => onViewDiff(file.path)}
|
||||
onRevert={() => onRevertFile(file.path)}
|
||||
isReverting={revertingPaths.has(file.path) || isRevertingAll}
|
||||
rowPaddingClassName={rowPaddingClassName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const row = item as FlattenedTreeRow;
|
||||
|
||||
if (row.kind === 'file') {
|
||||
const file = row.file;
|
||||
return (
|
||||
<ChangeRow
|
||||
file={file}
|
||||
checked={selectedPaths.has(file.path)}
|
||||
stats={diffStats?.[file.path]}
|
||||
onToggle={() => onToggleFile(file.path)}
|
||||
onViewDiff={() => onViewDiff(file.path)}
|
||||
onRevert={() => onRevertFile(file.path)}
|
||||
isReverting={revertingPaths.has(file.path) || isRevertingAll}
|
||||
rowPaddingClassName={rowPaddingClassName}
|
||||
indentPx={row.depth * TREE_INDENT_PX}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const directory = row.directory;
|
||||
const isExpanded = expandedDirectories.has(directory.path);
|
||||
const selectionState = getDirectorySelectionState(directory, selectedPaths);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('group flex items-center gap-2 py-1.5 hover:bg-sidebar/40', rowPaddingClassName)}
|
||||
style={{ paddingLeft: `${row.depth * TREE_INDENT_PX}px` }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleDirectoryExpanded(directory.path)}
|
||||
className="flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={isExpanded ? `Collapse ${directory.path}` : `Expand ${directory.path}`}
|
||||
>
|
||||
{isExpanded ? <RiArrowDownSLine className="size-4" /> : <RiArrowRightSLine className="size-4" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={selectionState === 'partial' ? 'mixed' : selectionState === 'all'}
|
||||
aria-label={`Toggle selection for directory ${directory.path}`}
|
||||
onClick={() => toggleDirectorySelection(directory)}
|
||||
className="flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
{selectionState === 'all' ? (
|
||||
<RiCheckboxLine className="size-4 text-primary" />
|
||||
) : selectionState === 'partial' ? (
|
||||
<RiSubtractLine className="size-4 text-primary" />
|
||||
) : (
|
||||
<RiCheckboxBlankLine className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<span className="min-w-0 truncate typography-ui-label text-foreground" title={directory.path}>
|
||||
{directory.name}
|
||||
</span>
|
||||
<span className="ml-auto shrink-0 typography-micro text-muted-foreground">{directory.files.length}</span>
|
||||
</div>
|
||||
);
|
||||
}, [
|
||||
diffStats,
|
||||
expandedDirectories,
|
||||
isRevertingAll,
|
||||
isTreeView,
|
||||
onRevertFile,
|
||||
onToggleFile,
|
||||
onViewDiff,
|
||||
revertingPaths,
|
||||
rowPaddingClassName,
|
||||
selectedPaths,
|
||||
toggleDirectoryExpanded,
|
||||
toggleDirectorySelection,
|
||||
]);
|
||||
|
||||
const handleConfirmRevertAll = React.useCallback(async () => {
|
||||
if (!onRevertAll || isRevertingAll || changeEntries.length === 0) {
|
||||
return;
|
||||
@@ -184,14 +490,18 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
|
||||
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
|
||||
>
|
||||
{virtualRows.map((row) => {
|
||||
const file = changeEntries[row.index];
|
||||
if (!file) {
|
||||
const item = rowItems[row.index];
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = isTreeView
|
||||
? (item as FlattenedTreeRow).key
|
||||
: `file:${(item as GitStatus['files'][number]).path}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={file.path}
|
||||
key={key}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
data-index={row.index}
|
||||
className={cn(
|
||||
@@ -200,40 +510,22 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
|
||||
)}
|
||||
style={{ transform: `translateY(${row.start}px)` }}
|
||||
>
|
||||
<ChangeRow
|
||||
file={file}
|
||||
checked={selectedPaths.has(file.path)}
|
||||
stats={diffStats?.[file.path]}
|
||||
onToggle={() => onToggleFile(file.path)}
|
||||
onViewDiff={() => onViewDiff(file.path)}
|
||||
onRevert={() => onRevertFile(file.path)}
|
||||
isReverting={revertingPaths.has(file.path) || isRevertingAll}
|
||||
rowPaddingClassName={rowPaddingClassName}
|
||||
/>
|
||||
{renderRow(item)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div role="list" aria-label="Changed files">
|
||||
{changeEntries.map((file, index) => (
|
||||
{rowItems.map((item, index) => (
|
||||
<div
|
||||
key={file.path}
|
||||
key={isTreeView ? (item as FlattenedTreeRow).key : `file:${(item as GitStatus['files'][number]).path}`}
|
||||
className={cn(
|
||||
'relative',
|
||||
index > 0 && 'before:pointer-events-none before:absolute before:left-0 before:right-2 before:top-0 before:border-t before:border-border/60'
|
||||
)}
|
||||
>
|
||||
<ChangeRow
|
||||
file={file}
|
||||
checked={selectedPaths.has(file.path)}
|
||||
stats={diffStats?.[file.path]}
|
||||
onToggle={() => onToggleFile(file.path)}
|
||||
onViewDiff={() => onViewDiff(file.path)}
|
||||
onRevert={() => onRevertFile(file.path)}
|
||||
isReverting={revertingPaths.has(file.path) || isRevertingAll}
|
||||
rowPaddingClassName={rowPaddingClassName}
|
||||
/>
|
||||
{renderRow(item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user