import { Popover } from '@base-ui/react/popover'; import { useQuery } from '@tanstack/react-query'; import { ChevronDownIcon } from 'lucide-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { buttonVariants } from '#/components/atoms/button.jsx'; import { Link } from '#/components/atoms/link.jsx'; import { Skeleton } from '#/components/atoms/skeleton.jsx'; import { AuthorFacepile } from '#/components/molecules/author-facepile.jsx'; import { CommitCheckLine } from '#/components/molecules/commit-check-line.jsx'; import { DiffFile } from '#/components/molecules/diff-file.jsx'; import { DiffStat } from '#/components/molecules/diff-stat.jsx'; import { DiffTree } from '#/components/molecules/diff-tree.jsx'; import { Handle } from '#/components/molecules/handle.jsx'; import { MergeBox } from '#/components/molecules/merge-box.jsx'; import { PageBar } from '#/components/molecules/page-bar.jsx'; import { PageHead } from '#/components/molecules/page-head.jsx'; import { PageNotice } from '#/components/molecules/page-notice.jsx'; import { PullChecks } from '#/components/molecules/pull-checks.jsx'; import { PullHeader } from '#/components/molecules/pull-header.jsx'; import { PullNoteEdit } from '#/components/molecules/pull-note-edit.jsx'; import { ReadingRepo } from '#/components/molecules/reading-repo.jsx'; import { ReviewComposer } from '#/components/molecules/review-composer.jsx'; import { Screen } from '#/components/molecules/screen.jsx'; import { TabBar } from '#/components/molecules/tab-bar.jsx'; import { Timeline } from '#/components/molecules/timeline.jsx'; import { loadAuthors } from '#/lib/authors.js'; import { latestPerWorkflow } from '#/lib/checks.js'; import { diffHref, PULL_COMMIT_LIMIT, pullCommits, pullStatements, replacedTips, } from '#/lib/collab.js'; import { anchorKey, lineKey, placeComments, threadSummaries, } from '#/lib/comments.js'; import { defaultRef, isEmpty, readChange, repoRecord, repoSize, } from '#/lib/git.js'; import { COLUMN } from '#/lib/layout.js'; import { extendSelection, lineFragment, lineId, parseLineFragment, } from '#/lib/line-links.js'; import { useHash } from '#/lib/navigation.jsx'; import { publishReview } from '#/lib/publish.js'; import { useSession } from '#/lib/session.jsx'; import { cn } from '#/lib/utils.js'; /** One shared empty list, so a pane with nothing to show is stable. */ const none = []; /** How many lines either side of an anchored comment read with it. */ const CONTEXT_LINES = 3; /** * One commit, or everything between two of them, from this account's * repository or from a collaborator's copy of it. * * A commit on somebody else's branch exists only in their bundles on their * server, so `from` names the account to read it from. A `base..tip` address * reads the whole range: a pull request written over a dozen commits is one * thing to read, and reading it a commit at a time shows a file once per * commit that touched it. */ export function CommitPage({ repo, sha, from, pull, checks, reviews, tab, backTo, space, }) { const record = repoRecord(repo, space); /** Each file's section, so the tree can move to one. */ const sections = useRef(new Map()); const [picked, setPicked] = useState(''); const [base, tip] = sha.includes('..') ? sha.split('..') : [null, sha]; const { data, error } = useQuery({ queryKey: ['commit', repo, sha, from ?? '', space ?? ''], enabled: Boolean(record) && !isEmpty(record), queryFn: () => readChange(repo, sha, from, space), }); // The heading waits for the authors, so a face never lands after it. const authors = useQuery({ queryKey: ['authors'], queryFn: loadAuthors, staleTime: Number.POSITIVE_INFINITY, }); const shown = data && (authors.isSuccess || authors.isError); const pick = useCallback((path) => { setPicked(path); sections.current.get(path)?.scrollIntoView({ behavior: 'smooth', block: 'start', }); }, []); const reviewable = Boolean(base || from); const [reviewOpen, setReviewOpen] = useState(false); // A pull request opens on what has been said about it, the way a forge opens // a proposal. A commit that is nobody's proposal has only its diff. const showFiles = !pull || tab === 'files'; const showChecks = Boolean(pull) && tab === 'checks'; // The pull request's own commits, for the timeline. This is the read the // commit listing makes, under the key it makes it under, so the two share // it. const { data: log, isLoading: logLoading } = useQuery({ queryKey: ['commits', repo, '', sha, from ?? '', space ?? ''], enabled: Boolean(record) && Boolean(pull) && !showFiles, queryFn: () => pullCommits(repo, base, tip, from, PULL_COMMIT_LIMIT), }); const { session } = useSession(); // The listed reviewers' statements, placed onto this version's lines. The // visitor's own, published from this page, join in from local state: a // reader always sees their own words, listed or not. const { data: statements, isLoading: statementsLoading } = useQuery({ queryKey: ['statements', repo, space ?? ''], enabled: Boolean(record) && reviewable, queryFn: () => pullStatements(repo, space), }); const [mine, setMine] = useState( /** @type {Array>} */ ([]), ); // What a review written here names: the pull request in view, or the copy a // lone commit was read from. A commit outside any branch is named by its sha // alone. const author = pull ? pull.author.did : (from ?? null); const ref = pull ? `refs/heads/${pull.branch}` : null; const all = useMemo(() => { const listed = statements ?? []; const keyOf = (s) => `${s.by?.did}:${s.reviewedAt}`; const seen = new Set(listed.map(keyOf)); return [...listed, ...mine.filter((s) => !seen.has(keyOf(s)))]; }, [statements, mine]); const comments = useMemo( () => placeComments(all, { sha: tip, author: pull ? author : null, ref }), [all, tip, pull, author, ref], ); // The tips this branch has replaced, so a statement answering one reads as // outdated rather than as lost. const replaced = useMemo( () => pull ? replacedTips(pull, [...(checks ?? []), ...(reviews ?? [])]) : new Set(), [pull, checks, reviews], ); // What the checks tab holds: the runs against the commit this branch ends // on, one per workflow, which is what the tab draws and so what its count // has to say. const tipChecks = useMemo( () => latestPerWorkflow((checks ?? []).filter((run) => run.sha === tip)), [checks, tip], ); const conversation = useMemo( () => pull ? all.filter( (s) => (s.author === pull.author.did && s.ref?.replace(/^refs\/heads\//, '') === pull.branch) || s.sha === pull.sha, ) : [], [pull, all], ); // The lines the address names, so a link to a line marks it for whoever // opens it and the address always says what is marked. const [hash, setHash] = useHash(); const selection = useMemo(() => parseLineFragment(hash), [hash]); const selectLine = useCallback( (path, line, shift) => { const side = line.newLine != null ? 'new' : 'old'; const at = line.newLine ?? line.oldLine; if (at == null) return; setHash( lineFragment(extendSelection(selection, { path, side, at }, shift)), ); }, [selection, setHash], ); const addStatement = useCallback((statement) => { setMine((held) => [...held, statement]); }, []); const onComment = useCallback( async ({ note, replyTo, ...anchor }) => { const place = anchor.path ? anchor : undefined; const written = await publishReview(session, { repo, sha: tip, author, ref, verdict: 'comment', note, anchor: place, replyTo, }); setMine((held) => [ ...held, { // The written record's own address, so an answer to these words // has something to name before any re-read. uri: written.uri, cid: written.cid, sha: tip, author, ref, verdict: 'comment', note, ...(place ? { anchor: place } : {}), ...(replyTo ? { replyTo } : {}), reviewedAt: new Date().toISOString(), by: { did: session.did, handle: session.handle }, }, ]); }, [session, repo, tip, author, ref], ); // A link from the pull request list names a thread in the fragment, and a // shared link names a line. The diff and its comments arrive after the first // paint, so the browser's own fragment scroll finds nothing; this one waits // for them. The address is read from the window rather than from the hook // above, so a click in the gutter moves nothing under the reader. useEffect(() => { if (!data || !shown || !showFiles) return; const at = decodeURIComponent(window.location.hash.slice(1)); if (at.startsWith('comment-')) { if (comments.byFile.size === 0) return; document.getElementById(at)?.scrollIntoView({ block: 'center' }); return; } const lines = parseLineFragment(at); if (!lines) return; document .getElementById(lineId(lines.path, `${lines.side}:${lines.start}`)) ?.scrollIntoView({ block: 'center' }); }, [data, shown, comments, showFiles]); if (!record) { return No such repository.; } // An empty repository holds no commits, so no sha names one. Say so rather // than asking the server for a commit that cannot exist. if (isEmpty(record)) { return No commits yet.; } if (error) { return {error.message}; } if (!shown) { return (
); } const [subject, ...rest] = data.message.split('\n'); const body = rest.join('\n').trim(); // One function owns the shape of a pull request's address, so the divider // between the branch and what follows it is written in one place. const at = pull ? diffHref(repo, pull) : ''; const tabHref = (name) => (pull ? diffHref(repo, pull, name) : ''); const threads = threadSummaries(conversation).length; const whose = from ? `/${encodeURIComponent(from)}` : ''; const commitHref = (one) => pull ? diffHref(repo, pull, `commit/${one}`) : `/${encodeURIComponent(repo)}/commit${whose}/${one}`; const commitsAt = `/${encodeURIComponent(repo)}/commits${whose}/${sha}`; // A few lines either side of an anchored comment, out of the diff already // in hand. Only a comment on the version in view has any: one written // against a tip the branch has replaced is placed nowhere in this diff, and // the line it stored is all that is left of it. const contextFor = (anchor) => { const file = data.files.find((one) => one.path === anchor.path); if (!file?.hunks) return null; const rows = file.hunks.flatMap((hunk) => hunk.lines); // The span the comment covers: its first line to its last. A single-line // comment has the two the same. anchorKey keys the last line, and the // first is the anchor's own side and line. const side = anchor.side === 'old' ? 'old' : 'new'; const startKey = `${side}:${anchor.line}`; const startAt = rows.findIndex((row) => lineKey(row) === startKey); const endAt = rows.findIndex((row) => lineKey(row) === anchorKey(anchor)); if (startAt < 0 || endAt < 0) return null; const [lo, hi] = startAt <= endAt ? [startAt, endAt] : [endAt, startAt]; const from = Math.max(0, lo - CONTEXT_LINES); return { lines: rows.slice(from, hi + CONTEXT_LINES + 1), from: lo - from, to: hi - from, }; }; // A thread anchored to a line is a place in the diff, which is the other // tab. The address carries both, so the fragment is still there when the // diff has rendered and the effect above can find it. const threadHref = (fragment) => `${tabHref('files')}#${encodeURIComponent(fragment)}`; // The head of the page: what the pull request is, and the tabs over it. It // is pinned on every tab, because it holds nothing that grows: what the // author wrote is the first word of the conversation and reads there, under // the tabs rather than over them. const head = ( <> {/* A pull request opens on what it is, in the order a reader asks; a plain commit opens on the subject and who wrote it, the way the ref and the path do on the source view. */} {pull ? ( ) : ( {new Date(data.committedAt).toLocaleString()} {data.base ? `${data.base.slice(0, 8)}..` : ''} {data.sha} {data.copy && ( in 's copy )} {data.files.length} {data.files.length === 1 ? 'file' : 'files'}{' '} changed {/* What a bare range address is showing. A pull request has said it already, in the count of commits it is ahead by. */} {data.base && every commit since it branched} } /> )} {/* A pull request is read two ways, and one scroll cannot hold both: on one of any size the conversation scrolls away under the diffs. A plain commit has nothing to say for itself, so it keeps the one pane. */} {pull && ( } > )} ); return (
{showChecks ? ( {head}
) : showFiles ? ( {head}
{/* The tree pins under the tabs while the diffs scroll past it, and steps aside on a narrow screen where there is no room for two columns. */} {data.files.length > 1 && ( )}
{/* On a pull request this reads at the top of the activity, the way the author wrote it before anybody answered. */} {!pull && body && (
                    {body}
                  
)} {/* The verdict is given here, on the files, where a forge keeps it: a reader has read the code it speaks to, and the conversation is left for words rather than judgements. A commit of this account's own history is a fact, not a pull request, but it is reviewed the same way. The composer opens from a button so the diffs, not the form, hold the column. */} {reviewable && session && (
Review changes { addStatement(statement); setReviewOpen(false); }} />
)} {!pull && reviewable && comments.elsewhere > 0 && (

{comments.elsewhere}{' '} {comments.elsewhere === 1 ? 'comment' : 'comments'} on commits this branch has replaced, on the conversation.

)} {data.files.map((file) => ( selectLine(file.path, line, shift) } ref={(node) => { if (node) sections.current.set(file.path, node); else sections.current.delete(file.path); }} /> ))}
) : ( {head}
{/* The author's first word, which is what a description is. It reads under the tabs rather than over them, so the head above stays the same height whichever tab is open. */} {pull && (pull.note || session?.did === pull.author.did) && (
)} {body && (
                  {body}
                
)}
{log?.truncated && (

Showing the newest {PULL_COMMIT_LIMIT} commits.{' '} All of them.

)}
{/* The merge box gathers the state a reader needs before the merge: the checks, the conflicts, the button. It reads under the timeline and above the comment box, the way a forge stacks it. */} {pull && ( )} {/* The comment box is the last thing, the word a reader adds after reading the story and the state above it. */} {session && (
)}
)}
); }