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
This commit is contained in:
Lukas May
2026-03-05 11:30:48 +01:00
parent 173c7f7916
commit 06b768e358
12 changed files with 1207 additions and 103 deletions

View File

@@ -12,6 +12,9 @@ interface DiffViewerProps {
) => void;
onResolveComment: (commentId: string) => void;
onUnresolveComment: (commentId: string) => void;
viewedFiles?: Set<string>;
onToggleViewed?: (filePath: string) => void;
onRegisterRef?: (filePath: string, el: HTMLDivElement | null) => void;
}
export function DiffViewer({
@@ -20,18 +23,24 @@ export function DiffViewer({
onAddComment,
onResolveComment,
onUnresolveComment,
viewedFiles,
onToggleViewed,
onRegisterRef,
}: DiffViewerProps) {
return (
<div className="space-y-4">
{files.map((file) => (
<FileCard
key={file.newPath}
file={file}
comments={comments.filter((c) => c.filePath === file.newPath)}
onAddComment={onAddComment}
onResolveComment={onResolveComment}
onUnresolveComment={onUnresolveComment}
/>
<div key={file.newPath} ref={(el) => onRegisterRef?.(file.newPath, el)}>
<FileCard
file={file}
comments={comments.filter((c) => c.filePath === file.newPath)}
onAddComment={onAddComment}
onResolveComment={onResolveComment}
onUnresolveComment={onUnresolveComment}
isViewed={viewedFiles?.has(file.newPath) ?? false}
onToggleViewed={() => onToggleViewed?.(file.newPath)}
/>
</div>
))}
</div>
);

View File

@@ -1,8 +1,45 @@
import { useState } from "react";
import { ChevronDown, ChevronRight, Plus, Minus } from "lucide-react";
import { useState, useMemo } from "react";
import {
ChevronDown,
ChevronRight,
Plus,
Minus,
CheckCircle2,
Circle,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import type { FileDiff, DiffLine, ReviewComment } from "./types";
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;
@@ -15,6 +52,8 @@ interface FileCardProps {
) => void;
onResolveComment: (commentId: string) => void;
onUnresolveComment: (commentId: string) => void;
isViewed?: boolean;
onToggleViewed?: () => void;
}
export function FileCard({
@@ -23,15 +62,25 @@ export function FileCard({
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 */}
{/* File header — sticky so it stays visible when scrolling */}
<button
className="flex w-full items-center gap-2 px-3 py-2 bg-muted/50 hover:bg-muted text-left text-sm font-mono transition-colors"
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 ? (
@@ -39,8 +88,41 @@ export function FileCard({
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<span className="truncate flex-1">{file.newPath}</span>
<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" />
@@ -75,6 +157,7 @@ export function FileCard({
onAddComment={onAddComment}
onResolveComment={onResolveComment}
onUnresolveComment={onUnresolveComment}
tokenMap={tokenMap}
/>
))}
</tbody>

View File

@@ -1,6 +1,7 @@
import { useState, useCallback } from "react";
import type { DiffLine, ReviewComment } from "./types";
import { LineWithComments } from "./LineWithComments";
import type { LineTokenMap } from "./use-syntax-highlight";
interface HunkRowsProps {
hunk: { header: string; lines: DiffLine[] };
@@ -14,6 +15,7 @@ interface HunkRowsProps {
) => void;
onResolveComment: (commentId: string) => void;
onUnresolveComment: (commentId: string) => void;
tokenMap?: LineTokenMap | null;
}
export function HunkRows({
@@ -23,6 +25,7 @@ export function HunkRows({
onAddComment,
onResolveComment,
onUnresolveComment,
tokenMap,
}: HunkRowsProps) {
const [commentingLine, setCommentingLine] = useState<{
lineNumber: number;
@@ -49,9 +52,26 @@ export function HunkRows({
<tr>
<td
colSpan={3}
className="px-3 py-1 text-muted-foreground bg-diff-hunk-bg text-[11px] select-none"
className="px-3 py-1 text-muted-foreground bg-diff-hunk-bg text-[11px] select-none border-l-2 border-l-primary/20"
>
{hunk.header}
{(() => {
const match = hunk.header.match(/^(@@[^@]+@@)(.*)$/);
if (match) {
const prefix = match[1];
const context = match[2].trim();
return (
<>
<span className="text-muted-foreground/60">{prefix}</span>
{context && (
<span className="ml-1 text-muted-foreground/80 font-medium">
{context}
</span>
)}
</>
);
}
return hunk.header;
})()}
</td>
</tr>
@@ -78,6 +98,11 @@ export function HunkRows({
onSubmitComment={handleSubmitComment}
onResolveComment={onResolveComment}
onUnresolveComment={onUnresolveComment}
tokens={
line.newLineNumber !== null
? tokenMap?.get(line.newLineNumber) ?? undefined
: undefined
}
/>
);
})}

View File

@@ -3,6 +3,7 @@ import { MessageSquarePlus } from "lucide-react";
import type { DiffLine, ReviewComment } from "./types";
import { CommentThread } from "./CommentThread";
import { CommentForm } from "./CommentForm";
import type { TokenizedLine } from "./use-syntax-highlight";
interface LineWithCommentsProps {
line: DiffLine;
@@ -14,11 +15,13 @@ interface LineWithCommentsProps {
onSubmitComment: (body: string) => void;
onResolveComment: (commentId: string) => void;
onUnresolveComment: (commentId: string) => void;
/** Syntax-highlighted tokens for this line (if available) */
tokens?: TokenizedLine;
}
export function LineWithComments({
line,
lineKey,
lineKey: _lineKey,
lineComments,
isCommenting,
onStartComment,
@@ -26,6 +29,7 @@ export function LineWithComments({
onSubmitComment,
onResolveComment,
onUnresolveComment,
tokens,
}: LineWithCommentsProps) {
const formRef = useRef<HTMLTextAreaElement>(null);
@@ -62,7 +66,7 @@ export function LineWithComments({
return (
<>
<tr
className={`group ${bgClass} hover:brightness-95 dark:hover:brightness-110`}
className={`group ${bgClass} hover:ring-1 hover:ring-inset hover:ring-primary/10`}
>
{/* Line numbers */}
<td
@@ -80,24 +84,48 @@ export function LineWithComments({
{/* Comment button gutter */}
<td className={`w-6 min-w-6 ${gutterBgClass} align-top`}>
<button
className="opacity-0 group-hover:opacity-100 transition-opacity p-0.5 hover:text-primary"
onClick={onStartComment}
title="Add comment"
>
<MessageSquarePlus className="h-3.5 w-3.5" />
</button>
{lineComments.length > 0 ? (
<div className="relative flex items-center justify-center h-5">
<button
className="flex items-center justify-center group/comment"
onClick={onStartComment}
title={`${lineComments.length} comment${lineComments.length > 1 ? "s" : ""} — click to reply`}
>
<span className="h-2 w-2 rounded-full bg-primary group-hover/comment:hidden" />
<MessageSquarePlus className="h-3.5 w-3.5 text-primary hidden group-hover/comment:block" />
{lineComments.length > 1 && (
<span className="absolute -top-0.5 -right-0.5 text-[8px] text-primary font-bold leading-none">
{lineComments.length}
</span>
)}
</button>
</div>
) : (
<button
className="opacity-0 group-hover:opacity-50 transition-opacity p-0.5 hover:!opacity-100 hover:text-primary text-muted-foreground"
onClick={onStartComment}
title="Add comment"
>
<MessageSquarePlus className="h-3.5 w-3.5" />
</button>
)}
</td>
{/* Code content */}
<td className="pl-1 pr-3 align-top">
<pre
className={`leading-5 whitespace-pre-wrap break-all ${textColorClass}`}
className={`leading-5 whitespace-pre-wrap break-all ${tokens ? "" : textColorClass}`}
>
<span className="select-none text-muted-foreground/60">
{prefix}
</span>
{line.content}
{tokens
? tokens.map((token, i) => (
<span key={i} style={{ color: token.color }}>
{token.content}
</span>
))
: line.content}
</pre>
</td>
</tr>

View File

@@ -1,3 +1,4 @@
import { useState, useRef, useEffect } from "react";
import {
Check,
X,
@@ -11,6 +12,9 @@ import {
CircleDot,
RotateCcw,
ArrowRight,
Eye,
AlertCircle,
GitMerge,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -43,6 +47,8 @@ interface ReviewHeaderProps {
onApprove: () => void;
onRequestChanges: () => void;
preview: PreviewState | null;
viewedCount?: number;
totalCount?: number;
}
export function ReviewHeader({
@@ -58,9 +64,31 @@ export function ReviewHeader({
onApprove,
onRequestChanges,
preview,
viewedCount,
totalCount,
}: ReviewHeaderProps) {
const totalAdditions = files.reduce((s, f) => s + f.additions, 0);
const totalDeletions = files.reduce((s, f) => s + f.deletions, 0);
const [showConfirmation, setShowConfirmation] = useState(false);
const confirmRef = useRef<HTMLDivElement>(null);
// Click-outside handler to dismiss confirmation
useEffect(() => {
if (!showConfirmation) return;
function handleClickOutside(e: MouseEvent) {
if (
confirmRef.current &&
!confirmRef.current.contains(e.target as Node)
) {
setShowConfirmation(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showConfirmation]);
const viewed = viewedCount ?? 0;
const total = totalCount ?? 0;
return (
<div className="border-b border-border bg-card/80 backdrop-blur-sm">
@@ -108,14 +136,14 @@ export function ReviewHeader({
</h2>
{sourceBranch && (
<div className="flex items-center gap-1 text-[11px] text-muted-foreground font-mono shrink-0">
<div className="flex items-center gap-1 text-[11px] text-muted-foreground font-mono min-w-0">
<GitBranch className="h-3 w-3 shrink-0" />
<span className="truncate max-w-[140px]" title={sourceBranch}>
{truncateBranch(sourceBranch)}
<span className="truncate" title={sourceBranch}>
{sourceBranch}
</span>
<ArrowRight className="h-2.5 w-2.5 shrink-0 text-muted-foreground/50" />
<span className="truncate max-w-[140px]" title={targetBranch}>
{truncateBranch(targetBranch)}
<span className="truncate" title={targetBranch}>
{targetBranch}
</span>
</div>
)}
@@ -136,8 +164,18 @@ export function ReviewHeader({
</div>
</div>
{/* Center: review progress */}
{total > 0 && (
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground shrink-0">
<Eye className="h-3 w-3" />
<span>
{viewed}/{total} viewed
</span>
</div>
)}
{/* Right: preview + actions */}
<div className="flex items-center gap-2 shrink-0">
<div className="flex items-center gap-3 shrink-0">
{/* Preview controls */}
{preview && <PreviewControls preview={preview} />}
@@ -148,22 +186,82 @@ export function ReviewHeader({
variant="outline"
size="sm"
onClick={onRequestChanges}
className="h-7 text-xs"
className="h-8 text-xs px-3 border-status-error-border/50 text-status-error-fg hover:bg-status-error-bg/50 hover:border-status-error-border"
>
<X className="h-3 w-3" />
Request Changes
</Button>
<Button
size="sm"
onClick={onApprove}
disabled={unresolvedCount > 0}
className="h-7 text-xs"
>
<Check className="h-3 w-3" />
{unresolvedCount > 0
? `${unresolvedCount} unresolved`
: "Approve & Merge"}
</Button>
<div className="relative" ref={confirmRef}>
<Button
size="sm"
onClick={() => {
if (unresolvedCount > 0) return;
setShowConfirmation(true);
}}
disabled={unresolvedCount > 0}
className={
unresolvedCount > 0
? "h-9 px-5 text-sm font-semibold"
: "bg-status-success-fg text-white hover:opacity-90 h-9 px-5 text-sm font-semibold shadow-sm"
}
>
{unresolvedCount > 0 ? (
<>
<AlertCircle className="h-3.5 w-3.5" />
{unresolvedCount} unresolved
</>
) : (
<>
<GitMerge className="h-3.5 w-3.5" />
Approve & Merge
</>
)}
</Button>
{/* Merge confirmation dropdown */}
{showConfirmation && (
<div className="absolute right-0 top-full mt-1 z-20 w-64 rounded-lg border border-border bg-card shadow-lg p-4">
<p className="text-sm font-semibold mb-3">
Ready to merge?
</p>
<div className="space-y-1.5 mb-4">
<div className="flex items-center gap-2 text-xs">
<Check className="h-3.5 w-3.5 text-status-success-fg" />
<span className="text-muted-foreground">
0 unresolved comments
</span>
</div>
<div className="flex items-center gap-2 text-xs">
<Eye className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">
{viewed}/{total} files viewed
</span>
</div>
</div>
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setShowConfirmation(false)}
className="h-8 text-xs"
>
Cancel
</Button>
<Button
size="sm"
onClick={() => {
setShowConfirmation(false);
onApprove();
}}
className="bg-status-success-fg text-white hover:opacity-90 h-8 px-4 text-xs font-semibold shadow-sm"
>
<GitMerge className="h-3.5 w-3.5" />
Merge Now
</Button>
</div>
</div>
)}
</div>
</>
)}
{status === "approved" && (
@@ -247,9 +345,3 @@ function PreviewControls({ preview }: { preview: PreviewState }) {
</Button>
);
}
function truncateBranch(branch: string): string {
const parts = branch.split("/");
if (parts.length <= 2) return branch;
return parts.slice(-2).join("/");
}

View File

@@ -1,7 +1,8 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import {
MessageSquare,
FileCode,
FolderOpen,
Plus,
Minus,
Circle,
@@ -21,6 +22,7 @@ interface ReviewSidebarProps {
activeFiles: FileDiff[];
commits: CommitInfo[];
onSelectCommit: (hash: string | null) => void;
viewedFiles?: Set<string>;
}
export function ReviewSidebar({
@@ -31,6 +33,7 @@ export function ReviewSidebar({
activeFiles,
commits,
onSelectCommit,
viewedFiles = new Set(),
}: ReviewSidebarProps) {
const [view, setView] = useState<SidebarView>("files");
@@ -45,6 +48,7 @@ export function ReviewSidebar({
onFileClick={onFileClick}
selectedCommit={selectedCommit}
activeFiles={activeFiles}
viewedFiles={viewedFiles}
/>
) : (
<CommitsView
@@ -115,25 +119,96 @@ function IconTab({
/* ─── Files View ───────────────────────────────────────── */
interface DirectoryGroup {
directory: string;
files: FileDiff[];
}
function groupFilesByDirectory(files: FileDiff[]): DirectoryGroup[] {
const groups = new Map<string, FileDiff[]>();
for (const file of files) {
const lastSlash = file.newPath.lastIndexOf("/");
const directory = lastSlash >= 0 ? file.newPath.slice(0, lastSlash + 1) : "";
const existing = groups.get(directory);
if (existing) {
existing.push(file);
} else {
groups.set(directory, [file]);
}
}
// Sort directories alphabetically, sort files within each directory
const sorted = Array.from(groups.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([directory, dirFiles]) => ({
directory,
files: dirFiles.sort((a, b) => {
const aName = a.newPath.slice(a.newPath.lastIndexOf("/") + 1);
const bName = b.newPath.slice(b.newPath.lastIndexOf("/") + 1);
return aName.localeCompare(bName);
}),
}));
return sorted;
}
function getFileName(path: string): string {
const lastSlash = path.lastIndexOf("/");
return lastSlash >= 0 ? path.slice(lastSlash + 1) : path;
}
const changeTypeDotColor: Record<string, string> = {
added: "bg-status-success-fg",
deleted: "bg-status-error-fg",
renamed: "bg-status-active-fg",
};
function FilesView({
files,
comments,
onFileClick,
selectedCommit,
activeFiles,
viewedFiles,
}: {
files: FileDiff[];
comments: ReviewComment[];
onFileClick: (filePath: string) => void;
selectedCommit: string | null;
activeFiles: FileDiff[];
viewedFiles: Set<string>;
}) {
const unresolvedCount = comments.filter((c) => !c.resolved).length;
const resolvedCount = comments.filter((c) => c.resolved).length;
const activeFilePaths = new Set(activeFiles.map((f) => f.newPath));
const directoryGroups = useMemo(() => groupFilesByDirectory(files), [files]);
const viewedCount = files.filter((f) => viewedFiles.has(f.newPath)).length;
const totalCount = files.length;
const progressPercent = totalCount > 0 ? (viewedCount / totalCount) * 100 : 0;
return (
<div className="space-y-4">
{/* Review progress */}
{totalCount > 0 && (
<div className="space-y-1.5">
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
Review Progress
</h4>
<div className="h-1 rounded-full bg-muted w-full">
<div
className="h-full rounded-full bg-status-success-fg transition-all duration-300"
style={{ width: `${progressPercent}%` }}
/>
</div>
<span className="text-[10px] text-muted-foreground">
{viewedCount}/{totalCount} files viewed
</span>
</div>
)}
{/* Comment summary */}
{comments.length > 0 && (
<div className="space-y-1.5">
@@ -161,8 +236,8 @@ function FilesView({
</div>
)}
{/* File list */}
<div className="space-y-0.5">
{/* Directory-grouped file tree */}
<div>
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
Files
{selectedCommit && (
@@ -171,46 +246,74 @@ function FilesView({
</span>
)}
</h4>
{files.map((file) => {
const fileCommentCount = comments.filter(
(c) => c.filePath === file.newPath,
).length;
const isInView = activeFilePaths.has(file.newPath);
const dimmed = selectedCommit && !isInView;
{directoryGroups.map((group) => (
<div key={group.directory}>
{/* Directory header */}
{group.directory && (
<div className="text-[10px] font-mono text-muted-foreground/70 mt-2 first:mt-0 px-2 py-0.5 flex items-center gap-1">
<FolderOpen className="h-3 w-3 shrink-0" />
<span className="truncate">{group.directory}</span>
</div>
)}
{/* Files in directory */}
<div className="space-y-0.5">
{group.files.map((file) => {
const fileCommentCount = comments.filter(
(c) => c.filePath === file.newPath,
).length;
const isInView = activeFilePaths.has(file.newPath);
const dimmed = selectedCommit && !isInView;
const isViewed = viewedFiles.has(file.newPath);
const dotColor = changeTypeDotColor[file.changeType];
return (
<button
key={file.newPath}
className={`
flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-[11px]
hover:bg-accent/50 transition-colors group
${dimmed ? "opacity-35" : ""}
`}
onClick={() => onFileClick(file.newPath)}
>
<FileCode className="h-3 w-3 text-muted-foreground shrink-0" />
<span className="truncate flex-1 font-mono">
{formatFilePath(file.newPath)}
</span>
<span className="flex items-center gap-1 shrink-0">
{fileCommentCount > 0 && (
<span className="flex items-center gap-0.5 text-muted-foreground">
<MessageSquare className="h-2.5 w-2.5" />
{fileCommentCount}
</span>
)}
<span className="text-diff-add-fg text-[10px]">
<Plus className="h-2.5 w-2.5 inline" />
{file.additions}
</span>
<span className="text-diff-remove-fg text-[10px]">
<Minus className="h-2.5 w-2.5 inline" />
{file.deletions}
</span>
</span>
</button>
);
})}
return (
<button
key={file.newPath}
className={`
flex w-full items-center gap-1.5 rounded py-1 text-left text-[11px]
hover:bg-accent/50 transition-colors group
${group.directory ? "pl-4 pr-2" : "px-2"}
${dimmed ? "opacity-35" : ""}
`}
onClick={() => onFileClick(file.newPath)}
>
{isViewed ? (
<CheckCircle2 className="h-3 w-3 text-status-success-fg shrink-0" />
) : (
<FileCode className="h-3 w-3 text-muted-foreground shrink-0" />
)}
{dotColor && (
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${dotColor}`} />
)}
<span className="truncate flex-1 font-mono">
{getFileName(file.newPath)}
</span>
<span className="flex items-center gap-1 shrink-0">
{fileCommentCount > 0 && (
<span className="flex items-center gap-0.5 text-muted-foreground">
<MessageSquare className="h-2.5 w-2.5" />
{fileCommentCount}
</span>
)}
{file.additions > 0 && (
<span className="text-diff-add-fg text-[10px]">
<Plus className="h-2.5 w-2.5 inline" />
{file.additions}
</span>
)}
{file.deletions > 0 && (
<span className="text-diff-remove-fg text-[10px]">
<Minus className="h-2.5 w-2.5 inline" />
{file.deletions}
</span>
)}
</span>
</button>
);
})}
</div>
</div>
))}
</div>
</div>
);
@@ -304,12 +407,6 @@ function CommitsView({
/* ─── Helpers ──────────────────────────────────────────── */
function formatFilePath(path: string): string {
const parts = path.split("/");
if (parts.length <= 2) return path;
return parts.slice(-2).join("/");
}
function truncateMessage(msg: string): string {
const firstLine = msg.split("\n")[0];
return firstLine.length > 50 ? firstLine.slice(0, 47) + "..." : firstLine;

View File

@@ -15,8 +15,29 @@ interface ReviewTabProps {
export function ReviewTab({ initiativeId }: ReviewTabProps) {
const [status, setStatus] = useState<ReviewStatus>("pending");
const [selectedCommit, setSelectedCommit] = useState<string | null>(null);
const [viewedFiles, setViewedFiles] = useState<Set<string>>(new Set());
const fileRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const toggleViewed = useCallback((filePath: string) => {
setViewedFiles(prev => {
const next = new Set(prev);
if (next.has(filePath)) {
next.delete(filePath);
} else {
next.add(filePath);
}
return next;
});
}, []);
const registerFileRef = useCallback((filePath: string, el: HTMLDivElement | null) => {
if (el) {
fileRefs.current.set(filePath, el);
} else {
fileRefs.current.delete(filePath);
}
}, []);
// Fetch phases for this initiative
const phasesQuery = trpc.listPhases.useQuery({ initiativeId });
const pendingReviewPhases = useMemo(
@@ -129,7 +150,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
lineType: c.lineType as "added" | "removed" | "context",
body: c.body,
author: c.author,
createdAt: c.createdAt instanceof Date ? c.createdAt.toISOString() : String(c.createdAt),
createdAt: typeof c.createdAt === 'string' ? c.createdAt : String(c.createdAt),
resolved: c.resolved,
}));
}, [commentsQuery.data]);
@@ -222,6 +243,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
setSelectedPhaseId(id);
setSelectedCommit(null);
setStatus("pending");
setViewedFiles(new Set());
}, []);
const unresolvedCount = comments.filter((c) => !c.resolved).length;
@@ -261,6 +283,8 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
onApprove={handleApprove}
onRequestChanges={handleRequestChanges}
preview={previewState}
viewedCount={viewedFiles.size}
totalCount={allFiles.length}
/>
{/* Main content area — sidebar always rendered to preserve state */}
@@ -285,6 +309,9 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
onAddComment={handleAddComment}
onResolveComment={handleResolveComment}
onUnresolveComment={handleUnresolveComment}
viewedFiles={viewedFiles}
onToggleViewed={toggleViewed}
onRegisterRef={registerFileRef}
/>
)}
</div>
@@ -300,6 +327,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
activeFiles={files}
commits={commits}
onSelectCommit={setSelectedCommit}
viewedFiles={viewedFiles}
/>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import type { FileDiff, DiffHunk, DiffLine } from "./types";
import type { FileDiff, FileChangeType, DiffHunk, DiffLine } from "./types";
/**
* Parse a unified diff string into structured FileDiff objects.
@@ -20,9 +20,13 @@ export function parseUnifiedDiff(raw: string): FileDiff[] {
let additions = 0;
let deletions = 0;
// Scan header lines (between "diff --git" and first "@@") for /dev/null markers
let hasOldDevNull = false;
let hasNewDevNull = false;
let i = 1;
// Skip to first hunk header
while (i < lines.length && !lines[i].startsWith("@@")) {
if (lines[i].startsWith("--- /dev/null")) hasOldDevNull = true;
if (lines[i].startsWith("+++ /dev/null")) hasNewDevNull = true;
i++;
}
@@ -86,7 +90,19 @@ export function parseUnifiedDiff(raw: string): FileDiff[] {
hunks.push({ header, oldStart, oldCount, newStart, newCount, lines: hunkLines });
}
files.push({ oldPath, newPath, hunks, additions, deletions });
// Derive changeType from header markers and path comparison
let changeType: FileChangeType;
if (hasOldDevNull) {
changeType = "added";
} else if (hasNewDevNull) {
changeType = "deleted";
} else if (oldPath !== newPath) {
changeType = "renamed";
} else {
changeType = "modified";
}
files.push({ oldPath, newPath, hunks, additions, deletions, changeType });
}
return files;

View File

@@ -14,12 +14,15 @@ export interface DiffLine {
newLineNumber: number | null;
}
export type FileChangeType = 'added' | 'modified' | 'deleted' | 'renamed';
export interface FileDiff {
oldPath: string;
newPath: string;
hunks: DiffHunk[];
additions: number;
deletions: number;
changeType: FileChangeType;
}
export interface ReviewComment {

View File

@@ -0,0 +1,163 @@
import { useState, useEffect, useMemo } from "react";
import type { ThemedToken } from "shiki";
/* ── Lazy singleton highlighter ─────────────────────────── */
let highlighterPromise: Promise<Awaited<
ReturnType<typeof import("shiki")["createHighlighter"]>
> | null> | null = null;
const LANGS = [
"typescript",
"javascript",
"tsx",
"jsx",
"json",
"css",
"html",
"yaml",
"markdown",
"bash",
"python",
"go",
"rust",
"sql",
"toml",
"xml",
] as const;
function getHighlighter() {
if (!highlighterPromise) {
highlighterPromise = import("shiki")
.then(({ createHighlighter }) =>
createHighlighter({
themes: ["github-dark-default"],
langs: [...LANGS],
}),
)
.catch(() => null);
}
return highlighterPromise;
}
// Pre-warm on module load (non-blocking)
getHighlighter();
/* ── Language detection ──────────────────────────────────── */
const EXT_TO_LANG: Record<string, string> = {
ts: "typescript",
tsx: "tsx",
js: "javascript",
jsx: "jsx",
mjs: "javascript",
cjs: "javascript",
json: "json",
css: "css",
scss: "css",
html: "html",
htm: "html",
yml: "yaml",
yaml: "yaml",
md: "markdown",
mdx: "markdown",
sh: "bash",
bash: "bash",
zsh: "bash",
py: "python",
go: "go",
rs: "rust",
sql: "sql",
toml: "toml",
xml: "xml",
};
function detectLang(path: string): string | null {
const ext = path.split(".").pop()?.toLowerCase() ?? "";
return EXT_TO_LANG[ext] ?? null;
}
/* ── Types ───────────────────────────────────────────────── */
export type TokenizedLine = ThemedToken[];
/** Maps newLineNumber → highlighted tokens for that line */
export type LineTokenMap = Map<number, TokenizedLine>;
interface DiffLineInput {
content: string;
newLineNumber: number | null;
type: "added" | "removed" | "context";
}
/* ── Hook ────────────────────────────────────────────────── */
/**
* Highlights the "new-side" content of a file diff.
* Returns null until highlighting is ready (progressive enhancement).
* Only context + added lines are highlighted (removed lines fall back to plain text).
*/
export function useHighlightedFile(
filePath: string,
allLines: DiffLineInput[],
): LineTokenMap | null {
const [tokenMap, setTokenMap] = useState<LineTokenMap | null>(null);
// Build stable code string + line number mapping
const { code, lineNums, cacheKey } = useMemo(() => {
const entries: { lineNum: number; content: string }[] = [];
for (const line of allLines) {
if (
line.newLineNumber !== null &&
(line.type === "context" || line.type === "added")
) {
entries.push({ lineNum: line.newLineNumber, content: line.content });
}
}
entries.sort((a, b) => a.lineNum - b.lineNum);
const c = entries.map((e) => e.content).join("\n");
return {
code: c,
lineNums: entries.map((e) => e.lineNum),
cacheKey: `${filePath}:${c.length}:${entries.length}`,
};
}, [filePath, allLines]);
useEffect(() => {
const lang = detectLang(filePath);
if (!lang || !code) {
setTokenMap(null);
return;
}
let cancelled = false;
getHighlighter().then((highlighter) => {
if (cancelled || !highlighter) return;
try {
const result = highlighter.codeToTokens(code, {
lang: lang as Parameters<typeof highlighter.codeToTokens>[1]["lang"],
theme: "github-dark-default",
});
const map: LineTokenMap = new Map();
result.tokens.forEach((lineTokens: ThemedToken[], idx: number) => {
if (idx < lineNums.length) {
map.set(lineNums[idx], lineTokens);
}
});
if (!cancelled) setTokenMap(map);
} catch {
// Language not loaded or parse error — no highlighting
}
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cacheKey]);
return tokenMap;
}