Build in the review tool.
This commit is contained in:
@@ -0,0 +1,337 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Comment } from '../types';
|
||||
import { api } from '../api';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
function initials(author: string): string {
|
||||
return author === 'claude' ? 'AI' : 'ME';
|
||||
}
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
const then = new Date(iso).getTime();
|
||||
const secs = Math.max(1, Math.round((Date.now() - then) / 1000));
|
||||
if (secs < 60) return `${secs}s ago`;
|
||||
const mins = Math.round(secs / 60);
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.round(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
interface Props {
|
||||
comments: Comment[];
|
||||
onChanged: () => void;
|
||||
}
|
||||
|
||||
// CommentThread renders every comment anchored to one line, GitHub-style, with
|
||||
// its replies and a reply composer.
|
||||
export function CommentThread({ comments, onChanged }: Props) {
|
||||
return (
|
||||
<div className="thread">
|
||||
{comments.map((c) => (
|
||||
<SingleThread key={c.id} comment={c} onChanged={onChanged} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// summarize reduces a thread to the single line shown while it is collapsed.
|
||||
function summarize(body: string): string {
|
||||
const line = body.trim().split('\n')[0];
|
||||
return line.length > 110 ? line.slice(0, 110) + '…' : line;
|
||||
}
|
||||
|
||||
function SingleThread({
|
||||
comment,
|
||||
onChanged,
|
||||
}: {
|
||||
comment: Comment;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [replyText, setReplyText] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
// The id of the message being edited — the comment's own id for the opening
|
||||
// message, a reply's id for a reply. Ids are unique across the thread, so one
|
||||
// piece of state is enough, and at most one editor is ever open.
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
// Resolved threads collapse to a one-line summary, GitHub-style. Not derived
|
||||
// from `status`: reopening has to leave the thread open, and expanding a
|
||||
// resolved thread must not reopen it.
|
||||
const [showResolved, setShowResolved] = useState(false);
|
||||
const resolved = comment.status === 'resolved';
|
||||
|
||||
const submitReply = async () => {
|
||||
if (!replyText.trim()) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.addReply(comment.id, replyText.trim(), 'user');
|
||||
setReplyText('');
|
||||
onChanged();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Editing the opening message and editing a reply hit different endpoints,
|
||||
// so the target id decides which one.
|
||||
const saveEdit = async (targetId: string, body: string) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
targetId === comment.id
|
||||
? await api.updateComment(comment.id, body)
|
||||
: await api.updateReply(comment.id, targetId, body);
|
||||
setEditingId(null);
|
||||
onChanged();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const del = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.deleteComment(comment.id);
|
||||
onChanged();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleResolve = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
resolved
|
||||
? await api.reopen(comment.id)
|
||||
: await api.resolve(comment.id);
|
||||
// Resolving collapses the thread; anything reopened starts expanded.
|
||||
setShowResolved(false);
|
||||
onChanged();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const replyCount = comment.replies.length;
|
||||
|
||||
// The element id is the jump target used by the comments rail, so it has to
|
||||
// stay on the outermost node in both the collapsed and expanded shapes.
|
||||
if (resolved && !showResolved) {
|
||||
return (
|
||||
<div id={`comment-${comment.id}`} className="thread-card is-resolved">
|
||||
<button
|
||||
className="thread-collapsed"
|
||||
onClick={() => setShowResolved(true)}
|
||||
title="Show resolved conversation"
|
||||
>
|
||||
<span className="thread-resolved-check">
|
||||
<Icon name="check-circle-fill" />
|
||||
</span>
|
||||
<span className="thread-resolved-label">Resolved</span>
|
||||
<span className="thread-collapsed-preview">{summarize(comment.body)}</span>
|
||||
{replyCount > 0 && (
|
||||
<span className="thread-collapsed-count">
|
||||
{replyCount + 1} comments
|
||||
</span>
|
||||
)}
|
||||
<span className="thread-collapsed-show">Show resolved</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`comment-${comment.id}`}
|
||||
className={`thread-card${resolved ? ' is-resolved' : ''}`}
|
||||
>
|
||||
{resolved && (
|
||||
<div className="thread-resolved-bar">
|
||||
<span className="thread-resolved-check">
|
||||
<Icon name="check-circle-fill" />
|
||||
</span>
|
||||
<span className="thread-resolved-label">Resolved</span>
|
||||
<button className="thread-hide" onClick={() => setShowResolved(false)}>
|
||||
Hide
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<Bubble
|
||||
author={comment.author}
|
||||
body={comment.body}
|
||||
time={comment.createdAt}
|
||||
status={comment.status}
|
||||
onEdit={busy ? undefined : () => setEditingId(comment.id)}
|
||||
editor={
|
||||
editingId === comment.id ? (
|
||||
<BodyEditor
|
||||
initial={comment.body}
|
||||
busy={busy}
|
||||
onSave={(body) => saveEdit(comment.id, body)}
|
||||
onCancel={() => setEditingId(null)}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{comment.replies.map((r) => (
|
||||
<Bubble
|
||||
key={r.id}
|
||||
author={r.author}
|
||||
body={r.body}
|
||||
time={r.createdAt}
|
||||
onEdit={busy ? undefined : () => setEditingId(r.id)}
|
||||
editor={
|
||||
editingId === r.id ? (
|
||||
<BodyEditor
|
||||
initial={r.body}
|
||||
busy={busy}
|
||||
onSave={(body) => saveEdit(r.id, body)}
|
||||
onCancel={() => setEditingId(null)}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="thread-reply">
|
||||
<textarea
|
||||
className="reply-input"
|
||||
placeholder={resolved ? 'Reopen to reply…' : 'Reply…'}
|
||||
value={replyText}
|
||||
disabled={resolved || busy}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') submitReply();
|
||||
}}
|
||||
/>
|
||||
<div className="thread-actions">
|
||||
<button className="btn-ghost" onClick={del} disabled={busy}>
|
||||
Delete
|
||||
</button>
|
||||
<button className="btn-ghost" onClick={toggleResolve} disabled={busy}>
|
||||
{resolved ? 'Reopen' : 'Resolve'}
|
||||
</button>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={submitReply}
|
||||
disabled={busy || resolved || !replyText.trim()}
|
||||
>
|
||||
Reply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// BodyEditor edits a comment's text in place of its rendered body. It starts
|
||||
// from the saved text and only reports a change on save, so cancelling always
|
||||
// leaves the stored comment untouched.
|
||||
function BodyEditor({
|
||||
initial,
|
||||
busy,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
initial: string;
|
||||
busy: boolean;
|
||||
onSave: (body: string) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [text, setText] = useState(initial);
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Focus with the caret at the end — you're almost always amending, not
|
||||
// retyping from the start.
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
el.setSelectionRange(el.value.length, el.value.length);
|
||||
}, []);
|
||||
|
||||
const trimmed = text.trim();
|
||||
const unchanged = trimmed === initial.trim();
|
||||
const save = () => {
|
||||
if (trimmed && !unchanged) onSave(trimmed);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bubble-edit">
|
||||
<textarea
|
||||
ref={ref}
|
||||
className="edit-input"
|
||||
value={text}
|
||||
disabled={busy}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') save();
|
||||
if (e.key === 'Escape') onCancel();
|
||||
}}
|
||||
/>
|
||||
<div className="edit-actions">
|
||||
<span className="composer-hint">⌘⏎ to save · esc to cancel</span>
|
||||
<button className="btn-ghost" onClick={onCancel} disabled={busy}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={save}
|
||||
disabled={busy || !trimmed || unchanged}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Bubble({
|
||||
author,
|
||||
body,
|
||||
time,
|
||||
status,
|
||||
editor,
|
||||
onEdit,
|
||||
}: {
|
||||
author: string;
|
||||
body: string;
|
||||
time: string;
|
||||
status?: string;
|
||||
// When present, replaces the rendered body — the comment is being edited.
|
||||
editor?: ReactNode;
|
||||
// Opens the editor for this message. Omitted while the thread is busy.
|
||||
onEdit?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="bubble">
|
||||
<div className={`avatar avatar-${author}`}>{initials(author)}</div>
|
||||
<div className="bubble-body">
|
||||
<div className="bubble-head">
|
||||
<span className="bubble-author">
|
||||
{author === 'claude' ? 'Claude' : 'You'}
|
||||
</span>
|
||||
<span className="bubble-time">{timeAgo(time)}</span>
|
||||
{status === 'draft' && <span className="pill pill-draft">draft</span>}
|
||||
{status === 'submitted' && (
|
||||
<span className="pill pill-open">open</span>
|
||||
)}
|
||||
{status === 'resolved' && (
|
||||
<span className="pill pill-resolved">resolved</span>
|
||||
)}
|
||||
{!editor && onEdit && (
|
||||
<button
|
||||
className="bubble-edit-btn"
|
||||
onClick={onEdit}
|
||||
title="Edit this comment"
|
||||
aria-label="Edit this comment"
|
||||
>
|
||||
<Icon name="pencil" size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{editor ?? <div className="bubble-text">{body}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import type { Comment, Status } from '../types';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
// Filters are by status, plus `outdated`, which cuts across status: it's about
|
||||
// whether the diff can still place a comment, not where it is in its lifecycle.
|
||||
type Filter = 'all' | 'draft' | 'submitted' | 'resolved' | 'outdated';
|
||||
|
||||
const FILTERS: { key: Filter; label: string }[] = [
|
||||
{ key: 'all', label: 'all' },
|
||||
{ key: 'draft', label: 'drafts' },
|
||||
{ key: 'submitted', label: 'open' },
|
||||
{ key: 'resolved', label: 'done' },
|
||||
{ key: 'outdated', label: 'outdated' },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
comments: Comment[];
|
||||
// Ids of comments the current diff can't place — see lib/anchor.
|
||||
outdated: ReadonlySet<string>;
|
||||
fileOrder: string[]; // file paths in diff order, for grouping
|
||||
width: number;
|
||||
onJump: (c: Comment) => void;
|
||||
onDeleteResolved: () => void;
|
||||
onCollapse: () => void;
|
||||
}
|
||||
|
||||
// location describes where a comment lives, in the compact form the rail shows.
|
||||
function location(c: Comment): string {
|
||||
if (c.level === 'review') return 'overall';
|
||||
if (c.level === 'file') return 'whole file';
|
||||
const line = c.endLine || c.line;
|
||||
return c.endLine && c.endLine !== c.line
|
||||
? `L${c.line}–${c.endLine}`
|
||||
: `L${line}`;
|
||||
}
|
||||
|
||||
function statusPill(status: Status) {
|
||||
const label =
|
||||
status === 'draft' ? 'draft' : status === 'resolved' ? 'resolved' : 'open';
|
||||
return <span className={`pill pill-${status === 'submitted' ? 'open' : status}`}>{label}</span>;
|
||||
}
|
||||
|
||||
// CommentsPanel is the right rail: every comment in the review, grouped by file,
|
||||
// with a click to jump to the thread in the diff.
|
||||
export function CommentsPanel({
|
||||
comments,
|
||||
outdated,
|
||||
fileOrder,
|
||||
width,
|
||||
onJump,
|
||||
onDeleteResolved,
|
||||
onCollapse,
|
||||
}: Props) {
|
||||
const [filter, setFilter] = useState<Filter>('all');
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const c = {
|
||||
all: comments.length,
|
||||
draft: 0,
|
||||
submitted: 0,
|
||||
resolved: 0,
|
||||
outdated: 0,
|
||||
};
|
||||
for (const cm of comments) {
|
||||
c[cm.status]++;
|
||||
if (outdated.has(cm.id)) c.outdated++;
|
||||
}
|
||||
return c;
|
||||
}, [comments, outdated]);
|
||||
|
||||
// Group by file (review-level comments first), keeping the diff's file order
|
||||
// and line order within a file.
|
||||
const groups = useMemo(() => {
|
||||
const shown = comments.filter((c) =>
|
||||
filter === 'all'
|
||||
? true
|
||||
: filter === 'outdated'
|
||||
? outdated.has(c.id)
|
||||
: c.status === filter,
|
||||
);
|
||||
const rank = new Map(fileOrder.map((p, i) => [p, i]));
|
||||
|
||||
const byFile = new Map<string, Comment[]>();
|
||||
const review: Comment[] = [];
|
||||
for (const c of shown) {
|
||||
if (c.level === 'review') {
|
||||
review.push(c);
|
||||
continue;
|
||||
}
|
||||
const list = byFile.get(c.file);
|
||||
if (list) list.push(c);
|
||||
else byFile.set(c.file, [c]);
|
||||
}
|
||||
|
||||
const files = [...byFile.entries()].sort(
|
||||
([a], [b]) =>
|
||||
(rank.get(a) ?? Number.MAX_SAFE_INTEGER) -
|
||||
(rank.get(b) ?? Number.MAX_SAFE_INTEGER) || a.localeCompare(b),
|
||||
);
|
||||
// File-level comments head their file's group; line comments follow in
|
||||
// line order.
|
||||
const levelRank = (c: Comment) => (c.level === 'file' ? 0 : 1);
|
||||
for (const [, cs] of files) {
|
||||
cs.sort(
|
||||
(a, b) =>
|
||||
levelRank(a) - levelRank(b) ||
|
||||
(a.line || 0) - (b.line || 0) ||
|
||||
a.createdAt.localeCompare(b.createdAt),
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
...(review.length > 0
|
||||
? ([['', review]] as [string, Comment[]][])
|
||||
: []),
|
||||
...files,
|
||||
];
|
||||
}, [comments, outdated, fileOrder, filter]);
|
||||
|
||||
const total = groups.reduce((n, [, cs]) => n + cs.length, 0);
|
||||
|
||||
return (
|
||||
<aside className="comments-rail" style={{ width }}>
|
||||
<div className="comments-head">
|
||||
<span className="comments-title">
|
||||
comments{comments.length > 0 && <span className="count">{comments.length}</span>}
|
||||
</span>
|
||||
<span className="comments-head-actions">
|
||||
{/* Only offered when there's something to clear — a control that can
|
||||
never do anything is just noise in a narrow rail. */}
|
||||
{counts.resolved > 0 && (
|
||||
<button
|
||||
className="rail-action is-danger"
|
||||
onClick={onDeleteResolved}
|
||||
title={`Delete ${counts.resolved} resolved comment${
|
||||
counts.resolved === 1 ? '' : 's'
|
||||
}`}
|
||||
aria-label="Delete resolved comments"
|
||||
>
|
||||
<Icon name="trash" size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="rail-action"
|
||||
onClick={onCollapse}
|
||||
title="Hide comments"
|
||||
aria-label="Hide comments"
|
||||
>
|
||||
<Icon name="chevron-right" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="comments-filters">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
className={`comments-filter${filter === f.key ? ' is-active' : ''}`}
|
||||
onClick={() => setFilter(f.key)}
|
||||
disabled={counts[f.key] === 0 && f.key !== 'all'}
|
||||
>
|
||||
{f.label}
|
||||
<span className="comments-filter-n">{counts[f.key]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="comments-list">
|
||||
{total === 0 ? (
|
||||
<p className="comments-empty">
|
||||
{comments.length === 0
|
||||
? 'No comments yet. Drag across the line gutter to start one.'
|
||||
: 'Nothing matches this filter.'}
|
||||
</p>
|
||||
) : (
|
||||
groups.map(([file, cs]) => (
|
||||
<section key={file || '__review'} className="comments-group">
|
||||
<h3 className="comments-group-head" title={file || 'Review-level'}>
|
||||
{file ? file.split('/').pop() : 'Review'}
|
||||
{file && (
|
||||
<span className="comments-group-dir">
|
||||
{file.slice(0, file.length - (file.split('/').pop()?.length ?? 0))}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<ul>
|
||||
{cs.map((c) => (
|
||||
<li key={c.id}>
|
||||
<button
|
||||
className={`comment-card status-${c.status}${
|
||||
outdated.has(c.id) ? ' is-outdated' : ''
|
||||
}`}
|
||||
onClick={() => onJump(c)}
|
||||
title={
|
||||
outdated.has(c.id)
|
||||
? 'Outdated — the code it was written on is no longer in this diff. Jump to it.'
|
||||
: 'Jump to this comment'
|
||||
}
|
||||
>
|
||||
<span className="comment-card-head">
|
||||
<span className="comment-card-where">{location(c)}</span>
|
||||
{statusPill(c.status)}
|
||||
{outdated.has(c.id) && (
|
||||
<span className="pill pill-outdated">outdated</span>
|
||||
)}
|
||||
<span className="comment-card-who">
|
||||
{c.author === 'claude' ? 'Claude' : 'You'}
|
||||
</span>
|
||||
</span>
|
||||
<span className="comment-card-body">{c.body}</span>
|
||||
{c.replies.length > 0 && (
|
||||
<span className="comment-card-replies">
|
||||
<Icon name="reply" size={12} /> {c.replies.length}{' '}
|
||||
{c.replies.length === 1 ? 'reply' : 'replies'}
|
||||
{c.replies[c.replies.length - 1].author === 'claude' &&
|
||||
' · Claude'}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
// CommentsTab is the thin strip shown in place of the rail when it's collapsed.
|
||||
export function CommentsTab({
|
||||
count,
|
||||
onExpand,
|
||||
}: {
|
||||
count: number;
|
||||
onExpand: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button className="comments-tab" onClick={onExpand} title="Show comments">
|
||||
<Icon name="chevron-left" />
|
||||
<span className="comments-tab-label">comments</span>
|
||||
{count > 0 && <span className="count">{count}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { Commit } from '../types';
|
||||
import { relativeTime } from '../lib/time';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
interface Props {
|
||||
// The commits the change set spans, oldest first.
|
||||
commits: Commit[];
|
||||
// The range holds more than this list — see git.maxCommits.
|
||||
more: boolean;
|
||||
// The sha currently being read on its own, if any.
|
||||
selected?: string;
|
||||
// Select one commit's diff, or the whole change set again with undefined.
|
||||
onSelect: (sha: string | undefined) => void;
|
||||
}
|
||||
|
||||
const OPEN_KEY = 'review-commits-open';
|
||||
|
||||
// CommitList is the top of the left rail: the commits the diff is made of, any one
|
||||
// of which can be read on its own.
|
||||
//
|
||||
// It's the answer to a change set that only makes sense a step at a time — a
|
||||
// branch where one commit moves code and the next changes it, which read as one
|
||||
// unintelligible patch together. The rows are in the order they were written,
|
||||
// because that's the order they were meant to be read in.
|
||||
export function CommitList({ commits, more, selected, onSelect }: Props) {
|
||||
const [open, setOpen] = useState(
|
||||
() => localStorage.getItem(OPEN_KEY) !== 'false',
|
||||
);
|
||||
|
||||
const toggle = () => {
|
||||
setOpen(!open);
|
||||
localStorage.setItem(OPEN_KEY, String(!open));
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="commitlist">
|
||||
<button
|
||||
className="commitlist-head"
|
||||
onClick={toggle}
|
||||
aria-expanded={open}
|
||||
title={open ? 'Hide the commit list' : 'Show the commit list'}
|
||||
>
|
||||
<Icon name={open ? 'chevron-down' : 'chevron-right'} size={12} />
|
||||
<span>
|
||||
{commits.length}
|
||||
{more ? '+' : ''} commit{commits.length === 1 && !more ? '' : 's'}
|
||||
</span>
|
||||
{/* Which one you're on stays legible with the list folded away. */}
|
||||
{selected && (
|
||||
<span className="commitlist-head-sha">{shortOf(commits, selected)}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<ul>
|
||||
<li>
|
||||
<button
|
||||
className={`commitlist-row${selected ? '' : ' is-active'}`}
|
||||
onClick={() => onSelect(undefined)}
|
||||
title="Show every commit in the range at once"
|
||||
>
|
||||
<span className="commitlist-icon">
|
||||
<Icon name="file-diff" size={14} />
|
||||
</span>
|
||||
<span className="commitlist-subject">All commits</span>
|
||||
</button>
|
||||
</li>
|
||||
{more && (
|
||||
<li className="commitlist-note">
|
||||
only the newest {commits.length} are listed — the range holds more
|
||||
</li>
|
||||
)}
|
||||
{commits.map((c) => (
|
||||
<li key={c.sha}>
|
||||
<button
|
||||
className={`commitlist-row${c.sha === selected ? ' is-active' : ''}`}
|
||||
onClick={() => onSelect(c.sha)}
|
||||
title={`${c.subject}\n\n${c.sha}\n${c.author}`}
|
||||
>
|
||||
<span className="commitlist-icon">
|
||||
<Icon name="git-commit" size={14} />
|
||||
</span>
|
||||
<span className="commitlist-body">
|
||||
<span className="commitlist-subject">{c.subject}</span>
|
||||
<span className="commitlist-meta">
|
||||
<span className="commitlist-sha">{c.shortSha}</span>
|
||||
{c.author && <span className="commitlist-author">{c.author}</span>}
|
||||
{c.date && <span>{relativeTime(c.date)}</span>}
|
||||
{/* A merge gets no stats from git, so there's nothing to show. */}
|
||||
{c.files > 0 && (
|
||||
<span className="commitlist-stats">
|
||||
<span className="add">+{c.additions}</span>
|
||||
<span className="del">−{c.deletions}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
// shortOf abbreviates the selected sha, preferring the abbreviation git chose for
|
||||
// it. A commit selected before a refresh dropped it out of the range still has to
|
||||
// render as something, hence the fallback.
|
||||
function shortOf(commits: Commit[], sha: string): string {
|
||||
return commits.find((c) => c.sha === sha)?.shortSha ?? sha.slice(0, 7);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
onSubmit: (body: string) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
// Composer is the inline "add a comment" box shown under a line.
|
||||
export function Composer({ onSubmit, onCancel }: Props) {
|
||||
const [text, setText] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
ref.current?.focus();
|
||||
}, []);
|
||||
|
||||
const submit = async () => {
|
||||
if (!text.trim()) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await onSubmit(text.trim());
|
||||
setText('');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="composer">
|
||||
<textarea
|
||||
ref={ref}
|
||||
className="composer-input"
|
||||
placeholder="Leave a comment on this line…"
|
||||
value={text}
|
||||
disabled={busy}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') submit();
|
||||
if (e.key === 'Escape') onCancel();
|
||||
}}
|
||||
/>
|
||||
<div className="composer-actions">
|
||||
<span className="composer-hint">⌘⏎ to add · esc to cancel</span>
|
||||
<button className="btn-ghost" onClick={onCancel} disabled={busy}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={submit}
|
||||
disabled={busy || !text.trim()}
|
||||
>
|
||||
Add comment
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useRef, type ReactNode } from 'react';
|
||||
|
||||
import { Icon } from './Icon';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
confirmLabel: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
// A modal confirmation, for actions that destroy something the user can't get
|
||||
// back. Escape and a click outside both cancel, and focus lands on Cancel rather
|
||||
// than the destructive button so a stray Enter can't confirm it.
|
||||
export function ConfirmDialog({
|
||||
title,
|
||||
children,
|
||||
confirmLabel,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: Props) {
|
||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
cancelRef.current?.focus();
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onCancel();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onCancel]);
|
||||
|
||||
return (
|
||||
<div className="dialog-overlay" onMouseDown={onCancel}>
|
||||
<div
|
||||
className="dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="dialog-head">
|
||||
<h2 className="dialog-title">{title}</h2>
|
||||
<button className="icon-btn" onClick={onCancel} aria-label="Cancel">
|
||||
<Icon name="x" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="dialog-body">{children}</div>
|
||||
<div className="dialog-actions">
|
||||
<button className="btn-ghost" ref={cancelRef} onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="btn-danger" onClick={onConfirm}>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,694 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import {
|
||||
Decoration,
|
||||
Diff,
|
||||
Hunk,
|
||||
getChangeKey,
|
||||
getCollapsedLinesCountBetween,
|
||||
markEdits,
|
||||
tokenize,
|
||||
useSourceExpansion,
|
||||
type ChangeData,
|
||||
type FileData,
|
||||
type HunkData,
|
||||
type HunkTokens,
|
||||
type ViewType,
|
||||
} from 'react-diff-view';
|
||||
|
||||
import { api } from '../api';
|
||||
import type { Comment, DiffContext, DraftTarget, Side } from '../types';
|
||||
import { anchorLine, changeKeyIndex, filePath, lineFor } from '../lib/anchor';
|
||||
import { languageForFile, refractorAdapter } from '../lib/language';
|
||||
import { CommentThread } from './CommentThread';
|
||||
import { Composer } from './Composer';
|
||||
import { Icon } from './Icon';
|
||||
import { OutdatedNote } from './Outdated';
|
||||
|
||||
interface Props {
|
||||
files: FileData[];
|
||||
comments: Comment[];
|
||||
// Ids of comments whose anchor is no longer in the diff (see lib/anchor).
|
||||
// Those belonging to a file still in the change set are shown in that file,
|
||||
// apart from the code, rather than pinned to a line that no longer means
|
||||
// what they were written about.
|
||||
outdated: ReadonlySet<string>;
|
||||
viewType: ViewType;
|
||||
ctx: DiffContext;
|
||||
draft: DraftTarget | null;
|
||||
viewed: ReadonlySet<string>;
|
||||
// Files that lost their viewed mark this session because their diff changed.
|
||||
// Flagged in the header so the mark coming off doesn't look like a glitch.
|
||||
changed: ReadonlySet<string>;
|
||||
// Path of a file to force open, with a sequence number so re-requesting the
|
||||
// same file counts as a new request. Set when a jump targets a thread inside
|
||||
// a collapsed file.
|
||||
reveal: { file: string; seq: number } | null;
|
||||
onSetViewed: (file: string, viewed: boolean) => void;
|
||||
onStartDraft: (d: DraftTarget) => void;
|
||||
onCancelDraft: () => void;
|
||||
onSubmitDraft: (body: string) => Promise<void>;
|
||||
onChanged: () => void;
|
||||
}
|
||||
|
||||
// Highlighting a whole file is linear in its size, but Prism on a megabyte of
|
||||
// minified output blocks the frame for long enough to feel broken. Past this we
|
||||
// render the diff unhighlighted, as GitHub does for generated blobs.
|
||||
const MAX_HIGHLIGHT_BYTES = 512 * 1024;
|
||||
|
||||
export function DiffView({
|
||||
files,
|
||||
comments,
|
||||
outdated,
|
||||
viewType,
|
||||
ctx,
|
||||
viewed,
|
||||
changed,
|
||||
reveal,
|
||||
onSetViewed,
|
||||
onStartDraft,
|
||||
onCancelDraft,
|
||||
onSubmitDraft,
|
||||
onChanged,
|
||||
draft,
|
||||
}: Props) {
|
||||
if (files.length === 0) {
|
||||
return (
|
||||
<div className="empty-diff">
|
||||
<div className="empty-diff-mark">∅</div>
|
||||
<p>No changes for this selection.</p>
|
||||
<p className="muted">Try a different base ref or toggle uncommitted changes.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{files.map((file) => (
|
||||
<FileView
|
||||
key={filePath(file) + file.oldRevision + file.newRevision}
|
||||
file={file}
|
||||
base={ctx.base}
|
||||
comments={comments.filter((c) => c.file === filePath(file))}
|
||||
outdated={outdated}
|
||||
viewType={viewType}
|
||||
draft={draft}
|
||||
viewed={viewed.has(filePath(file))}
|
||||
changed={changed.has(filePath(file))}
|
||||
reveal={reveal?.file === filePath(file) ? reveal.seq : null}
|
||||
onSetViewed={onSetViewed}
|
||||
onStartDraft={onStartDraft}
|
||||
onCancelDraft={onCancelDraft}
|
||||
onSubmitDraft={onSubmitDraft}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function statusLabel(type: string): string {
|
||||
switch (type) {
|
||||
case 'add':
|
||||
return 'added';
|
||||
case 'delete':
|
||||
return 'deleted';
|
||||
case 'rename':
|
||||
return 'renamed';
|
||||
case 'copy':
|
||||
return 'copied';
|
||||
default:
|
||||
return 'modified';
|
||||
}
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
side: Side;
|
||||
anchor: number;
|
||||
head: number;
|
||||
}
|
||||
|
||||
function FileView({
|
||||
file,
|
||||
base,
|
||||
comments,
|
||||
outdated,
|
||||
viewType,
|
||||
draft,
|
||||
viewed,
|
||||
changed,
|
||||
reveal,
|
||||
onSetViewed,
|
||||
onStartDraft,
|
||||
onCancelDraft,
|
||||
onSubmitDraft,
|
||||
onChanged,
|
||||
}: {
|
||||
file: FileData;
|
||||
base: string;
|
||||
comments: Comment[];
|
||||
outdated: ReadonlySet<string>;
|
||||
viewType: ViewType;
|
||||
draft: DraftTarget | null;
|
||||
viewed: boolean;
|
||||
changed: boolean;
|
||||
reveal: number | null;
|
||||
onSetViewed: Props['onSetViewed'];
|
||||
onStartDraft: (d: DraftTarget) => void;
|
||||
onCancelDraft: () => void;
|
||||
onSubmitDraft: Props['onSubmitDraft'];
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const path = filePath(file);
|
||||
// Collapsed and viewed are independent — you can fold a file you haven't read
|
||||
// and read one you leave open — but a file already marked viewed opens folded,
|
||||
// and the checkbox folds it for you (see toggleViewed).
|
||||
const [collapsed, setCollapsed] = useState(viewed);
|
||||
const [drag, setDrag] = useState<DragState | null>(null);
|
||||
|
||||
// A jump from the comments rail can target a thread inside a collapsed file;
|
||||
// opening the file here is what puts that thread in the DOM for the scroll to
|
||||
// find. Keyed on the request's sequence number, so clicking the same comment
|
||||
// again after re-folding the file opens it again.
|
||||
useEffect(() => {
|
||||
if (reveal != null) setCollapsed(false);
|
||||
}, [reveal]);
|
||||
|
||||
// Losing the viewed mark to a change is the one thing that unfolds a file on
|
||||
// its own. Marking it viewed folded it away; the code under that fold is no
|
||||
// longer the code you approved, so it comes back open.
|
||||
useEffect(() => {
|
||||
if (changed) setCollapsed(false);
|
||||
}, [changed]);
|
||||
|
||||
// Comments still anchored in this diff render against their line (or, for
|
||||
// file-level ones, at the top of the file). The rest are outdated: kept, but
|
||||
// gathered above the code with a note, since the line they named is gone.
|
||||
const lineComments = comments.filter(
|
||||
(c) => c.level === 'line' && !outdated.has(c.id),
|
||||
);
|
||||
const fileComments = comments.filter((c) => c.level === 'file');
|
||||
const staleComments = comments.filter(
|
||||
(c) => c.level === 'line' && outdated.has(c.id),
|
||||
);
|
||||
|
||||
// Fetch the base-side source so collapsed context can be expanded on demand.
|
||||
// Added files have no base version, so expansion is disabled for them.
|
||||
const [oldSource, setOldSource] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (file.type === 'add') {
|
||||
setOldSource(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
api.fileContent(base, file.oldPath).then((s) => {
|
||||
if (!cancelled) setOldSource(s);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [base, file.oldPath, file.type]);
|
||||
|
||||
const [hunks, expandRange] = useSourceExpansion(file.hunks, oldSource);
|
||||
const canExpand = oldSource != null;
|
||||
// Number of lines in the base file, ignoring the trailing newline so we don't
|
||||
// count a phantom empty line at the end.
|
||||
const totalOldLines = useMemo(
|
||||
() => (oldSource != null ? oldSource.replace(/\n$/, '').split('\n').length : null),
|
||||
[oldSource],
|
||||
);
|
||||
|
||||
// Highlighting is done over the *whole* file, never over the visible hunks
|
||||
// alone. Prism is a stateful tokenizer: a construct that opens above the first
|
||||
// visible line — a block comment, a template literal, a heredoc — leaves it in
|
||||
// the wrong state and mis-colours everything after it, so what got highlighted
|
||||
// would depend on which context happened to be collapsed. Handing it the base
|
||||
// source (react-diff-view derives the head side by applying `hunks`) makes the
|
||||
// result identical no matter what is expanded.
|
||||
//
|
||||
// A wholly added or deleted file needs no base source: its hunks already carry
|
||||
// every line, so tokenizing them is exact. Otherwise we wait for the fetch
|
||||
// rather than highlight a fragment — a beat of plain text beats wrong colours.
|
||||
const tokens: HunkTokens | undefined = useMemo(() => {
|
||||
const lang = languageForFile(path);
|
||||
if (!lang) return undefined;
|
||||
const whole = file.type === 'add' ? undefined : (oldSource ?? undefined);
|
||||
if (file.type !== 'add' && whole === undefined) return undefined;
|
||||
if (whole !== undefined && whole.length > MAX_HIGHLIGHT_BYTES) return undefined;
|
||||
try {
|
||||
return tokenize(hunks, {
|
||||
highlight: true,
|
||||
refractor: refractorAdapter,
|
||||
language: lang,
|
||||
oldSource: whole,
|
||||
// Word-level marks inside a changed line, the way GitHub shows them.
|
||||
enhancers: [markEdits(hunks, { type: 'block' })],
|
||||
});
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}, [hunks, path, oldSource, file.type]);
|
||||
|
||||
// Map "side:line" -> react-diff-view change key, so we can attach widgets.
|
||||
const lineKeyToChangeKey = useMemo(() => changeKeyIndex(hunks), [hunks]);
|
||||
|
||||
// rangeKeys returns the change keys of lines [start, end] on a side, used to
|
||||
// highlight a selection or an existing comment's range.
|
||||
const rangeKeys = useCallback(
|
||||
(side: Side, start: number, end: number): string[] => {
|
||||
const lo = Math.min(start, end);
|
||||
const hi = Math.max(start, end);
|
||||
const keys: string[] = [];
|
||||
for (const hunk of hunks) {
|
||||
for (const change of hunk.changes) {
|
||||
const l = lineFor(change, side);
|
||||
if (l != null && l >= lo && l <= hi) keys.push(getChangeKey(change));
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
},
|
||||
[hunks],
|
||||
);
|
||||
|
||||
// Group line comments (and the active line-draft composer) by change key. A
|
||||
// range comment anchors to its end line. Anything that isn't outdated has a
|
||||
// line in this diff by construction, so a missing key here would mean the
|
||||
// anchor index and the rendered hunks disagreed — drop it rather than render
|
||||
// the thread against the wrong line; it still shows in the comments rail.
|
||||
const widgets = useMemo(() => {
|
||||
const contentByKey: Record<string, ReactNode[]> = {};
|
||||
|
||||
const grouped: Record<string, Comment[]> = {};
|
||||
for (const c of lineComments) {
|
||||
const key = lineKeyToChangeKey[`${c.side}:${anchorLine(c)}`];
|
||||
if (key) (grouped[key] ??= []).push(c);
|
||||
}
|
||||
for (const [key, cs] of Object.entries(grouped)) {
|
||||
contentByKey[key] = [
|
||||
<CommentThread key="thread" comments={cs} onChanged={onChanged} />,
|
||||
];
|
||||
}
|
||||
|
||||
if (draft?.level === 'line' && draft.file === path) {
|
||||
(contentByKey[draft.changeKey] ??= []).push(
|
||||
<Composer key="composer" onSubmit={onSubmitDraft} onCancel={onCancelDraft} />,
|
||||
);
|
||||
}
|
||||
|
||||
const built: Record<string, ReactNode> = {};
|
||||
for (const [key, nodes] of Object.entries(contentByKey)) {
|
||||
built[key] = <div className="line-widget">{nodes}</div>;
|
||||
}
|
||||
return built;
|
||||
}, [lineComments, lineKeyToChangeKey, draft, path, onChanged, onSubmitDraft, onCancelDraft]);
|
||||
|
||||
// Highlight the lines being dragged, or the pending line-draft's range.
|
||||
const selectedKeys = useMemo(() => {
|
||||
if (drag) return rangeKeys(drag.side, drag.anchor, drag.head);
|
||||
if (draft?.level === 'line' && draft.file === path) {
|
||||
return rangeKeys(draft.side, draft.startLine, draft.endLine);
|
||||
}
|
||||
return [];
|
||||
}, [drag, draft, path, rangeKeys]);
|
||||
const selectedSet = useMemo(() => new Set(selectedKeys), [selectedKeys]);
|
||||
|
||||
const generateLineClassName = useCallback(
|
||||
({ changes }: { changes: ChangeData[] }) => {
|
||||
// A split-view row can have an empty side, so `changes` may contain a
|
||||
// falsy slot — getChangeKey() throws on those. Skip work when nothing is
|
||||
// selected, and guard falsy changes otherwise.
|
||||
if (selectedSet.size === 0) return '';
|
||||
return changes.some((c) => c && selectedSet.has(getChangeKey(c)))
|
||||
? 'line-selected'
|
||||
: '';
|
||||
},
|
||||
[selectedSet],
|
||||
);
|
||||
|
||||
// Click-and-drag range selection on the gutter (GitHub style).
|
||||
const gutterEvents = useMemo(
|
||||
() => ({
|
||||
onMouseDown: (
|
||||
{ change, side }: { change: ChangeData | null; side?: Side },
|
||||
e: { preventDefault(): void },
|
||||
) => {
|
||||
if (!change) return;
|
||||
const s = side ?? 'new';
|
||||
const line = lineFor(change, s);
|
||||
if (line == null) return;
|
||||
e.preventDefault();
|
||||
setDrag({ side: s, anchor: line, head: line });
|
||||
},
|
||||
onMouseEnter: ({ change, side }: { change: ChangeData | null; side?: Side }) => {
|
||||
setDrag((d) => {
|
||||
if (!d || !change || (side ?? 'new') !== d.side) return d;
|
||||
const line = lineFor(change, d.side);
|
||||
return line == null ? d : { ...d, head: line };
|
||||
});
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// Finish a drag anywhere on the page: open a composer for the selected range.
|
||||
useEffect(() => {
|
||||
if (!drag) return;
|
||||
const onUp = () => {
|
||||
const start = Math.min(drag.anchor, drag.head);
|
||||
const end = Math.max(drag.anchor, drag.head);
|
||||
const key = lineKeyToChangeKey[`${drag.side}:${end}`];
|
||||
setDrag(null);
|
||||
if (key) {
|
||||
onStartDraft({
|
||||
level: 'line',
|
||||
file: path,
|
||||
side: drag.side,
|
||||
startLine: start,
|
||||
endLine: end,
|
||||
changeKey: key,
|
||||
});
|
||||
}
|
||||
};
|
||||
window.addEventListener('mouseup', onUp);
|
||||
return () => window.removeEventListener('mouseup', onUp);
|
||||
}, [drag, lineKeyToChangeKey, onStartDraft, path]);
|
||||
|
||||
const openCount = comments.filter((c) => c.status !== 'resolved').length;
|
||||
const additions = countChanges(file, 'insert');
|
||||
const deletions = countChanges(file, 'delete');
|
||||
|
||||
// Marking a file viewed folds it away, and unmarking brings it back — the
|
||||
// reason you'd touch the checkbox is that you're done with (or returning to)
|
||||
// this file, so the fold is the point.
|
||||
const toggleViewed = () => {
|
||||
onSetViewed(path, !viewed);
|
||||
setCollapsed(!viewed);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={`file${viewed ? ' is-viewed' : ''}`} id={`file-${path}`}>
|
||||
<header
|
||||
className={`file-head${collapsed ? ' is-collapsed' : ''}${
|
||||
viewed ? ' is-viewed' : ''
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
className="file-collapse"
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
aria-label={collapsed ? 'Expand' : 'Collapse'}
|
||||
>
|
||||
<Icon name={collapsed ? 'chevron-right' : 'chevron-down'} />
|
||||
</button>
|
||||
<span className="file-path">{path}</span>
|
||||
<button
|
||||
className="icon-btn"
|
||||
onClick={() => navigator.clipboard?.writeText(path)}
|
||||
title="Copy path"
|
||||
aria-label="Copy path"
|
||||
>
|
||||
<Icon name="copy" />
|
||||
</button>
|
||||
{file.type !== 'modify' && (
|
||||
<span className={`file-status file-status-${file.type}`}>
|
||||
{statusLabel(file.type)}
|
||||
</span>
|
||||
)}
|
||||
{file.type === 'rename' && (
|
||||
<span className="file-rename muted">← {file.oldPath}</span>
|
||||
)}
|
||||
{changed && !viewed && (
|
||||
<span
|
||||
className="file-status file-status-changed"
|
||||
title="This file's diff changed since you marked it viewed, so the mark came off"
|
||||
>
|
||||
changed since viewed
|
||||
</span>
|
||||
)}
|
||||
<span className="file-head-right">
|
||||
<span className="file-stat file-stat-add">+{additions}</span>
|
||||
<span className="file-stat file-stat-del">−{deletions}</span>
|
||||
<DiffStat additions={additions} deletions={deletions} />
|
||||
<label
|
||||
className={`viewed-check${viewed ? ' is-on' : ''}`}
|
||||
title={
|
||||
viewed
|
||||
? 'Mark as not viewed (expands the file)'
|
||||
: 'Mark as viewed (collapses the file)'
|
||||
}
|
||||
>
|
||||
<input type="checkbox" checked={viewed} onChange={toggleViewed} />
|
||||
Viewed
|
||||
</label>
|
||||
<button
|
||||
className="icon-btn has-label"
|
||||
onClick={() => onStartDraft({ level: 'file', file: path })}
|
||||
title={
|
||||
openCount > 0
|
||||
? `${openCount} open comment${openCount === 1 ? '' : 's'} — add another`
|
||||
: 'Comment on this file'
|
||||
}
|
||||
>
|
||||
<Icon name="comment" />
|
||||
{openCount > 0 && openCount}
|
||||
</button>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{!collapsed && (
|
||||
<>
|
||||
{(fileComments.length > 0 ||
|
||||
(draft?.level === 'file' && draft.file === path)) && (
|
||||
<div className="file-level-comments">
|
||||
{fileComments.length > 0 && (
|
||||
<CommentThread comments={fileComments} onChanged={onChanged} />
|
||||
)}
|
||||
{draft?.level === 'file' && draft.file === path && (
|
||||
<Composer onSubmit={onSubmitDraft} onCancel={onCancelDraft} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{staleComments.length > 0 && (
|
||||
<OutdatedNote comments={staleComments} onChanged={onChanged} />
|
||||
)}
|
||||
{file.isBinary ? (
|
||||
<div className="binary-note">Binary file not shown.</div>
|
||||
) : (
|
||||
<Diff
|
||||
className={drag ? 'is-dragging' : undefined}
|
||||
diffType={file.type}
|
||||
viewType={viewType}
|
||||
hunks={hunks}
|
||||
tokens={tokens}
|
||||
widgets={widgets}
|
||||
gutterType="default"
|
||||
gutterEvents={gutterEvents}
|
||||
selectedChanges={selectedKeys}
|
||||
generateLineClassName={generateLineClassName}
|
||||
optimizeSelection
|
||||
>
|
||||
{(renderHunks) => {
|
||||
const out: ReactElement[] = [];
|
||||
renderHunks.forEach((hunk, i) => {
|
||||
const prev: HunkData | null = i > 0 ? renderHunks[i - 1] : null;
|
||||
const collapsed = getCollapsedLinesCountBetween(prev, hunk);
|
||||
// Ranges are [start, end) — end is EXCLUSIVE, matching
|
||||
// react-diff-view's expandFromRawCode (slice semantics).
|
||||
const start = prev ? prev.oldStart + prev.oldLines : 1;
|
||||
out.push(
|
||||
<Decoration key={`deco-${i}`}>
|
||||
<UnfoldHeader
|
||||
content={hunk.content}
|
||||
collapsed={collapsed}
|
||||
canExpand={canExpand}
|
||||
rangeStart={start}
|
||||
rangeEnd={start + collapsed}
|
||||
position={i === 0 ? 'leading' : 'middle'}
|
||||
onExpand={expandRange}
|
||||
/>
|
||||
</Decoration>,
|
||||
);
|
||||
out.push(<Hunk key={`hunk-${i}`} hunk={hunk} />);
|
||||
});
|
||||
// Trailing gap: lines after the last hunk to end of file.
|
||||
const last = renderHunks[renderHunks.length - 1];
|
||||
if (last && canExpand && totalOldLines != null) {
|
||||
const start = last.oldStart + last.oldLines;
|
||||
const collapsed = totalOldLines - start + 1;
|
||||
if (collapsed > 0) {
|
||||
out.push(
|
||||
<Decoration key="deco-tail">
|
||||
<UnfoldHeader
|
||||
content=""
|
||||
collapsed={collapsed}
|
||||
canExpand={canExpand}
|
||||
rangeStart={start}
|
||||
rangeEnd={start + collapsed}
|
||||
position="trailing"
|
||||
onExpand={expandRange}
|
||||
/>
|
||||
</Decoration>,
|
||||
);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}}
|
||||
</Diff>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// Lines revealed per click on a directional expander, as on GitHub.
|
||||
const CHUNK = 20;
|
||||
|
||||
// Where a gap of hidden lines sits relative to the hunks around it. It decides
|
||||
// which way the gap can be opened: one above the first hunk can only be walked
|
||||
// upwards from that hunk, one after the last only downwards from where it
|
||||
// ended, and one between two hunks from either end.
|
||||
type GapPosition = 'leading' | 'middle' | 'trailing';
|
||||
|
||||
// UnfoldHeader renders the hunk-header bar: an accent-tinted band carrying the
|
||||
// @@ range, plus — when there are collapsed lines above the hunk and we have the
|
||||
// base source to fill them from — GitHub's blue expander block in the
|
||||
// line-number column.
|
||||
function UnfoldHeader({
|
||||
content,
|
||||
collapsed,
|
||||
canExpand,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
position,
|
||||
onExpand,
|
||||
}: {
|
||||
content: string;
|
||||
collapsed: number;
|
||||
canExpand: boolean;
|
||||
rangeStart: number;
|
||||
// rangeEnd is EXCLUSIVE: the range [rangeStart, rangeEnd) is revealed.
|
||||
rangeEnd: number;
|
||||
position: GapPosition;
|
||||
onExpand: (start: number, end: number) => void;
|
||||
}) {
|
||||
if (!canExpand || collapsed <= 0) {
|
||||
// Still lay out the (empty) gutter block so the @@ text lines up with code.
|
||||
return (
|
||||
<div className="hunk-deco">
|
||||
<span className="unfold-controls" />
|
||||
<HunkText content={content} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A gap small enough to open in one click gets a single two-way control; there
|
||||
// is nothing for a second, identical button to do.
|
||||
const oneClick = collapsed <= CHUNK;
|
||||
const all = () => onExpand(rangeStart, rangeEnd);
|
||||
|
||||
const controls =
|
||||
oneClick && position === 'middle' ? (
|
||||
<button
|
||||
className="unfold-btn"
|
||||
title={`Expand ${collapsed} hidden line${collapsed === 1 ? '' : 's'}`}
|
||||
onClick={all}
|
||||
>
|
||||
<Icon name="unfold" />
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{position !== 'leading' && (
|
||||
<button
|
||||
className="unfold-btn"
|
||||
title={oneClick ? `Expand ${collapsed} hidden lines` : 'Expand down'}
|
||||
onClick={oneClick ? all : () => onExpand(rangeStart, rangeStart + CHUNK)}
|
||||
>
|
||||
<Icon name="fold-down" />
|
||||
</button>
|
||||
)}
|
||||
{position !== 'trailing' && (
|
||||
<button
|
||||
className="unfold-btn"
|
||||
title={oneClick ? `Expand ${collapsed} hidden lines` : 'Expand up'}
|
||||
onClick={oneClick ? all : () => onExpand(rangeEnd - CHUNK, rangeEnd)}
|
||||
>
|
||||
<Icon name="fold-up" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="hunk-deco">
|
||||
<span className="unfold-controls is-expandable">{controls}</span>
|
||||
<HunkText content={content} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// HunkText prints the hunk header the way GitHub does: the @@ range in subtle
|
||||
// text, and the enclosing declaration git tacked on after it a shade brighter.
|
||||
function HunkText({ content }: { content: string }) {
|
||||
const end = content.indexOf('@@', 2);
|
||||
if (end < 0) return <span className="unfold-text">{content}</span>;
|
||||
return (
|
||||
<span className="unfold-text">
|
||||
<span className="unfold-range">{content.slice(0, end + 2)}</span>
|
||||
{content.slice(end + 2)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// DiffStat is GitHub's five-block bar: the file's additions and deletions scaled
|
||||
// onto five squares, with any remainder left neutral. Under six total changes
|
||||
// the blocks are exact, so a one-line change reads as one green square.
|
||||
function DiffStat({
|
||||
additions,
|
||||
deletions,
|
||||
}: {
|
||||
additions: number;
|
||||
deletions: number;
|
||||
}) {
|
||||
const total = additions + deletions;
|
||||
let add = 0;
|
||||
let del = 0;
|
||||
if (total > 0 && total <= 5) {
|
||||
add = additions;
|
||||
del = deletions;
|
||||
} else if (total > 5) {
|
||||
add = Math.floor((additions / total) * 5);
|
||||
// Never round a non-empty side away to nothing.
|
||||
if (additions > 0 && add === 0) add = 1;
|
||||
if (deletions > 0 && add === 5) add = 4;
|
||||
del = 5 - add;
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className="diffstat"
|
||||
title={`${additions} addition${additions === 1 ? '' : 's'} & ${deletions} deletion${deletions === 1 ? '' : 's'}`}
|
||||
>
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={i < add ? 'is-add' : i < add + del ? 'is-del' : ''}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function countChanges(file: FileData, type: 'insert' | 'delete'): number {
|
||||
let n = 0;
|
||||
for (const hunk of file.hunks) {
|
||||
for (const change of hunk.changes) {
|
||||
if (change.type === type) n++;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import type { Comment, DiffFile } from '../types';
|
||||
import {
|
||||
buildTree,
|
||||
dirPaths,
|
||||
flatten,
|
||||
pathOf,
|
||||
type DirNode,
|
||||
type FileNode,
|
||||
} from '../lib/filetree';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
interface Props {
|
||||
files: DiffFile[];
|
||||
comments: Comment[];
|
||||
onSelect: (path: string) => void;
|
||||
}
|
||||
|
||||
type Mode = 'tree' | 'list';
|
||||
|
||||
function initialMode(): Mode {
|
||||
return localStorage.getItem('review-filelist-mode') === 'list'
|
||||
? 'list'
|
||||
: 'tree';
|
||||
}
|
||||
|
||||
// FileList is the left rail: every changed file with its stats and open-comment
|
||||
// count, either as a GitHub-style collapsible folder tree or a flat list.
|
||||
// Clicking a file scrolls to it.
|
||||
export function FileList({ files, comments, onSelect }: Props) {
|
||||
const [mode, setMode] = useState<Mode>(initialMode);
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
||||
|
||||
const openByFile = useMemo(() => {
|
||||
const m = new Map<string, number>();
|
||||
for (const c of comments) {
|
||||
if (c.status === 'resolved') continue;
|
||||
m.set(c.file, (m.get(c.file) ?? 0) + 1);
|
||||
}
|
||||
return m;
|
||||
}, [comments]);
|
||||
|
||||
const tree = useMemo(() => buildTree(files, openByFile), [files, openByFile]);
|
||||
const rows = useMemo(() => flatten(tree, collapsed), [tree, collapsed]);
|
||||
const allCollapsed = useMemo(() => {
|
||||
const dirs = dirPaths(tree);
|
||||
return dirs.length > 0 && dirs.every((p) => collapsed.has(p));
|
||||
}, [tree, collapsed]);
|
||||
|
||||
const chooseMode = (next: Mode) => {
|
||||
setMode(next);
|
||||
localStorage.setItem('review-filelist-mode', next);
|
||||
};
|
||||
|
||||
const toggleDir = (path: string) =>
|
||||
setCollapsed((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (!next.delete(path)) next.add(path);
|
||||
return next;
|
||||
});
|
||||
|
||||
const toggleAll = () =>
|
||||
setCollapsed(allCollapsed ? new Set() : new Set(dirPaths(tree)));
|
||||
|
||||
return (
|
||||
<nav className="filelist">
|
||||
<div className="filelist-head">
|
||||
<span>
|
||||
{files.length} file{files.length === 1 ? '' : 's'} changed
|
||||
</span>
|
||||
<span className="filelist-head-actions">
|
||||
{mode === 'tree' && (
|
||||
<button
|
||||
className="rail-action"
|
||||
onClick={toggleAll}
|
||||
title={allCollapsed ? 'Expand all folders' : 'Collapse all folders'}
|
||||
>
|
||||
<Icon name={allCollapsed ? 'unfold' : 'fold-up'} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="rail-action"
|
||||
onClick={() => chooseMode(mode === 'tree' ? 'list' : 'tree')}
|
||||
title={mode === 'tree' ? 'Show as flat list' : 'Show as folder tree'}
|
||||
>
|
||||
<Icon
|
||||
name={mode === 'tree' ? 'list-unordered' : 'file-directory-fill'}
|
||||
/>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul>
|
||||
{mode === 'list'
|
||||
? files.map((f) => (
|
||||
<FileRow
|
||||
key={pathOf(f)}
|
||||
node={{
|
||||
kind: 'file',
|
||||
path: pathOf(f),
|
||||
name: pathOf(f),
|
||||
file: f,
|
||||
open: openByFile.get(pathOf(f)) ?? 0,
|
||||
}}
|
||||
depth={0}
|
||||
showDir
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))
|
||||
: rows.map(({ node, depth }) =>
|
||||
node.kind === 'dir' ? (
|
||||
<DirRow
|
||||
key={`dir:${node.path}`}
|
||||
node={node}
|
||||
depth={depth}
|
||||
collapsed={collapsed.has(node.path)}
|
||||
onToggle={() => toggleDir(node.path)}
|
||||
/>
|
||||
) : (
|
||||
<FileRow
|
||||
key={node.path}
|
||||
node={node}
|
||||
depth={depth}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
// indent mirrors the tree depth; the chevron column keeps files aligned with
|
||||
// the folder name above them.
|
||||
function indent(depth: number) {
|
||||
return { paddingLeft: 8 + depth * 13 };
|
||||
}
|
||||
|
||||
function DirRow({
|
||||
node,
|
||||
depth,
|
||||
collapsed,
|
||||
onToggle,
|
||||
}: {
|
||||
node: DirNode;
|
||||
depth: number;
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
className="filelist-dir-row"
|
||||
style={indent(depth)}
|
||||
onClick={onToggle}
|
||||
title={node.path}
|
||||
aria-expanded={!collapsed}
|
||||
>
|
||||
<span className="filelist-chevron">
|
||||
<Icon name={collapsed ? 'chevron-right' : 'chevron-down'} size={12} />
|
||||
</span>
|
||||
<span className="filelist-icon">
|
||||
<Icon
|
||||
name={collapsed ? 'file-directory-fill' : 'file-directory-open-fill'}
|
||||
/>
|
||||
</span>
|
||||
<span className="filelist-folder">{node.name}</span>
|
||||
<span className="filelist-stats">
|
||||
{node.open > 0 && (
|
||||
<span className="filelist-badge">
|
||||
<Icon name="comment" size={12} />
|
||||
{node.open}
|
||||
</span>
|
||||
)}
|
||||
{collapsed && (
|
||||
<>
|
||||
<span className="add">+{node.additions}</span>
|
||||
<span className="del">−{node.deletions}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function FileRow({
|
||||
node,
|
||||
depth,
|
||||
showDir = false,
|
||||
onSelect,
|
||||
}: {
|
||||
node: FileNode;
|
||||
depth: number;
|
||||
showDir?: boolean;
|
||||
onSelect: (path: string) => void;
|
||||
}) {
|
||||
const name = showDir ? node.path.split('/').pop() : node.name;
|
||||
const dir = showDir ? node.path.slice(0, node.path.length - (name?.length ?? 0)) : '';
|
||||
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
className="filelist-file-row"
|
||||
style={indent(depth)}
|
||||
onClick={() => onSelect(node.path)}
|
||||
title={node.path}
|
||||
>
|
||||
<span className="filelist-chevron" />
|
||||
<span className={`filelist-icon is-${node.file.status}`}>
|
||||
<Icon name="file-diff" />
|
||||
</span>
|
||||
<span className="filelist-name">
|
||||
{dir && <span className="filelist-dir">{dir}</span>}
|
||||
{name}
|
||||
</span>
|
||||
<span className="filelist-stats">
|
||||
{node.open > 0 && (
|
||||
<span className="filelist-badge">
|
||||
<Icon name="comment" size={12} />
|
||||
{node.open}
|
||||
</span>
|
||||
)}
|
||||
<span className="add">+{node.file.additions}</span>
|
||||
<span className="del">−{node.file.deletions}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Octicons — GitHub's own icon set, inlined.
|
||||
//
|
||||
// The path data below is copied verbatim from @primer/octicons (16px variants),
|
||||
// so an icon here is the same shape GitHub draws. They are inlined rather than
|
||||
// pulled in as a dependency because we need a dozen of ~600, and a local table
|
||||
// keeps the icon set visible in one place instead of hidden behind imports.
|
||||
//
|
||||
// Every glyph is authored on a 16×16 grid with `fill: currentColor`, so colour
|
||||
// comes from the surrounding text colour and size from the `size` prop.
|
||||
|
||||
const PATHS = {
|
||||
'chevron-down':
|
||||
'M12.78 5.22a.749.749 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.06 0L3.22 6.28a.749.749 0 1 1 1.06-1.06L8 8.939l3.72-3.719a.749.749 0 0 1 1.06 0Z',
|
||||
'chevron-right':
|
||||
'M6.22 3.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L9.94 8 6.22 4.28a.75.75 0 0 1 0-1.06Z',
|
||||
'chevron-left':
|
||||
'M9.78 12.78a.75.75 0 0 1-1.06 0L4.47 8.53a.75.75 0 0 1 0-1.06l4.25-4.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042L6.06 8l3.72 3.72a.75.75 0 0 1 0 1.06Z',
|
||||
'file-directory-fill':
|
||||
'M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z',
|
||||
'file-directory-open-fill':
|
||||
'M.513 1.513A1.75 1.75 0 0 1 1.75 1h3.5c.55 0 1.07.26 1.4.7l.9 1.2a.25.25 0 0 0 .2.1H13a1 1 0 0 1 1 1v.5H2.75a.75.75 0 0 0 0 1.5h11.978a1 1 0 0 1 .994 1.117L15 13.25A1.75 1.75 0 0 1 13.25 15H1.75A1.75 1.75 0 0 1 0 13.25V2.75c0-.464.184-.91.513-1.237Z',
|
||||
'file-diff':
|
||||
'M1 1.75C1 .784 1.784 0 2.75 0h7.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16H2.75A1.75 1.75 0 0 1 1 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25V4.664a.25.25 0 0 0-.073-.177l-2.914-2.914a.25.25 0 0 0-.177-.073ZM8 3.25a.75.75 0 0 1 .75.75v1.5h1.5a.75.75 0 0 1 0 1.5h-1.5v1.5a.75.75 0 0 1-1.5 0V7h-1.5a.75.75 0 0 1 0-1.5h1.5V4A.75.75 0 0 1 8 3.25Zm-3 8a.75.75 0 0 1 .75-.75h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1-.75-.75Z',
|
||||
copy: 'M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25ZM5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z',
|
||||
unfold:
|
||||
'm8.177.677 2.896 2.896a.25.25 0 0 1-.177.427H8.75v1.25a.75.75 0 0 1-1.5 0V4H5.104a.25.25 0 0 1-.177-.427L7.823.677a.25.25 0 0 1 .354 0ZM7.25 10.75a.75.75 0 0 1 1.5 0V12h2.146a.25.25 0 0 1 .177.427l-2.896 2.896a.25.25 0 0 1-.354 0l-2.896-2.896A.25.25 0 0 1 5.104 12H7.25v-1.25Zm-5-2a.75.75 0 0 0 0-1.5h-.5a.75.75 0 0 0 0 1.5h.5ZM6 8a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1 0-1.5h.5A.75.75 0 0 1 6 8Zm2.25.75a.75.75 0 0 0 0-1.5h-.5a.75.75 0 0 0 0 1.5h.5ZM12 8a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1 0-1.5h.5A.75.75 0 0 1 12 8Zm2.25.75a.75.75 0 0 0 0-1.5h-.5a.75.75 0 0 0 0 1.5h.5Z',
|
||||
'fold-down':
|
||||
'm8.177 14.323 2.896-2.896a.25.25 0 0 0-.177-.427H8.75V7.764a.75.75 0 1 0-1.5 0V11H5.104a.25.25 0 0 0-.177.427l2.896 2.896a.25.25 0 0 0 .354 0ZM2.25 5a.75.75 0 0 0 0-1.5h-.5a.75.75 0 0 0 0 1.5h.5ZM6 4.25a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1 0-1.5h.5a.75.75 0 0 1 .75.75ZM8.25 5a.75.75 0 0 0 0-1.5h-.5a.75.75 0 0 0 0 1.5h.5ZM12 4.25a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1 0-1.5h.5a.75.75 0 0 1 .75.75Zm2.25.75a.75.75 0 0 0 0-1.5h-.5a.75.75 0 0 0 0 1.5h.5Z',
|
||||
'fold-up':
|
||||
'M7.823 1.677 4.927 4.573A.25.25 0 0 0 5.104 5H7.25v3.236a.75.75 0 1 0 1.5 0V5h2.146a.25.25 0 0 0 .177-.427L8.177 1.677a.25.25 0 0 0-.354 0ZM13.75 11a.75.75 0 0 0 0 1.5h.5a.75.75 0 0 0 0-1.5h-.5Zm-3.75.75a.75.75 0 0 1 .75-.75h.5a.75.75 0 0 1 0 1.5h-.5a.75.75 0 0 1-.75-.75ZM7.75 11a.75.75 0 0 0 0 1.5h.5a.75.75 0 0 0 0-1.5h-.5ZM4 11.75a.75.75 0 0 1 .75-.75h.5a.75.75 0 0 1 0 1.5h-.5a.75.75 0 0 1-.75-.75ZM1.75 11a.75.75 0 0 0 0 1.5h.5a.75.75 0 0 0 0-1.5h-.5Z',
|
||||
comment:
|
||||
'M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 13.25 12H9.06l-2.573 2.573A1.458 1.458 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z',
|
||||
check:
|
||||
'M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z',
|
||||
'check-circle-fill':
|
||||
'M8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16Zm3.78-9.72a.751.751 0 0 0-.018-1.042.751.751 0 0 0-1.042-.018L6.75 9.19 5.28 7.72a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042l2 2a.75.75 0 0 0 1.06 0Z',
|
||||
search:
|
||||
'M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z',
|
||||
x: 'M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z',
|
||||
plus: 'M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z',
|
||||
sync: 'M1.705 8.005a.75.75 0 0 1 .834.656 5.5 5.5 0 0 0 9.592 2.97l-1.204-1.204a.25.25 0 0 1 .177-.427h3.646a.25.25 0 0 1 .25.25v3.646a.25.25 0 0 1-.427.177l-1.38-1.38A7.002 7.002 0 0 1 1.05 8.84a.75.75 0 0 1 .656-.834ZM8 2.5a5.487 5.487 0 0 0-4.131 1.869l1.204 1.204A.25.25 0 0 1 4.896 6H1.25A.25.25 0 0 1 1 5.75V2.104a.25.25 0 0 1 .427-.177l1.38 1.38A7.002 7.002 0 0 1 14.95 7.16a.75.75 0 0 1-1.49.178A5.5 5.5 0 0 0 8 2.5Z',
|
||||
sun: 'M8 12a4 4 0 1 1 0-8 4 4 0 0 1 0 8Zm0-1.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Zm5.657-8.157a.75.75 0 0 1 0 1.061l-1.061 1.06a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l1.06-1.06a.75.75 0 0 1 1.06 0Zm-9.193 9.193a.75.75 0 0 1 0 1.06l-1.06 1.061a.75.75 0 1 1-1.061-1.06l1.06-1.061a.75.75 0 0 1 1.061 0ZM8 0a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0V.75A.75.75 0 0 1 8 0ZM3 8a.75.75 0 0 1-.75.75H.75a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 3 8Zm13 0a.75.75 0 0 1-.75.75h-1.5a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 16 8Zm-8 5a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 8 13Zm3.536-1.464a.75.75 0 0 1 1.06 0l1.061 1.06a.75.75 0 0 1-1.06 1.061l-1.061-1.06a.75.75 0 0 1 0-1.061ZM2.343 2.343a.75.75 0 0 1 1.061 0l1.06 1.061a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018l-1.06-1.06a.75.75 0 0 1 0-1.06Z',
|
||||
moon: 'M9.598 1.591a.749.749 0 0 1 .785-.175 7.001 7.001 0 1 1-8.967 8.967.75.75 0 0 1 .961-.96 5.5 5.5 0 0 0 7.046-7.046.75.75 0 0 1 .175-.786Zm1.616 1.945a7 7 0 0 1-7.678 7.678 5.499 5.499 0 1 0 7.678-7.678Z',
|
||||
'list-unordered':
|
||||
'M5.75 2.5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5Zm0 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5Zm0 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5ZM2 14a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm1-6a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM2 4a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z',
|
||||
star: 'M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Zm0 2.445L6.615 5.5a.75.75 0 0 1-.564.41l-3.097.45 2.24 2.184a.75.75 0 0 1 .216.664l-.528 3.084 2.769-1.456a.75.75 0 0 1 .698 0l2.77 1.456-.53-3.084a.75.75 0 0 1 .216-.664l2.24-2.183-3.096-.45a.75.75 0 0 1-.564-.41L8 2.694Z',
|
||||
'star-fill':
|
||||
'M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Z',
|
||||
reply:
|
||||
'M6.78 1.97a.75.75 0 0 1 0 1.06L3.81 6h6.44A4.75 4.75 0 0 1 15 10.75v2.5a.75.75 0 0 1-1.5 0v-2.5a3.25 3.25 0 0 0-3.25-3.25H3.81l2.97 2.97a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L1.47 7.28a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z',
|
||||
'git-commit':
|
||||
'M11.93 8.5a4.002 4.002 0 0 1-7.86 0H.75a.75.75 0 0 1 0-1.5h3.32a4.002 4.002 0 0 1 7.86 0h3.32a.75.75 0 0 1 0 1.5Zm-1.43-.75a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z',
|
||||
'git-branch':
|
||||
'M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z',
|
||||
columns:
|
||||
'M2.75 0h2.5C6.216 0 7 .784 7 1.75v12.5A1.75 1.75 0 0 1 5.25 16h-2.5A1.75 1.75 0 0 1 1 14.25V1.75C1 .784 1.784 0 2.75 0Zm8 0h2.5C14.216 0 15 .784 15 1.75v12.5A1.75 1.75 0 0 1 13.25 16h-2.5A1.75 1.75 0 0 1 9 14.25V1.75C9 .784 9.784 0 10.75 0ZM2.5 1.75v12.5c0 .138.112.25.25.25h2.5a.25.25 0 0 0 .25-.25V1.75a.25.25 0 0 0-.25-.25h-2.5a.25.25 0 0 0-.25.25Zm8 0v12.5c0 .138.112.25.25.25h2.5a.25.25 0 0 0 .25-.25V1.75a.25.25 0 0 0-.25-.25h-2.5a.25.25 0 0 0-.25.25Z',
|
||||
rows: 'M16 10.75v2.5A1.75 1.75 0 0 1 14.25 15H1.75A1.75 1.75 0 0 1 0 13.25v-2.5C0 9.784.784 9 1.75 9h12.5c.966 0 1.75.784 1.75 1.75Zm0-8v2.5A1.75 1.75 0 0 1 14.25 7H1.75A1.75 1.75 0 0 1 0 5.25v-2.5C0 1.784.784 1 1.75 1h12.5c.966 0 1.75.784 1.75 1.75Zm-1.75-.25H1.75a.25.25 0 0 0-.25.25v2.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-2.5a.25.25 0 0 0-.25-.25Zm0 8H1.75a.25.25 0 0 0-.25.25v2.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-2.5a.25.25 0 0 0-.25-.25Z',
|
||||
repo: 'M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8ZM5 12.25a.25.25 0 0 1 .25-.25h3.5a.25.25 0 0 1 .25.25v3.25a.25.25 0 0 1-.4.2l-1.45-1.087a.249.249 0 0 0-.3 0L5.4 15.7a.25.25 0 0 1-.4-.2Z',
|
||||
'dot-fill': 'M8 4a4 4 0 1 1 0 8 4 4 0 0 1 0-8Z',
|
||||
trash:
|
||||
'M11 1.75V3h2.25a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75ZM4.496 6.675l.66 6.6a.25.25 0 0 0 .249.225h5.19a.25.25 0 0 0 .249-.225l.66-6.6a.75.75 0 0 1 1.492.149l-.66 6.6A1.748 1.748 0 0 1 10.595 15h-5.19a1.75 1.75 0 0 1-1.741-1.575l-.66-6.6a.75.75 0 1 1 1.492-.15ZM6.5 1.75V3h3V1.75a.25.25 0 0 0-.25-.25h-2.5a.25.25 0 0 0-.25.25Z',
|
||||
home: 'M6.906.664a1.749 1.749 0 0 1 2.187 0l5.25 4.2c.415.332.657.835.657 1.367v7.019A1.75 1.75 0 0 1 13.25 15h-3.5a.75.75 0 0 1-.75-.75V9H7v5.25a.75.75 0 0 1-.75.75h-3.5A1.75 1.75 0 0 1 1 13.25V6.23c0-.531.242-1.034.657-1.366l5.25-4.2Zm1.25 1.171a.25.25 0 0 0-.312 0l-5.25 4.2a.25.25 0 0 0-.094.196v7.019c0 .138.112.25.25.25H5.5V8.25a.75.75 0 0 1 .75-.75h3.5a.75.75 0 0 1 .75.75v5.25h2.75a.25.25 0 0 0 .25-.25V6.23a.25.25 0 0 0-.094-.195Z',
|
||||
'arrow-up':
|
||||
'M3.47 7.78a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0l4.25 4.25a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018L9 4.81v7.44a.75.75 0 0 1-1.5 0V4.81L4.53 7.78a.75.75 0 0 1-1.06 0Z',
|
||||
alert:
|
||||
'M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z',
|
||||
'git-pull-request':
|
||||
'M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z',
|
||||
'link-external':
|
||||
'M3.75 2h3.5a.75.75 0 0 1 0 1.5h-3.5a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-3.5a.75.75 0 0 1 1.5 0v3.5A1.75 1.75 0 0 1 12.25 14h-8.5A1.75 1.75 0 0 1 2 12.25v-8.5A1.75 1.75 0 0 1 3.75 2Zm6.854-1h3.396a.25.25 0 0 1 .25.25v3.396a.25.25 0 0 1-.427.177L12.5 3.561 8.53 7.53a.75.75 0 0 1-1.06-1.06l3.969-3.97-1.262-1.323a.25.25 0 0 1 .177-.427Z',
|
||||
pencil:
|
||||
'M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm.176 4.823L9.75 4.81l-6.286 6.287a.253.253 0 0 0-.064.108l-.558 1.953 1.953-.558a.253.253 0 0 0 .108-.064Zm1.238-3.763a.25.25 0 0 0-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 0 0 0-.354Z',
|
||||
clock:
|
||||
'M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm7-3.25v2.992l2.028.812a.75.75 0 0 1-.557 1.392l-2.5-1A.751.751 0 0 1 7 8.25v-3.5a.75.75 0 0 1 1.5 0Z',
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof PATHS;
|
||||
|
||||
interface Props {
|
||||
name: IconName;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Icon({ name, size = 16, className }: Props) {
|
||||
return (
|
||||
<svg
|
||||
className={className ? `octicon ${className}` : 'octicon'}
|
||||
viewBox="0 0 16 16"
|
||||
width={size}
|
||||
height={size}
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path d={PATHS[name]} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import type { Comment } from '../types';
|
||||
import { CommentThread } from './CommentThread';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
// Outdated comments: threads whose anchor is no longer in the diff on screen
|
||||
// (see lib/anchor). They are never hidden — a comment is something the reviewer
|
||||
// wrote, and the code moving out from under it is exactly when they most need to
|
||||
// see it again — but they can't be pinned to a line, so they get their own
|
||||
// framing that says where they used to point.
|
||||
//
|
||||
// Two shapes, by how much is missing:
|
||||
// - OutdatedNote — the file is still in the change set, one line is gone; the
|
||||
// note sits at the top of that file.
|
||||
// - OutdatedPanel — the file has left the change set entirely; the panel sits
|
||||
// below the diff, grouped by path.
|
||||
|
||||
// where describes the anchor a comment was written against.
|
||||
function where(c: Comment): string {
|
||||
if (c.level === 'file') return 'whole file';
|
||||
if (c.endLine && c.endLine !== c.line) return `L${c.line}–${c.endLine}`;
|
||||
return `L${c.endLine || c.line}`;
|
||||
}
|
||||
|
||||
// ctxLabel names the diff selection a comment was written against, for the ones
|
||||
// whose base ref is no longer the one being viewed.
|
||||
function ctxLabel(c: Comment): string {
|
||||
// A comment written while reading one commit belongs to that commit, and saying
|
||||
// so is the whole explanation for why it can't be placed here.
|
||||
if (c.context.commit) return `commit ${c.context.commit.slice(0, 7)}`;
|
||||
const base = c.context.base || 'HEAD';
|
||||
return c.context.uncommitted ? `${base} + uncommitted` : base;
|
||||
}
|
||||
|
||||
// OutdatedNote heads a file whose diff no longer contains the lines these
|
||||
// comments named.
|
||||
export function OutdatedNote({
|
||||
comments,
|
||||
onChanged,
|
||||
}: {
|
||||
comments: Comment[];
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="outdated-note">
|
||||
<div className="outdated-head">
|
||||
<Icon name="alert" size={14} />
|
||||
<strong>
|
||||
{comments.length} outdated comment{comments.length === 1 ? '' : 's'}
|
||||
</strong>
|
||||
<span className="muted">
|
||||
the line{comments.length === 1 ? '' : 's'} {comments.map(where).join(', ')}{' '}
|
||||
{comments.length === 1 ? 'is' : 'are'} no longer in this diff
|
||||
</span>
|
||||
</div>
|
||||
<div className="outdated-threads">
|
||||
<CommentThread comments={comments} onChanged={onChanged} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// OutdatedPanel collects comments on files the current diff doesn't touch at
|
||||
// all, so they stay reachable, replyable and resolvable.
|
||||
export function OutdatedPanel({
|
||||
comments,
|
||||
onChanged,
|
||||
}: {
|
||||
comments: Comment[];
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
// Group by path, files in alphabetical order, comments in line order.
|
||||
const groups = useMemo(() => {
|
||||
const byFile = new Map<string, Comment[]>();
|
||||
for (const c of comments) {
|
||||
const list = byFile.get(c.file);
|
||||
if (list) list.push(c);
|
||||
else byFile.set(c.file, [c]);
|
||||
}
|
||||
for (const [, cs] of byFile) {
|
||||
cs.sort(
|
||||
(a, b) =>
|
||||
(a.level === 'file' ? 0 : 1) - (b.level === 'file' ? 0 : 1) ||
|
||||
(a.line || 0) - (b.line || 0) ||
|
||||
a.createdAt.localeCompare(b.createdAt),
|
||||
);
|
||||
}
|
||||
return [...byFile.entries()].sort(([a], [b]) => a.localeCompare(b));
|
||||
}, [comments]);
|
||||
|
||||
if (groups.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="outdated-panel">
|
||||
<div className="outdated-panel-head">
|
||||
<Icon name="alert" size={14} />
|
||||
<span className="outdated-panel-title">
|
||||
Outdated comments
|
||||
<span className="count">{comments.length}</span>
|
||||
</span>
|
||||
<span className="muted">
|
||||
on files this diff doesn’t touch — the base ref moved, or the change was
|
||||
undone. Nothing has been lost; resolve or delete them when you’re done.
|
||||
</span>
|
||||
</div>
|
||||
{groups.map(([file, cs]) => (
|
||||
<div key={file} className="outdated-group">
|
||||
<div className="outdated-group-head">
|
||||
<span className="outdated-group-path">{file}</span>
|
||||
<span className="outdated-group-meta muted">
|
||||
{cs.map(where).join(', ')} · written against {ctxLabel(cs[0])}
|
||||
</span>
|
||||
</div>
|
||||
<CommentThread comments={cs} onChanged={onChanged} />
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { DiffFile } from '../types';
|
||||
import { ConfirmDialog } from './ConfirmDialog';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
// A diff the server refused to hand over unasked, because rendering it would
|
||||
// wedge the page (see git.Repo.Diff). Two shapes, both built from the file
|
||||
// summary that came back in its place:
|
||||
//
|
||||
// - OversizeWarning — the modal that asks, put up as soon as the diff lands.
|
||||
// - OversizeNotice — what stands in for the diff afterwards, so a dismissed
|
||||
// warning doesn't leave an empty screen with no way back.
|
||||
//
|
||||
// The size is nearly always a base ref whose history has moved on rather than a
|
||||
// genuinely enormous review, so both of them point at the base and at what to
|
||||
// switch to.
|
||||
|
||||
const n = (x: number) => x.toLocaleString();
|
||||
|
||||
// sizeLine describes the change set in one phrase: "412 files, 87,204 changed
|
||||
// lines". Binary files count for no lines, so the file count carries them.
|
||||
function sizeLine(files: DiffFile[]): string {
|
||||
const lines = files.reduce((total, f) => total + f.additions + f.deletions, 0);
|
||||
return `${n(files.length)} file${files.length === 1 ? '' : 's'}, ${n(lines)} changed line${
|
||||
lines === 1 ? '' : 's'
|
||||
}`;
|
||||
}
|
||||
|
||||
// commitAdvice points at the other way through a change set too big to render:
|
||||
// the commits are listed in the left rail whether or not the patch loaded, and
|
||||
// one of them at a time costs nothing.
|
||||
function commitAdvice(commits: number): string | null {
|
||||
if (commits === 0) return null;
|
||||
return (
|
||||
'The commits it spans are listed in the left rail — reading one at a time ' +
|
||||
'renders only that commit, however big the whole range is.'
|
||||
);
|
||||
}
|
||||
|
||||
// advice suggests the way out, which depends on what the base already is.
|
||||
function advice(base: string, suggested: string): string {
|
||||
if (base === 'HEAD') {
|
||||
return 'That is a lot of uncommitted work for one screen.';
|
||||
}
|
||||
const alternative = suggested && suggested !== base ? `${suggested}, or HEAD` : 'HEAD';
|
||||
return (
|
||||
`A diff this size usually means ${base} has moved on since this work was ` +
|
||||
`cut from it, so the change set is padded with commits nobody is reviewing. ` +
|
||||
`Switching the base to ${alternative} will show only the work itself.`
|
||||
);
|
||||
}
|
||||
|
||||
export function OversizeWarning({
|
||||
files,
|
||||
base,
|
||||
suggested,
|
||||
commits,
|
||||
onLoad,
|
||||
onCancel,
|
||||
}: {
|
||||
files: DiffFile[];
|
||||
base: string;
|
||||
suggested: string;
|
||||
commits: number;
|
||||
onLoad: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
return (
|
||||
<ConfirmDialog
|
||||
title="This diff is very large"
|
||||
confirmLabel="Load it anyway"
|
||||
onConfirm={onLoad}
|
||||
onCancel={onCancel}
|
||||
>
|
||||
<p>
|
||||
<code>{base}</code> gives <strong>{sizeLine(files)}</strong>. Rendering
|
||||
that much at once can leave the page unresponsive for a while.
|
||||
</p>
|
||||
<p>{advice(base, suggested)}</p>
|
||||
{commitAdvice(commits) && <p>{commitAdvice(commits)}</p>}
|
||||
</ConfirmDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function OversizeNotice({
|
||||
files,
|
||||
base,
|
||||
suggested,
|
||||
commits,
|
||||
onLoad,
|
||||
}: {
|
||||
files: DiffFile[];
|
||||
base: string;
|
||||
suggested: string;
|
||||
commits: number;
|
||||
onLoad: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="oversize-notice">
|
||||
<Icon name="alert" size={20} />
|
||||
<h2>Diff not loaded</h2>
|
||||
<p>
|
||||
<code>{base}</code> gives {sizeLine(files)} — enough to make the page
|
||||
unresponsive, so it wasn’t rendered.
|
||||
</p>
|
||||
<p>{advice(base, suggested)}</p>
|
||||
{commitAdvice(commits) && <p>{commitAdvice(commits)}</p>}
|
||||
<button className="btn-ghost" onClick={onLoad}>
|
||||
Load it anyway
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
width: number;
|
||||
min: number;
|
||||
max: number;
|
||||
onChange: (width: number) => void;
|
||||
onReset: () => void;
|
||||
// Which panel this divider sizes. A right-hand panel grows as the pointer
|
||||
// moves left, so the delta is mirrored.
|
||||
panel?: 'left' | 'right';
|
||||
}
|
||||
|
||||
// Resizer is a draggable divider between a side panel and the diff. It also
|
||||
// takes focus so the panel can be sized with the arrow keys.
|
||||
export function Resizer({
|
||||
width,
|
||||
min,
|
||||
max,
|
||||
onChange,
|
||||
onReset,
|
||||
panel = 'left',
|
||||
}: Props) {
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const start = useRef({ x: 0, width: 0 });
|
||||
const sign = panel === 'right' ? -1 : 1;
|
||||
|
||||
const clamp = useCallback(
|
||||
(w: number) => Math.min(Math.max(w, min), max),
|
||||
[min, max],
|
||||
);
|
||||
|
||||
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
start.current = { x: e.clientX, width };
|
||||
setDragging(true);
|
||||
document.body.classList.add('is-resizing');
|
||||
};
|
||||
|
||||
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!dragging) return;
|
||||
onChange(clamp(start.current.width + sign * (e.clientX - start.current.x)));
|
||||
};
|
||||
|
||||
const stop = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!dragging) return;
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
setDragging(false);
|
||||
document.body.classList.remove('is-resizing');
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const step = (e.shiftKey ? 48 : 16) * sign;
|
||||
if (e.key === 'ArrowLeft') onChange(clamp(width - step));
|
||||
else if (e.key === 'ArrowRight') onChange(clamp(width + step));
|
||||
else if (e.key === 'Home' || e.key === 'Enter') onReset();
|
||||
else return;
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`resizer${dragging ? ' is-dragging' : ''}`}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize sidebar"
|
||||
aria-valuenow={width}
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={max}
|
||||
tabIndex={0}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={stop}
|
||||
onPointerCancel={stop}
|
||||
onDoubleClick={onReset}
|
||||
onKeyDown={onKeyDown}
|
||||
title="Drag to resize · double-click to reset"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Comment } from '../types';
|
||||
import { CommentThread } from './CommentThread';
|
||||
import { Composer } from './Composer';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
interface Props {
|
||||
comments: Comment[]; // review-level comments
|
||||
draftActive: boolean;
|
||||
onStart: () => void;
|
||||
onSubmit: (body: string) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
onChanged: () => void;
|
||||
}
|
||||
|
||||
// ReviewPanel holds general comments about the whole change set (not tied to any
|
||||
// file or line), shown above the file diffs.
|
||||
export function ReviewPanel({
|
||||
comments,
|
||||
draftActive,
|
||||
onStart,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
onChanged,
|
||||
}: Props) {
|
||||
const empty = comments.length === 0 && !draftActive;
|
||||
|
||||
return (
|
||||
<section className="review-panel">
|
||||
<div className="review-panel-head">
|
||||
<span className="review-panel-title">Review discussion</span>
|
||||
{!draftActive && (
|
||||
<button className="btn-ghost" onClick={onStart}>
|
||||
<Icon name="plus" size={14} /> general comment
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{empty ? (
|
||||
<p className="review-panel-empty">
|
||||
No general comments yet — leave one about the overall change set.
|
||||
</p>
|
||||
) : (
|
||||
<div className="review-threads">
|
||||
{comments.length > 0 && (
|
||||
<CommentThread comments={comments} onChanged={onChanged} />
|
||||
)}
|
||||
{draftActive && <Composer onSubmit={onSubmit} onCancel={onCancel} />}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { DiffFile } from '../types';
|
||||
import { pathOf } from '../lib/filetree';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
interface Props {
|
||||
files: DiffFile[];
|
||||
viewed: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
// weightOf is a file's share of the review. Changed lines, not file count, is
|
||||
// what reading a diff actually costs — a 400-line rewrite isn't one thirtieth of
|
||||
// a 30-file branch just because it's one file. Files with no counted lines (pure
|
||||
// renames, binaries) still weigh 1 so they can't vanish from the total.
|
||||
function weightOf(f: DiffFile): number {
|
||||
return Math.max(1, f.additions + f.deletions);
|
||||
}
|
||||
|
||||
// ReviewProgress is the right end of the tab bar: how much of the diff — by
|
||||
// weight, not by file — you've marked viewed.
|
||||
export function ReviewProgress({ files, viewed }: Props) {
|
||||
if (files.length === 0) return null;
|
||||
|
||||
let total = 0;
|
||||
let done = 0;
|
||||
let seen = 0;
|
||||
for (const f of files) {
|
||||
const w = weightOf(f);
|
||||
total += w;
|
||||
if (viewed.has(pathOf(f))) {
|
||||
done += w;
|
||||
seen++;
|
||||
}
|
||||
}
|
||||
|
||||
const complete = seen === files.length;
|
||||
// Don't let rounding show 100% with files still unread: a big file marked
|
||||
// viewed can swamp a one-liner that hasn't been.
|
||||
const pct = complete ? 100 : Math.min(99, Math.round((done / total) * 100));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`review-progress${complete ? ' is-complete' : ''}`}
|
||||
title={`${pct}% of the diff viewed — ${seen} of ${files.length} file${
|
||||
files.length === 1 ? '' : 's'
|
||||
}`}
|
||||
>
|
||||
<span className="review-progress-track">
|
||||
<span className="review-progress-fill" style={{ width: `${pct}%` }} />
|
||||
</span>
|
||||
<span className="review-progress-label">
|
||||
{complete && <Icon name="check-circle-fill" size={12} />}
|
||||
{pct}%
|
||||
</span>
|
||||
<span className="review-progress-files">
|
||||
{seen}/{files.length}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user