feat: PR diff sidebar has 'marked viewed' checkbox & flat file view (#282)

This commit is contained in:
Maxwell
2026-03-03 16:40:28 -08:00
committed by GitHub
parent 4213bd08a0
commit 8211d7cf0c
2 changed files with 363 additions and 62 deletions
+334 -57
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useCallback, useMemo, memo } from "react";
import { useState, useCallback, useMemo, memo, useEffect, useRef } from "react";
import {
ChevronRight,
FilePlus2,
@@ -11,6 +11,8 @@ import {
Check,
Search,
X,
FolderTree,
List,
} from "lucide-react";
import { FileTypeIcon } from "@/components/shared/file-icon";
import { cn } from "@/lib/utils";
@@ -28,6 +30,8 @@ interface DiffFileTreeProps {
onSelectFile: (index: number) => void;
viewedFiles: Set<string>;
threadsByFile: Map<string, ReviewThread[]>;
onToggleViewed: (filename: string) => void;
onSetFilesViewed: (filenames: string[], viewed: boolean) => void;
}
function getFileStatusIcon(status: string) {
@@ -85,12 +89,18 @@ function buildSearchIndex(nodes: DiffTreeNode[]): SearchEntry[] {
return result;
}
type ViewMode = "tree" | "flat";
function DiffFileSearchBar({
searchIndex,
onSelectFile,
viewMode,
onToggleViewMode,
}: {
searchIndex: SearchEntry[];
onSelectFile: (index: number) => void;
viewMode: ViewMode;
onToggleViewMode: () => void;
}) {
const [inputValue, setInputValue] = useState("");
const [selectedIdx, setSelectedIdx] = useState(0);
@@ -149,31 +159,48 @@ function DiffFileSearchBar({
return (
<div className="shrink-0 p-2 relative">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground/50" />
<input
type="text"
placeholder="Filter files..."
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);
setSelectedIdx(0);
}}
onKeyDown={handleKeyDown}
className="w-full text-[11px] font-mono pl-7 pr-7 py-1.5 bg-transparent border border-border rounded focus:outline-none focus:ring-1 focus:ring-muted-foreground/30 placeholder:text-muted-foreground/50"
/>
{inputValue && (
<button
onClick={() => setInputValue("")}
className="absolute right-1.5 top-1/2 -translate-y-1/2 p-0.5 text-muted-foreground/50 hover:text-muted-foreground transition-colors"
>
<X className="w-3 h-3" />
</button>
)}
<div className="flex items-center gap-1.5">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground/50" />
<input
type="text"
placeholder="Filter files..."
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);
setSelectedIdx(0);
}}
onKeyDown={handleKeyDown}
className="w-full text-[11px] font-mono pl-7 pr-7 py-1.5 bg-transparent border border-border rounded focus:outline-none focus:ring-1 focus:ring-muted-foreground/30 placeholder:text-muted-foreground/50"
/>
{inputValue && (
<button
onClick={() => setInputValue("")}
className="absolute right-1.5 top-1/2 -translate-y-1/2 p-0.5 text-muted-foreground/50 hover:text-muted-foreground transition-colors"
>
<X className="w-3 h-3" />
</button>
)}
</div>
<button
onClick={onToggleViewMode}
className="p-1.5 rounded border border-border hover:bg-muted/50 transition-colors"
title={
viewMode === "tree"
? "Switch to flat view"
: "Switch to tree view"
}
>
{viewMode === "tree" ? (
<List className="w-3.5 h-3.5 text-muted-foreground" />
) : (
<FolderTree className="w-3.5 h-3.5 text-muted-foreground" />
)}
</button>
</div>
{showDropdown && (
<div className="absolute left-2 right-2 top-full mt-0.5 z-30 max-h-72 overflow-y-auto bg-background border border-border rounded-md shadow-lg">
<div className="absolute left-2 right-10 top-full mt-0.5 z-30 max-h-72 overflow-y-auto bg-background border border-border rounded-md shadow-lg">
{suggestions.length === 0 ? (
<p className="text-[11px] text-muted-foreground/50 font-mono px-3 py-2">
No files found
@@ -236,6 +263,21 @@ interface TreeNodeProps {
threadsByFile: Map<string, ReviewThread[]>;
expandedPaths: Set<string>;
onToggle: (path: string) => void;
onToggleViewed: (filename: string) => void;
onSetFilesViewed: (filenames: string[], viewed: boolean) => void;
}
function collectFilePaths(node: DiffTreeNode): string[] {
const paths: string[] = [];
function walk(n: DiffTreeNode) {
if (n.type === "file") {
paths.push(n.path);
} else if (n.children) {
for (const child of n.children) walk(child);
}
}
walk(node);
return paths;
}
const DiffTreeNode = memo(function DiffTreeNode({
@@ -247,17 +289,25 @@ const DiffTreeNode = memo(function DiffTreeNode({
threadsByFile,
expandedPaths,
onToggle,
onToggleViewed,
onSetFilesViewed,
}: TreeNodeProps) {
const isExpanded = expandedPaths.has(node.path);
const paddingLeft = depth * 16 + 8;
if (node.type === "dir") {
const filePaths = collectFilePaths(node);
const viewedCount = filePaths.filter((p) => viewedFiles.has(p)).length;
const allViewed = filePaths.length > 0 && viewedCount === filePaths.length;
const someViewed = viewedCount > 0 && viewedCount < filePaths.length;
return (
<div>
<button
<div
onClick={() => onToggle(node.path)}
className={cn(
"flex items-center gap-1.5 w-full text-left py-[3px] pr-2 hover:bg-muted/50 dark:hover:bg-white/[0.02] transition-colors group relative",
"flex items-center gap-1.5 w-full text-left py-[3px] pr-2 hover:bg-card transition-colors group cursor-pointer relative",
allViewed && "opacity-70",
)}
style={{ paddingLeft }}
>
@@ -284,16 +334,46 @@ const DiffTreeNode = memo(function DiffTreeNode({
{node.name}
</span>
{(node.additions ?? 0) > 0 && (
<span className="text-[9px] font-mono text-success tabular-nums shrink-0">
<span className="text-[9px] font-mono text-success tabular-nums shrink-0 group-hover:hidden">
+{node.additions}
</span>
)}
{(node.deletions ?? 0) > 0 && (
<span className="text-[9px] font-mono text-destructive tabular-nums shrink-0">
<span className="text-[9px] font-mono text-destructive tabular-nums shrink-0 group-hover:hidden">
-{node.deletions}
</span>
)}
</button>
<button
onClick={(e) => {
e.stopPropagation();
onSetFilesViewed(filePaths, !allViewed);
}}
className="hidden group-hover:flex items-center justify-end w-[34px] shrink-0"
title={
allViewed
? "Mark all as unviewed"
: "Mark all as viewed"
}
>
<span
className={cn(
"w-3.5 h-3.5 rounded border flex items-center justify-center transition-colors cursor-pointer",
allViewed
? "bg-success border-success"
: someViewed
? "border-success bg-success/30"
: "border-muted-foreground/40 hover:border-muted-foreground/60",
)}
>
{allViewed && (
<Check className="w-2.5 h-2.5 text-success-foreground" />
)}
{someViewed && !allViewed && (
<span className="w-1.5 h-1.5 bg-success rounded-sm" />
)}
</span>
</button>
</div>
<div
className={cn(
"grid transition-[grid-template-rows] duration-150 ease-out",
@@ -312,6 +392,8 @@ const DiffTreeNode = memo(function DiffTreeNode({
threadsByFile={threadsByFile}
expandedPaths={expandedPaths}
onToggle={onToggle}
onToggleViewed={onToggleViewed}
onSetFilesViewed={onSetFilesViewed}
/>
))}
</div>
@@ -326,14 +408,14 @@ const DiffTreeNode = memo(function DiffTreeNode({
const Icon = getFileStatusIcon(node.status ?? "modified");
return (
<button
onClick={() => node.fileIndex !== undefined && onSelectFile(node.fileIndex)}
<div
className={cn(
"flex items-center gap-1.5 w-full text-left py-[3px] pr-2 hover:bg-muted/50 dark:hover:bg-white/[0.02] transition-colors relative group/file cursor-pointer",
isActive && "bg-muted/70",
isViewed && "opacity-50",
"flex items-center gap-1.5 w-full text-left py-[3px] pr-2 hover:bg-card transition-colors relative group/file cursor-pointer",
isActive && "bg-accent!",
isViewed && "opacity-70",
)}
style={{ paddingLeft: paddingLeft + 15 }}
onClick={() => node.fileIndex !== undefined && onSelectFile(node.fileIndex)}
>
{Array.from({ length: depth }).map((_, i) => (
<span
@@ -371,13 +453,158 @@ const DiffTreeNode = memo(function DiffTreeNode({
title={`${fileThreads.length} review thread${fileThreads.length !== 1 ? "s" : ""}`}
/>
)}
<span className="text-[9px] font-mono text-success tabular-nums shrink-0">
<span className="text-[9px] font-mono text-success tabular-nums shrink-0 group-hover/file:hidden">
+{node.additions ?? 0}
</span>
<span className="text-[9px] font-mono text-destructive tabular-nums shrink-0">
<span className="text-[9px] font-mono text-destructive tabular-nums shrink-0 group-hover/file:hidden">
-{node.deletions ?? 0}
</span>
</button>
<button
onClick={(e) => {
e.stopPropagation();
onToggleViewed(node.path);
}}
className="hidden group-hover/file:flex items-center justify-end w-[34px] shrink-0"
title={isViewed ? "Mark as unviewed" : "Mark as viewed"}
>
<span
className={cn(
"w-3.5 h-3.5 rounded border flex items-center justify-center transition-colors cursor-pointer",
isViewed
? "bg-success border-success"
: "border-muted-foreground/40 hover:border-muted-foreground/60",
)}
>
{isViewed && (
<Check className="w-2.5 h-2.5 text-success-foreground" />
)}
</span>
</button>
</div>
);
});
function collectAllDirPaths(nodes: DiffTreeNode[]): string[] {
const paths: string[] = [];
function walk(list: DiffTreeNode[]) {
for (const n of list) {
if (n.type === "dir") {
paths.push(n.path);
if (n.children) walk(n.children);
}
}
}
walk(nodes);
return paths;
}
interface FlatFileItemProps {
file: DiffFile;
index: number;
isActive: boolean;
isViewed: boolean;
threads: ReviewThread[] | undefined;
onSelectFile: (index: number) => void;
onToggleViewed: (filename: string) => void;
}
const FlatFileItem = memo(function FlatFileItem({
file,
index,
isActive,
isViewed,
threads,
onSelectFile,
onToggleViewed,
}: FlatFileItemProps) {
const Icon = getFileStatusIcon(file.status);
const lastSlash = file.filename.lastIndexOf("/");
const dirPath = lastSlash > 0 ? file.filename.slice(0, lastSlash + 1) : "";
const fileName = lastSlash > 0 ? file.filename.slice(lastSlash + 1) : file.filename;
return (
<div
onClick={() => onSelectFile(index)}
className={cn(
"flex items-center gap-1.5 w-full text-left py-[3px] px-2 hover:bg-card transition-colors relative group/file cursor-pointer",
isActive && "bg-accent",
isViewed && "opacity-70",
)}
>
{isActive && (
<span className="absolute left-0 top-0 bottom-0 w-0.5 bg-foreground" />
)}
{isViewed ? (
<Check className="w-3 h-3 shrink-0 text-success" />
) : (
<Icon
className={cn(
"w-3 h-3 shrink-0",
getFileStatusColor(file.status),
)}
/>
)}
<span
className="font-mono flex items-baseline min-w-0 flex-1 group-hover/file:text-foreground"
title={file.filename}
>
{dirPath && (
<span
className={cn(
"text-[9px] truncate min-w-0 shrink",
isViewed
? "text-muted-foreground/40 line-through"
: "text-muted-foreground/50",
)}
>
{dirPath}
</span>
)}
<span
className={cn(
"text-[11px] shrink-0",
isViewed
? "text-muted-foreground/60 line-through"
: "text-foreground/80",
)}
>
{fileName}
</span>
</span>
{threads && threads.length > 0 && (
<span
className="w-1.5 h-1.5 rounded-full bg-warning/60 shrink-0"
title={`${threads.length} review thread${threads.length !== 1 ? "s" : ""}`}
/>
)}
<span className="text-[9px] font-mono text-success tabular-nums shrink-0 group-hover/file:hidden">
+{file.additions}
</span>
<span className="text-[9px] font-mono text-destructive tabular-nums shrink-0 group-hover/file:hidden">
-{file.deletions}
</span>
<button
onClick={(e) => {
e.stopPropagation();
onToggleViewed(file.filename);
}}
className="hidden group-hover/file:flex items-center justify-end w-[34px] shrink-0"
title={isViewed ? "Mark as unviewed" : "Mark as viewed"}
>
<span
className={cn(
"w-3.5 h-3.5 rounded border flex items-center justify-center transition-colors cursor-pointer",
isViewed
? "bg-success border-success"
: "border-muted-foreground/40 hover:border-muted-foreground/60",
)}
>
{isViewed && (
<Check className="w-2.5 h-2.5 text-success-foreground" />
)}
</span>
</button>
</div>
);
});
@@ -387,18 +614,24 @@ export function DiffFileTree({
onSelectFile,
viewedFiles,
threadsByFile,
onToggleViewed,
onSetFilesViewed,
}: DiffFileTreeProps) {
const tree = useMemo(() => buildDiffFileTree(files), [files]);
const searchIndex = useMemo(() => buildSearchIndex(tree), [tree]);
const currentFile = files[activeIndex];
const currentPath = currentFile?.filename ?? null;
const allDirPaths = useMemo(() => collectAllDirPaths(tree), [tree]);
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => {
if (!currentPath) return new Set<string>();
const ancestors = getAncestorPaths(currentPath);
return new Set(ancestors);
});
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set<string>());
const [viewMode, setViewMode] = useState<ViewMode>("tree");
const initializedRef = useRef(false);
useEffect(() => {
if (!initializedRef.current && allDirPaths.length > 0) {
initializedRef.current = true;
setExpandedPaths(new Set(allDirPaths));
}
}, [allDirPaths]);
const toggleExpand = useCallback((path: string) => {
setExpandedPaths((prev) => {
@@ -409,6 +642,10 @@ export function DiffFileTree({
});
}, []);
const toggleViewMode = useCallback(() => {
setViewMode((prev) => (prev === "tree" ? "flat" : "tree"));
}, []);
const handleSelectFile = useCallback(
(index: number) => {
const file = files[index];
@@ -430,21 +667,61 @@ export function DiffFileTree({
<DiffFileSearchBar
searchIndex={searchIndex}
onSelectFile={handleSelectFile}
viewMode={viewMode}
onToggleViewMode={toggleViewMode}
/>
<div className="flex-1 overflow-y-auto overflow-x-hidden py-1">
{tree.map((node) => (
<DiffTreeNode
key={node.path}
node={node}
depth={0}
activeIndex={activeIndex}
onSelectFile={handleSelectFile}
viewedFiles={viewedFiles}
threadsByFile={threadsByFile}
expandedPaths={expandedPaths}
onToggle={toggleExpand}
/>
))}
<div className="flex-1 overflow-y-auto overflow-x-hidden py-1 pr-2 pb-12">
<div key={viewMode} className="animate-in fade-in duration-150">
{viewMode === "tree"
? tree.map((node) => (
<DiffTreeNode
key={node.path}
node={node}
depth={0}
activeIndex={activeIndex}
onSelectFile={
handleSelectFile
}
viewedFiles={viewedFiles}
threadsByFile={
threadsByFile
}
expandedPaths={
expandedPaths
}
onToggle={toggleExpand}
onToggleViewed={
onToggleViewed
}
onSetFilesViewed={
onSetFilesViewed
}
/>
))
: files.map((file, index) => (
<FlatFileItem
key={file.filename}
file={file}
index={index}
isActive={
index ===
activeIndex
}
isViewed={viewedFiles.has(
file.filename,
)}
threads={threadsByFile.get(
file.filename,
)}
onSelectFile={
handleSelectFile
}
onToggleViewed={
onToggleViewed
}
/>
))}
</div>
</div>
</div>
);
+29 -5
View File
@@ -237,6 +237,17 @@ export function PRDiffViewer({
});
};
const setFilesViewed = (filenames: string[], viewed: boolean) => {
setViewedFiles((prev) => {
const next = new Set(prev);
for (const f of filenames) {
if (viewed) next.add(f);
else next.delete(f);
}
return next;
});
};
const viewedCount = viewedFiles.size;
// Group review comments by file
@@ -261,7 +272,7 @@ export function PRDiffViewer({
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const x = clientX - rect.left;
setSidebarWidth(Math.max(140, Math.min(400, x)));
setSidebarWidth(Math.max(140, Math.min(600, x)));
}, []);
return (
@@ -270,7 +281,7 @@ export function PRDiffViewer({
{!sidebarCollapsed && (
<>
<div
className="hidden lg:flex flex-col shrink-0 border-r border-border pr-3"
className="hidden lg:flex flex-col shrink-0 border-r border-border pr-2"
style={{
width: sidebarWidth,
transition: isDragging
@@ -364,8 +375,15 @@ export function PRDiffViewer({
</button>
</div>
</div>
{viewedCount > 0 && (
<div className="shrink-0 h-1 bg-border/60 mx-3 rounded-full overflow-hidden">
{
<div
className={cn(
"shrink-0 h-1 mx-3 rounded-full overflow-hidden transition-all duration-300",
viewedCount === 0
? "bg-border/20"
: "bg-border/60",
)}
>
<div
className="h-full bg-success/70 transition-all duration-300 rounded-full"
style={{
@@ -373,7 +391,7 @@ export function PRDiffViewer({
}}
/>
</div>
)}
}
{/* Sidebar content */}
<div className="flex-1 overflow-y-auto overscroll-contain min-h-0">
@@ -388,6 +406,12 @@ export function PRDiffViewer({
threadsByFile={
threadsByFile
}
onToggleViewed={
toggleViewed
}
onSetFilesViewed={
setFilesViewed
}
/>
) : sidebarMode === "commits" ? (
<SidebarCommits