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 (
{coloured.map((html, index) => { const row = rows[index]; const type = row?.type ?? 'context'; return (
= context.from && index <= context.to && 'bg-key/10', )} > {row ? (row.newLine ?? row.oldLine ?? '') : (anchor.line ?? '')} {SIGN[type]}
); })}
); } /** * 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 )}
)} {threadStack(thread).map((statement) => { const said = verdictLook(statement.verdict); return ( 0 ? 'ml-6' : ''} mark={ statement.verdict !== 'comment' && ( {said.label} {outdated(statement) && ( outdated )} ) } action={ onReply && session && statement.uri && ( ) } /> ); })} {composing && thread.some((s) => s.uri === composing.uri) && (