import { GitCommitVerticalIcon } from 'lucide-react';
import { useState } from 'react';
import { Button } from '#/components/atoms/button.jsx';
import { Link } from '#/components/atoms/link.jsx';
import { Skeleton } from '#/components/atoms/skeleton.jsx';
import { Textarea } from '#/components/atoms/textarea.jsx';
import { AuthorFaces } from '#/components/molecules/author-facepile.jsx';
import { StatementCard } from '#/components/molecules/statement-card.jsx';
import { checkLook, latestBySha, summarize } from '#/lib/checks.js';
import {
commentFragment,
threadStack,
threadSummaries,
} from '#/lib/comments.js';
import { ROW_STYLE, SIGN, SIGN_STYLE } from '#/lib/diff-rows.js';
import { timeAgo } from '#/lib/format.js';
import { highlightToLines, languageFor } from '#/lib/highlight.js';
import { useSession } from '#/lib/session.jsx';
import { cn } from '#/lib/utils.js';
import { verdictLook } from '#/lib/verdicts.js';
/**
* The code an anchored comment speaks to, drawn the way the diff draws it:
* the same sign in the gutter and the same weight behind an added or a
* deleted line, so a quoted line looks like the line it quotes.
*
* Where the comment is on the commit in view the lines either side read with
* it, and the line itself is marked. Where it is not, the anchor's own record
* of the line is all there is, and a snippet has no side to draw.
*/
function AnchoredCode({ anchor, context }) {
const rows = context?.lines ?? [];
const text = context
? rows.map((row) => row.text).join('\n')
: (anchor.snippet ?? '');
if (!text) return null;
const coloured = highlightToLines(text, languageFor(anchor.path));
return (
);
}
/**
* What happened to one pull request, in the order it happened: the commits its
* author pushed and the threads anyone opened, on one list.
*
* The list runs oldest first, against the newest-first the rest of the app
* reads. This one is a story rather than a listing, and a push answered by
* a comment only makes sense in that order.
*
* A thread anchored to a line is a place in the diff, which is the tab
* beside this one, so it appears here as one line that opens there. A
* thread about the whole pull request has no such place, so it appears in
* full: root and replies, with a Reply under each statement that can be
* answered. Inside a thread the conversation runs oldest first too.
*
* The commits are the tip's, so a rebased branch lists what it carries now.
* A statement made against a version that was rewritten still shows, with
* the version tag saying which tip it answered.
*
* A commit that was a tip when the runner saw it carries what the run said,
* so the commit that broke the build is the one with the mark against it. One
* that was never a tip carries nothing, because nothing ran against it: the
* runner checks a push, and a commit arriving under a later one is checked
* only as part of what it arrived with.
*/
export function Timeline({
loading = false,
checks,
replaced,
statements,
commits,
sha,
contextFor,
commitHref,
onReply,
threadHref,
}) {
const { session } = useSession();
/** The statement being answered, or null. */
const [composing, setComposing] = useState(null);
const [note, setNote] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const threads = threadSummaries(statements);
// The runs against each commit, so a row can say what happened to it. The
// latest per workflow, the way a commit's own page counts them.
const ran = latestBySha(checks ?? []);
// One list, in the order the events landed. A thread sits where it was
// opened, not where it was last answered. A commit dates in epoch ms and a
// statement in an ISO string, which Date reads either way.
//
// A commit is placed by when it was written, not when it was committed. A
// rebase rewrites every commit it moves and stamps them all with the
// moment it ran, so committer dates put the commit that caused the rebase
// before the ones it moved. Author dates survive it.
const events = [
...(commits ?? []).map((commit) => ({
key: `commit:${commit.sha}`,
at: commit.authoredAt ?? commit.committedAt,
commit,
})),
...threads.map((thread) => ({
key: `thread:${thread.root.by?.did}:${thread.root.reviewedAt}`,
at: thread.root.reviewedAt,
thread,
})),
].sort((a, b) => new Date(a.at) - new Date(b.at));
// Nothing has arrived yet is not the same as nothing was said. The story
// holds its shape while its commits and statements are read.
if (loading && events.length === 0) {
return (
);
}
if (events.length === 0) {
return (
Nothing pushed or said about this pull request yet.
);
}
const post = async (event) => {
event.preventDefault();
const target = composing;
setBusy(true);
setError('');
try {
await onReply({
note: note.trim(),
replyTo: { uri: target.uri, cid: target.cid },
});
setNote('');
// Another statement may have been picked while this posted; only the
// composer this post came from closes.
setComposing((open) => (open === target ? null : open));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
/** Whether a statement answers a commit the branch has since replaced. */
const outdated = (statement) => replaced?.has(statement.sha);
return (
{events.map((event) => {
// A commit the author pushed. It opens on its own page, the way a
// row of the commit listing does.
if (event.commit) {
const { commit } = event;
const summary = summarize(ran.get(commit.sha) ?? []);
const look = summary ? checkLook(summary.status) : null;
return (
{/* Whose commit it is, as a face rather than a name: the
message is what the row is for, and a name beside every one
of a dozen commits is the same word a dozen times. */}
{commit.message.split('\n')[0]}
{commit.sha.slice(0, 8)}
{timeAgo(commit.authoredAt ?? commit.committedAt)}
{look && (
)}
);
}
const { root, thread } = event.thread;
// A thread anchored to a line reads with the line it is about, the
// way it would in the diff. The snippet is what the anchor recorded
// when the comment was written, so it is the code as it stood then.
const anchor = root.anchor?.path ? root.anchor : null;
const fragment = anchor ? commentFragment(anchor.path, anchor) : '';
// Only a comment on the version in view has a line to open. One
// written against a tip the branch has since replaced is placed
// nowhere in this diff, so a link would scroll to nothing.
const inDiff = anchor && root.sha === sha;
return (
{anchor && (
{anchor.path}
{anchor.line
? anchor.endLine && anchor.endLine !== anchor.line
? `:${anchor.line}-${anchor.endLine}`
: `:${anchor.line}`
: ''}
{inDiff && threadHref ? (
Open in the diff
) : (
Outdated
)}