Files
Codewalkers/apps/web/src/components/review/FileCard.tsx
Lukas May 06b768e358 feat: Polish review tab — viewed tracking, file tree, syntax highlighting, better UX
- Add file "viewed" checkmarks with progress tracking (X/26 viewed in header + sidebar)
- Add directory-grouped file tree in sidebar with review progress bar
- Add sticky file headers that stay visible when scrolling through long diffs
- Add file change type badges (NEW/DELETED/RENAMED) with colored left borders
- Add syntax highlighting via shiki with lazy loading and progressive enhancement
- Add merge confirmation dropdown showing unresolved comments + viewed progress
- Make Approve & Merge button prominently green, Request Changes styled with error tokens
- Show full branch names instead of aggressively truncated text
- Add always-visible comment dots on lines with comments, subtle hover on others
- Improve hunk headers with two-tone @@ display and context function highlighting
- Add review progress bar to sidebar with file-level viewed state
2026-03-05 11:30:48 +01:00

170 lines
5.0 KiB
TypeScript

import { useState, useMemo } from "react";
import {
ChevronDown,
ChevronRight,
Plus,
Minus,
CheckCircle2,
Circle,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import type { FileDiff, FileChangeType, DiffLine, ReviewComment } from "./types";
import { HunkRows } from "./HunkRows";
import { useHighlightedFile } from "./use-syntax-highlight";
const changeTypeBadge: Record<
FileChangeType,
{ label: string; classes: string } | null
> = {
added: {
label: "NEW",
classes:
"bg-status-success-bg text-status-success-fg border-status-success-border",
},
deleted: {
label: "DELETED",
classes:
"bg-status-error-bg text-status-error-fg border-status-error-border",
},
renamed: {
label: "RENAMED",
classes:
"bg-status-active-bg text-status-active-fg border-status-active-border",
},
modified: null,
};
const leftBorderClass: Record<FileChangeType, string> = {
added: "border-l-2 border-l-status-success-fg",
deleted: "border-l-2 border-l-status-error-fg",
renamed: "border-l-2 border-l-status-active-fg",
modified: "border-l-2 border-l-primary/40",
};
interface FileCardProps {
file: FileDiff;
comments: ReviewComment[];
onAddComment: (
filePath: string,
lineNumber: number,
lineType: DiffLine["type"],
body: string,
) => void;
onResolveComment: (commentId: string) => void;
onUnresolveComment: (commentId: string) => void;
isViewed?: boolean;
onToggleViewed?: () => void;
}
export function FileCard({
file,
comments,
onAddComment,
onResolveComment,
onUnresolveComment,
isViewed = false,
onToggleViewed = () => {},
}: FileCardProps) {
const [expanded, setExpanded] = useState(true);
const commentCount = comments.length;
const badge = changeTypeBadge[file.changeType];
// Flatten all hunk lines for syntax highlighting
const allLines = useMemo(
() => file.hunks.flatMap((h) => h.lines),
[file.hunks],
);
const tokenMap = useHighlightedFile(file.newPath, allLines);
return (
<div className="rounded-lg border border-border overflow-hidden">
{/* File header — sticky so it stays visible when scrolling */}
<button
className={`sticky top-0 z-10 flex w-full items-center gap-2 px-3 py-2 bg-muted hover:bg-muted/90 text-left text-sm font-mono transition-colors ${leftBorderClass[file.changeType]}`}
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<span className="truncate flex-1 flex items-center gap-2">
{file.newPath}
{badge && (
<span
className={`inline-flex text-[9px] font-semibold uppercase px-1.5 py-0 rounded border leading-4 ${badge.classes}`}
>
{badge.label}
</span>
)}
</span>
<span className="flex items-center gap-2 shrink-0 text-xs">
{/* Viewed toggle */}
<span
role="checkbox"
aria-checked={isViewed}
tabIndex={0}
className="cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onToggleViewed();
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
e.preventDefault();
onToggleViewed();
}
}}
>
{isViewed ? (
<CheckCircle2 className="h-4 w-4 text-status-success-fg" />
) : (
<Circle className="h-4 w-4 text-muted-foreground" />
)}
</span>
{file.additions > 0 && (
<span className="flex items-center gap-0.5 text-diff-add-fg">
<Plus className="h-3 w-3" />
{file.additions}
</span>
)}
{file.deletions > 0 && (
<span className="flex items-center gap-0.5 text-diff-remove-fg">
<Minus className="h-3 w-3" />
{file.deletions}
</span>
)}
{commentCount > 0 && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
{commentCount}
</Badge>
)}
</span>
</button>
{/* Diff content */}
{expanded && (
<div className="overflow-x-auto">
<table className="w-full text-xs font-mono border-collapse">
<tbody>
{file.hunks.map((hunk, hi) => (
<HunkRows
key={hi}
hunk={hunk}
filePath={file.newPath}
comments={comments}
onAddComment={onAddComment}
onResolveComment={onResolveComment}
onUnresolveComment={onUnresolveComment}
tokenMap={tokenMap}
/>
))}
</tbody>
</table>
</div>
)}
</div>
);
}