chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
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<Record<string, *>>} */ ([]),
);
// 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 <PageNotice>No such repository.</PageNotice>;
}
// 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 <PageNotice>No commits yet.</PageNotice>;
}
if (error) {
return <PageNotice tone="error">{error.message}</PageNotice>;
}
if (!shown) {
return (
<div className="min-h-0 flex-1 overflow-y-auto">
<div className={cn(pull && COLUMN)}>
<div className="flex flex-col gap-3 px-(--gutter) pt-4 pb-5">
<Skeleton className="h-6 w-1/2 max-w-md" />
<Skeleton className="h-4 w-2/3 max-w-lg" />
</div>
<div className="empty:hidden px-(--gutter) pb-4">
<ReadingRepo size={repoSize(record)} />
</div>
<div className="flex flex-col gap-2.5 border-border border-t px-(--gutter) py-4">
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-4 w-1/2" />
</div>
</div>
</div>
);
}
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 ? (
<PullHeader
wide={showFiles}
repo={repo}
pull={pull}
defaultBranch={defaultRef(record)}
/>
) : (
<PageHead
wide
title={subject}
// Where this commit was reached from: the pull request that
// carries it, or the branch's history for one that is nobody's
// proposal.
back={
backTo
? { href: backTo.href, label: backTo.title }
: {
href: `/${encodeURIComponent(repo)}/commits/${encodeURI(defaultRef(record))}`,
label: 'Commits',
}
}
meta={
<>
<AuthorFacepile ident={data.author} message={data.message} />
<span>{new Date(data.committedAt).toLocaleString()}</span>
<code className="break-all font-mono text-key">
{data.base ? `${data.base.slice(0, 8)}..` : ''}
{data.sha}
</code>
{data.copy && (
<span className="flex min-w-0 items-baseline gap-1">
in
<Handle who={data.copy} className="text-faint">
's copy
</Handle>
</span>
)}
<span>
{data.files.length} {data.files.length === 1 ? 'file' : 'files'}{' '}
changed
</span>
<CommitCheckLine repo={repo} sha={data.sha} space={space} />
{/* 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 && <span>every commit since it branched</span>}
</>
}
/>
)}
{/* 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 && (
<PageBar
tabs
wide={showFiles}
// The head scrolls away with the page and the tabs stay under the
// bar, so a phone gives the reading the room the head took.
className="sticky top-0 z-10 bg-background"
// Beside the tabs, where a forge puts it: the size of the thing
// the files tab opens, without opening it. A phone has no room
// for it beside the tabs.
actions={
<span className="hidden sm:block">
<DiffStat files={data.files} />
</span>
}
>
<TabBar
className="border-b-0"
active={tab === 'files' || tab === 'checks' ? tab : 'conversation'}
tabs={[
{
key: 'conversation',
label: 'Conversation',
href: at,
count: threads,
},
{
key: 'checks',
label: 'Checks',
href: tabHref('checks'),
count: tipChecks.length,
},
{
key: 'files',
label: 'Files changed',
href: tabHref('files'),
count: data.files.length,
},
]}
/>
</PageBar>
)}
</>
);
return (
<div className="flex min-h-0 flex-1 flex-col">
{showChecks ? (
<Screen wide>
{head}
<div className={COLUMN}>
<PullChecks
repo={repo}
checks={checks ?? none}
tip={tip}
space={space}
/>
</div>
</Screen>
) : showFiles ? (
<Screen wide>
{head}
<div
className={cn(
'flex items-start',
!pull && 'border-border border-t',
)}
>
{/* 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 && (
<aside
className={cn(
'sticky hidden max-h-[calc(100dvh-10rem)] w-72 shrink-0 self-start overflow-y-auto border-border border-r px-2 py-3 lg:block',
pull ? 'top-12' : 'top-0',
)}
>
<DiffTree files={data.files} onPick={pick} picked={picked} />
</aside>
)}
<div className="min-w-0 flex-1">
{/* On a pull request this reads at the top of the activity, the
way the author wrote it before anybody answered. */}
{!pull && body && (
<div className="border-border border-b px-(--gutter) py-4">
<pre className="max-w-3xl whitespace-pre-wrap break-words font-mono text-[13px] text-muted-foreground">
{body}
</pre>
</div>
)}
{/* 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 && (
<div className="flex items-center justify-end border-border border-b px-(--gutter) py-2.5">
<Popover.Root open={reviewOpen} onOpenChange={setReviewOpen}>
<Popover.Trigger
className={cn(buttonVariants({ size: 'sm' }))}
>
Review changes
<ChevronDownIcon className="size-3.5" />
</Popover.Trigger>
<Popover.Portal>
<Popover.Positioner
className="z-50"
sideOffset={6}
align="end"
>
<Popover.Popup className="w-[min(34rem,calc(100vw-2rem))] rounded-2xl border border-border bg-card p-4 shadow-lg outline-none">
<ReviewComposer
repo={repo}
sha={data.sha}
author={author}
refName={ref}
variant="review"
bare
onPublished={(statement) => {
addStatement(statement);
setReviewOpen(false);
}}
/>
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
</Popover.Root>
</div>
)}
{!pull && reviewable && comments.elsewhere > 0 && (
<p className="border-border border-b px-(--gutter) py-3 text-[12.5px] text-faint">
{comments.elsewhere}{' '}
{comments.elsewhere === 1 ? 'comment' : 'comments'} on commits
this branch has replaced, on the conversation.
</p>
)}
{data.files.map((file) => (
<DiffFile
key={file.path}
file={file}
comments={comments.byFile.get(file.path)}
spans={comments.spans.get(file.path)}
onComment={reviewable && session ? onComment : undefined}
selection={selection}
onSelectLine={(line, shift) =>
selectLine(file.path, line, shift)
}
ref={(node) => {
if (node) sections.current.set(file.path, node);
else sections.current.delete(file.path);
}}
/>
))}
</div>
</div>
</Screen>
) : (
<Screen wide>
{head}
<div className={COLUMN}>
{/* 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) && (
<div className="px-(--gutter) pt-4">
<PullNoteEdit repo={repo} pull={pull} />
</div>
)}
{body && (
<div className="px-(--gutter) pt-4">
<pre className="whitespace-pre-wrap break-words font-mono text-[13px] text-muted-foreground">
{body}
</pre>
</div>
)}
<div className="px-(--gutter)">
<div>
<Timeline
loading={logLoading || statementsLoading}
checks={checks}
replaced={replaced}
statements={conversation}
sha={tip}
contextFor={contextFor}
commits={log?.commits}
commitHref={commitHref}
onReply={session ? onComment : undefined}
threadHref={threadHref}
/>
{log?.truncated && (
<p className="pb-4 text-[12.5px] text-faint">
Showing the newest {PULL_COMMIT_LIMIT} commits.{' '}
<Link href={commitsAt}>All of them</Link>.
</p>
)}
</div>
</div>
{/* 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 && (
<MergeBox
repo={repo}
pull={pull}
checks={tipChecks}
checksHref={tabHref('checks')}
/>
)}
{/* The comment box is the last thing, the word a reader adds after
reading the story and the state above it. */}
{session && (
<div className="px-(--gutter) pt-2 pb-6">
<div>
<ReviewComposer
repo={repo}
sha={data.sha}
author={author}
refName={ref}
variant="comment"
onPublished={addStatement}
/>
</div>
</div>
)}
</div>
</Screen>
)}
</div>
);
}