chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
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 (
<div className="overflow-x-auto py-1 font-mono text-[12.5px] leading-6">
{coloured.map((html, index) => {
const row = rows[index];
const type = row?.type ?? 'context';
return (
<div
key={row ? `${row.type}-${row.oldLine}-${row.newLine}` : 'snippet'}
className={cn(
'flex whitespace-pre px-3',
ROW_STYLE[type],
// The lines the comment is about, marked over whatever side
// they are on. A single line is a span of one.
context &&
index >= context.from &&
index <= context.to &&
'bg-key/10',
)}
>
<span className="w-10 shrink-0 select-none text-right text-faint tabular-nums">
{row ? (row.newLine ?? row.oldLine ?? '') : (anchor.line ?? '')}
</span>
<span className={cn('shrink-0 select-none pl-3', SIGN_STYLE[type])}>
{SIGN[type]}
</span>
<span
className={cn('text-muted-foreground', row ? 'pl-2' : 'pl-3')}
// biome-ignore lint/security/noDangerouslySetInnerHtml: the highlighter escapes its input
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
);
})}
</div>
);
}
/**
* 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 (
<div className="flex flex-col gap-3 py-4" aria-hidden="true">
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-2/5" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-4 w-1/3" />
</div>
);
}
if (events.length === 0) {
return (
<p className="py-6 text-[13px] text-faint">
Nothing pushed or said about this pull request yet.
</p>
);
}
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 (
<div className="flex flex-col gap-2 py-4">
{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 (
<Link
key={event.key}
href={commitHref(commit.sha)}
className="-mx-1.5 flex items-center gap-2 rounded-lg px-1.5 py-1 text-[12.5px] text-foreground no-underline hover:bg-accent/50 hover:no-underline"
>
<GitCommitVerticalIcon className="size-3.5 shrink-0 text-faint" />
{/* 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. */}
<AuthorFaces ident={commit.author} message={commit.message} />
<span className="min-w-0 truncate">
{commit.message.split('\n')[0]}
</span>
<code className="shrink-0 font-mono text-[12px] text-faint">
{commit.sha.slice(0, 8)}
</code>
<span className="shrink-0 text-[12px] text-faint">
{timeAgo(commit.authoredAt ?? commit.committedAt)}
</span>
{look && (
<look.Icon
className={cn('size-3.5 shrink-0', look.className)}
aria-label={`checks ${look.label}`}
/>
)}
</Link>
);
}
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 (
<div key={event.key} className="flex flex-col gap-2">
{anchor && (
<div className="overflow-hidden rounded-md border border-border">
<div className="flex flex-wrap items-baseline gap-x-3 border-border border-b bg-muted px-3 py-1.5 text-[12px]">
<code className="min-w-0 truncate font-mono text-faint">
{anchor.path}
{anchor.line
? anchor.endLine && anchor.endLine !== anchor.line
? `:${anchor.line}-${anchor.endLine}`
: `:${anchor.line}`
: ''}
</code>
{inDiff && threadHref ? (
<Link href={threadHref(fragment)} className="ml-auto">
Open in the diff
</Link>
) : (
<span
className="ml-auto shrink-0 rounded-full border border-input px-2 py-px text-[11.5px] text-faint"
title="Written against a commit this branch has since replaced, so it is placed nowhere in the diff."
>
Outdated
</span>
)}
</div>
<AnchoredCode
anchor={anchor}
context={inDiff ? contextFor?.(anchor) : null}
/>
</div>
)}
{threadStack(thread).map((statement) => {
const said = verdictLook(statement.verdict);
return (
<StatementCard
key={`${statement.by?.did}:${statement.reviewedAt}`}
who={statement.by}
at={statement.reviewedAt}
note={statement.note}
className={statement.depth > 0 ? 'ml-6' : ''}
mark={
statement.verdict !== 'comment' && (
<span
className={`flex items-center gap-1 ${said.className}`}
>
<said.Icon className="size-3.5" />
{said.label}
{outdated(statement) && (
<span className="text-faint">outdated</span>
)}
</span>
)
}
action={
onReply &&
session &&
statement.uri && (
<button
type="button"
onClick={() => {
setComposing(statement);
setError('');
}}
className="cursor-pointer text-[12px] text-key hover:underline"
>
Reply
</button>
)
}
/>
);
})}
{composing && thread.some((s) => s.uri === composing.uri) && (
<form onSubmit={post} className="ml-6 flex flex-col gap-2">
<Textarea
value={note}
onChange={(event) => setNote(event.target.value)}
placeholder="Your answer."
aria-label="Your answer"
rows={2}
disabled={busy}
/>
<div className="flex items-center gap-2">
<Button
type="submit"
size="sm"
disabled={busy || !note.trim()}
>
Reply
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setComposing(null)}
>
Cancel
</Button>
{error && (
<span className="text-[12px] text-destructive">
{error}
</span>
)}
</div>
</form>
)}
</div>
);
})}
</div>
);
}