feat: Replace horizontal commit nav with VSCode-style sidebar icon switcher
Move commit navigation from a horizontal strip into ReviewSidebar with a vertical icon strip (Files/Commits views). Delete CommitNav.tsx.
This commit is contained in:
@@ -1,116 +0,0 @@
|
|||||||
import { GitCommitHorizontal, Plus, Minus, FileCode } from "lucide-react";
|
|
||||||
import type { CommitInfo } from "./types";
|
|
||||||
|
|
||||||
interface CommitNavProps {
|
|
||||||
commits: CommitInfo[];
|
|
||||||
selectedCommit: string | null; // null = "all changes"
|
|
||||||
onSelectCommit: (hash: string | null) => void;
|
|
||||||
isLoading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CommitNav({
|
|
||||||
commits,
|
|
||||||
selectedCommit,
|
|
||||||
onSelectCommit,
|
|
||||||
isLoading,
|
|
||||||
}: CommitNavProps) {
|
|
||||||
if (isLoading || commits.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="border-b border-border/50 bg-muted/20">
|
|
||||||
<div className="flex items-center gap-0 px-4 overflow-x-auto">
|
|
||||||
{/* "All changes" pill — only when multiple commits */}
|
|
||||||
{commits.length > 1 && (
|
|
||||||
<>
|
|
||||||
<CommitPill
|
|
||||||
label="All changes"
|
|
||||||
sublabel={`${commits.length} commits`}
|
|
||||||
isActive={selectedCommit === null}
|
|
||||||
onClick={() => onSelectCommit(null)}
|
|
||||||
/>
|
|
||||||
<div className="w-px h-5 bg-border mx-1 shrink-0" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Individual commit pills - most recent first */}
|
|
||||||
{commits.map((commit) => (
|
|
||||||
<CommitPill
|
|
||||||
key={commit.hash}
|
|
||||||
label={commit.shortHash}
|
|
||||||
sublabel={truncateMessage(commit.message)}
|
|
||||||
stats={{ files: commit.filesChanged, add: commit.insertions, del: commit.deletions }}
|
|
||||||
isActive={selectedCommit === commit.hash}
|
|
||||||
onClick={() => onSelectCommit(commit.hash)}
|
|
||||||
isMono
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CommitPillProps {
|
|
||||||
label: string;
|
|
||||||
sublabel: string;
|
|
||||||
stats?: { files: number; add: number; del: number };
|
|
||||||
isActive: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
isMono?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CommitPill({
|
|
||||||
label,
|
|
||||||
sublabel,
|
|
||||||
stats,
|
|
||||||
isActive,
|
|
||||||
onClick,
|
|
||||||
isMono,
|
|
||||||
}: CommitPillProps) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
className={`
|
|
||||||
flex items-center gap-2 px-3 py-2 text-[11px] whitespace-nowrap
|
|
||||||
transition-colors duration-fast border-b-2 shrink-0
|
|
||||||
${isActive
|
|
||||||
? "border-primary text-foreground"
|
|
||||||
: "border-transparent text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{isMono ? (
|
|
||||||
<GitCommitHorizontal className="h-3 w-3 shrink-0 text-muted-foreground" />
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<span className={isMono ? "font-mono font-medium" : "font-medium"}>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="text-muted-foreground truncate max-w-[180px]">
|
|
||||||
{sublabel}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{stats && stats.files > 0 && (
|
|
||||||
<span className="flex items-center gap-1.5 text-[10px]">
|
|
||||||
<span className="flex items-center gap-0.5 text-muted-foreground">
|
|
||||||
<FileCode className="h-2.5 w-2.5" />
|
|
||||||
{stats.files}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-0 text-diff-add-fg">
|
|
||||||
<Plus className="h-2.5 w-2.5" />
|
|
||||||
{stats.add}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-0 text-diff-remove-fg">
|
|
||||||
<Minus className="h-2.5 w-2.5" />
|
|
||||||
{stats.del}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function truncateMessage(msg: string): string {
|
|
||||||
const firstLine = msg.split("\n")[0];
|
|
||||||
return firstLine.length > 50 ? firstLine.slice(0, 47) + "..." : firstLine;
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
FileCode,
|
FileCode,
|
||||||
@@ -5,8 +6,12 @@ import {
|
|||||||
Minus,
|
Minus,
|
||||||
Circle,
|
Circle,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
GitCommitHorizontal,
|
||||||
|
Layers,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { FileDiff, ReviewComment } from "./types";
|
import type { FileDiff, ReviewComment, CommitInfo } from "./types";
|
||||||
|
|
||||||
|
type SidebarView = "files" | "commits";
|
||||||
|
|
||||||
interface ReviewSidebarProps {
|
interface ReviewSidebarProps {
|
||||||
files: FileDiff[];
|
files: FileDiff[];
|
||||||
@@ -14,6 +19,8 @@ interface ReviewSidebarProps {
|
|||||||
onFileClick: (filePath: string) => void;
|
onFileClick: (filePath: string) => void;
|
||||||
selectedCommit: string | null;
|
selectedCommit: string | null;
|
||||||
activeFiles: FileDiff[];
|
activeFiles: FileDiff[];
|
||||||
|
commits: CommitInfo[];
|
||||||
|
onSelectCommit: (hash: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReviewSidebar({
|
export function ReviewSidebar({
|
||||||
@@ -22,11 +29,107 @@ export function ReviewSidebar({
|
|||||||
onFileClick,
|
onFileClick,
|
||||||
selectedCommit,
|
selectedCommit,
|
||||||
activeFiles,
|
activeFiles,
|
||||||
|
commits,
|
||||||
|
onSelectCommit,
|
||||||
}: ReviewSidebarProps) {
|
}: ReviewSidebarProps) {
|
||||||
|
const [view, setView] = useState<SidebarView>("files");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full">
|
||||||
|
{/* Content panel */}
|
||||||
|
<div className="flex-1 min-w-0 overflow-y-auto">
|
||||||
|
{view === "files" ? (
|
||||||
|
<FilesView
|
||||||
|
files={files}
|
||||||
|
comments={comments}
|
||||||
|
onFileClick={onFileClick}
|
||||||
|
selectedCommit={selectedCommit}
|
||||||
|
activeFiles={activeFiles}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CommitsView
|
||||||
|
commits={commits}
|
||||||
|
selectedCommit={selectedCommit}
|
||||||
|
onSelectCommit={onSelectCommit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Icon strip — right edge */}
|
||||||
|
<div className="flex flex-col items-center w-9 shrink-0 border-l border-border/50 py-1.5 gap-0.5">
|
||||||
|
<IconTab
|
||||||
|
icon={FileCode}
|
||||||
|
label="Files"
|
||||||
|
active={view === "files"}
|
||||||
|
onClick={() => setView("files")}
|
||||||
|
/>
|
||||||
|
<IconTab
|
||||||
|
icon={GitCommitHorizontal}
|
||||||
|
label="Commits"
|
||||||
|
active={view === "commits"}
|
||||||
|
onClick={() => setView("commits")}
|
||||||
|
badge={commits.length > 1 ? commits.length : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Icon Tab ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function IconTab({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
badge,
|
||||||
|
}: {
|
||||||
|
icon: typeof FileCode;
|
||||||
|
label: string;
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
badge?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
title={label}
|
||||||
|
className={`
|
||||||
|
relative flex items-center justify-center w-8 h-8 rounded-md
|
||||||
|
transition-all duration-150
|
||||||
|
${active
|
||||||
|
? "text-primary bg-primary/10 shadow-[inset_2px_0_0_0] shadow-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-accent/50"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<Icon className="h-[15px] w-[15px]" />
|
||||||
|
{badge !== undefined && (
|
||||||
|
<span className="absolute -top-0.5 -right-0.5 min-w-[14px] h-[14px] rounded-full bg-primary text-primary-foreground text-[9px] font-semibold flex items-center justify-center px-0.5 leading-none">
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Files View ───────────────────────────────────────── */
|
||||||
|
|
||||||
|
function FilesView({
|
||||||
|
files,
|
||||||
|
comments,
|
||||||
|
onFileClick,
|
||||||
|
selectedCommit,
|
||||||
|
activeFiles,
|
||||||
|
}: {
|
||||||
|
files: FileDiff[];
|
||||||
|
comments: ReviewComment[];
|
||||||
|
onFileClick: (filePath: string) => void;
|
||||||
|
selectedCommit: string | null;
|
||||||
|
activeFiles: FileDiff[];
|
||||||
|
}) {
|
||||||
const unresolvedCount = comments.filter((c) => !c.resolved).length;
|
const unresolvedCount = comments.filter((c) => !c.resolved).length;
|
||||||
const resolvedCount = comments.filter((c) => c.resolved).length;
|
const resolvedCount = comments.filter((c) => c.resolved).length;
|
||||||
|
|
||||||
// Build a set of files visible in the current diff view
|
|
||||||
const activeFilePaths = new Set(activeFiles.map((f) => f.newPath));
|
const activeFilePaths = new Set(activeFiles.map((f) => f.newPath));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -113,9 +216,101 @@ export function ReviewSidebar({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Show filename with parent directory for context */
|
/* ─── Commits View ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
function CommitsView({
|
||||||
|
commits,
|
||||||
|
selectedCommit,
|
||||||
|
onSelectCommit,
|
||||||
|
}: {
|
||||||
|
commits: CommitInfo[];
|
||||||
|
selectedCommit: string | null;
|
||||||
|
onSelectCommit: (hash: string | null) => void;
|
||||||
|
}) {
|
||||||
|
if (commits.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-20 text-xs text-muted-foreground">
|
||||||
|
No commits
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
|
||||||
|
Commits
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{/* "All changes" — only when >1 commit */}
|
||||||
|
{commits.length > 1 && (
|
||||||
|
<button
|
||||||
|
onClick={() => onSelectCommit(null)}
|
||||||
|
className={`
|
||||||
|
flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[11px]
|
||||||
|
transition-colors
|
||||||
|
${selectedCommit === null
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-accent/50"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<Layers className="h-3 w-3 shrink-0" />
|
||||||
|
<span className="font-medium">All changes</span>
|
||||||
|
<span className="ml-auto text-[10px] opacity-70">
|
||||||
|
{commits.length} commits
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Commit list */}
|
||||||
|
{commits.map((commit) => {
|
||||||
|
const isActive = selectedCommit === commit.hash;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={commit.hash}
|
||||||
|
onClick={() => onSelectCommit(commit.hash)}
|
||||||
|
className={`
|
||||||
|
flex w-full flex-col gap-0.5 rounded px-2 py-1.5 text-left
|
||||||
|
transition-colors
|
||||||
|
${isActive
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "text-foreground hover:bg-accent/50"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* Hash + stats */}
|
||||||
|
<div className="flex items-center gap-1.5 w-full">
|
||||||
|
<GitCommitHorizontal className={`h-3 w-3 shrink-0 ${isActive ? "text-primary" : "text-muted-foreground"}`} />
|
||||||
|
<span className="font-mono text-[11px] font-medium">
|
||||||
|
{commit.shortHash}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto flex items-center gap-1.5 text-[10px] shrink-0">
|
||||||
|
<span className="text-muted-foreground">{commit.filesChanged}f</span>
|
||||||
|
<span className="text-diff-add-fg">+{commit.insertions}</span>
|
||||||
|
<span className="text-diff-remove-fg">−{commit.deletions}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message */}
|
||||||
|
<span className={`text-[11px] truncate pl-[18px] ${isActive ? "text-primary/80" : "text-muted-foreground"}`}>
|
||||||
|
{truncateMessage(commit.message)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Helpers ──────────────────────────────────────────── */
|
||||||
|
|
||||||
function formatFilePath(path: string): string {
|
function formatFilePath(path: string): string {
|
||||||
const parts = path.split("/");
|
const parts = path.split("/");
|
||||||
if (parts.length <= 2) return path;
|
if (parts.length <= 2) return path;
|
||||||
return parts.slice(-2).join("/");
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { parseUnifiedDiff } from "./parse-diff";
|
|||||||
import { DiffViewer } from "./DiffViewer";
|
import { DiffViewer } from "./DiffViewer";
|
||||||
import { ReviewSidebar } from "./ReviewSidebar";
|
import { ReviewSidebar } from "./ReviewSidebar";
|
||||||
import { ReviewHeader } from "./ReviewHeader";
|
import { ReviewHeader } from "./ReviewHeader";
|
||||||
import { CommitNav } from "./CommitNav";
|
|
||||||
import type { ReviewComment, ReviewStatus, DiffLine } from "./types";
|
import type { ReviewComment, ReviewStatus, DiffLine } from "./types";
|
||||||
|
|
||||||
interface ReviewTabProps {
|
interface ReviewTabProps {
|
||||||
@@ -235,14 +234,6 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
|||||||
preview={previewState}
|
preview={previewState}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Commit navigation strip */}
|
|
||||||
<CommitNav
|
|
||||||
commits={commits}
|
|
||||||
selectedCommit={selectedCommit}
|
|
||||||
onSelectCommit={setSelectedCommit}
|
|
||||||
isLoading={commitsQuery.isLoading}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Main content area */}
|
{/* Main content area */}
|
||||||
{isDiffLoading ? (
|
{isDiffLoading ? (
|
||||||
<div className="flex h-64 items-center justify-center text-muted-foreground gap-2">
|
<div className="flex h-64 items-center justify-center text-muted-foreground gap-2">
|
||||||
@@ -278,6 +269,8 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
|||||||
onFileClick={handleFileClick}
|
onFileClick={handleFileClick}
|
||||||
selectedCommit={selectedCommit}
|
selectedCommit={selectedCommit}
|
||||||
activeFiles={files}
|
activeFiles={files}
|
||||||
|
commits={commits}
|
||||||
|
onSelectCommit={setSelectedCommit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -111,10 +111,9 @@ The initiative detail page has three tabs managed via local state (not URL param
|
|||||||
### Review Components (`src/components/review/`)
|
### Review Components (`src/components/review/`)
|
||||||
| Component | Purpose |
|
| Component | Purpose |
|
||||||
|-----------|---------|
|
|-----------|---------|
|
||||||
| `ReviewTab` | Review tab container — orchestrates header, commit nav, diff, sidebar, and preview |
|
| `ReviewTab` | Review tab container — orchestrates header, diff, sidebar, and preview |
|
||||||
| `ReviewHeader` | Consolidated toolbar: phase selector pills, branch info, stats, preview controls, approve/reject actions |
|
| `ReviewHeader` | Consolidated toolbar: phase selector pills, branch info, stats, preview controls, approve/reject actions |
|
||||||
| `CommitNav` | Horizontal commit navigation strip — "All changes" + individual commit pills with stats |
|
| `ReviewSidebar` | VSCode-style icon strip (Files/Commits views) with file list, comment counts, and commit navigation |
|
||||||
| `ReviewSidebar` | File list with comment counts, dimmed files when viewing single commit |
|
|
||||||
| `DiffViewer` | Unified diff renderer with inline comments |
|
| `DiffViewer` | Unified diff renderer with inline comments |
|
||||||
| `PreviewPanel` | Docker preview status: building/running/failed with start/stop (legacy, now integrated into ReviewHeader) |
|
| `PreviewPanel` | Docker preview status: building/running/failed with start/stop (legacy, now integrated into ReviewHeader) |
|
||||||
| `ProposalCard` | Individual proposal display |
|
| `ProposalCard` | Individual proposal display |
|
||||||
|
|||||||
Reference in New Issue
Block a user