chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
import { lfsPointer } from '@pdsjs/git/browser';
import { ChevronDownIcon, ChevronRightIcon, PlusIcon } from 'lucide-react';
import { useMemo, useState } from 'react';
import { Badge } from '#/components/atoms/badge.jsx';
import { Button } from '#/components/atoms/button.jsx';
import { Card } from '#/components/atoms/card.jsx';
import { Textarea } from '#/components/atoms/textarea.jsx';
import { StatementCard } from '#/components/molecules/statement-card.jsx';
import { inSpan, lineKey } from '#/lib/comments.js';
import { ROW_STYLE, SIGN, SIGN_STYLE } from '#/lib/diff-rows.js';
import { bytes } from '#/lib/format.js';
import { diffHighlighter, languageFor } from '#/lib/highlight.js';
import { inSelection, lineFragment, lineId } from '#/lib/line-links.js';
import { useObjectUrl } from '#/lib/object-url.js';
import { cn } from '#/lib/utils.js';
import { verdictLook } from '#/lib/verdicts.js';
/* A file added or taken away is a fact about the diff, not a failure, so
the two read by weight rather than by hue. */
const STATUS_STYLE = {
added: 'border-input bg-accent text-foreground',
deleted: 'border-border bg-muted text-faint',
modified: '',
};
/**
* One number in the gutter, which is also the address of its line. A plain
* click selects the line, and shift takes the span from the line already
* selected to this one. The anchor is a real link, so the browser's own menu
* copies it and a middle click opens it.
*
* A column showing a number the line is not addressed by draws it plainly. A
* context line is addressed on the new side, the way a comment anchors to it,
* so its old number reads as a number and nothing more.
*/
function LineNumber({ value, fragment, style, className, onSelect }) {
if (fragment == null) {
return (
<span style={style} className={className}>
{value ?? ''}
</span>
);
}
return (
<a
href={`#${fragment}`}
style={style}
className={cn(className, 'cursor-pointer hover:text-foreground')}
aria-label={`Link to line ${value}; shift-click to end a span`}
onClick={(event) => {
event.preventDefault();
onSelect(event.shiftKey);
}}
>
{value}
</a>
);
}
/**
* One side of a changed image, with its size under it. A side the repository
* holds as a Git LFS pointer has no picture to show, so the caption carries
* the size of the file the pointer names.
*/
function DiffImage({ image, mediaType, label, path }) {
const pointer = lfsPointer(image);
const url = useObjectUrl(pointer ? null : image, mediaType);
return (
<figure className="flex min-w-0 flex-col items-center gap-2">
{pointer ? (
<p className="rounded-lg border border-border border-dashed px-4 py-6 text-[12.5px] text-faint">
Stored with Git LFS
</p>
) : (
url && (
<img
src={url}
alt={label ? `${path}, ${label}` : path}
className="h-auto max-w-full rounded-lg"
/>
)
)}
<figcaption className="flex gap-2 font-mono text-[12px] text-faint">
{label && <span>{label}</span>}
<span>{bytes(pointer ? pointer.size : image.length)}</span>
</figcaption>
</figure>
);
}
/**
* One changed file: its status, its counts, and its hunks. An image shows
* both of its pictures where the hunks would go. A commit that
* touches many files is easier to read with some of them shut, so each one
* folds away on its own.
*
* A pull request page passes `comments`, this file's anchored statements
* keyed the way `lineKey` keys a row, `spans` the ranges a comment covers,
* and `onComment` to publish a new one;
* each renders under the line it speaks to, which is what an anchor is for. A
* plain commit page passes neither and reads as it always did.
*
* `selection` is the span of lines the address names, when it names one in
* this file, and `onSelectLine` sets a new one from a click in the gutter.
*/
export function DiffFile({
file,
ref,
comments,
spans,
onComment,
selection,
onSelectLine,
}) {
const language = languageFor(file.path);
const [open, setOpen] = useState(true);
// Colouring a whole file twice costs more than colouring a hunk, so it
// happens once for the file rather than on every render.
const colour = useMemo(
() => diffHighlighter(file.oldText ?? '', file.newText ?? '', language),
[file.oldText, file.newText, language],
);
/**
* Where the composer is open: `{key}` for a fresh comment on a line, or
* `{key, replyTo}` for an answer to one of its comments. Null otherwise.
*/
const [composing, setComposing] = useState(null);
const [note, setNote] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const Chevron = open ? ChevronDownIcon : ChevronRightIcon;
// Both sides of a changed image are labelled. A picture on its own is the
// whole change, and the status says which side it is.
const twoImages = Boolean(file.oldImage && file.newImage);
const post = async () => {
const target = composing;
setBusy(true);
setError('');
try {
const range = target?.range;
await onComment({
path: file.path,
...(range
? {
line: range.start,
side: range.side,
snippet: range.startText.slice(0, 512),
...(range.end !== range.start
? {
endLine: range.end,
endSnippet: range.endText.slice(0, 512),
}
: {}),
}
: {}),
replyTo: target?.replyTo,
note: note.trim(),
});
// Only the composer this post belongs to closes. The visitor may have
// opened another while the write was in flight, and it is theirs.
setComposing((current) => (current === target ? null : current));
setNote('');
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
/**
* Open the composer on a line, extend a range to it with shift, or answer a
* comment. A shift-click on the gutter of a line, with a fresh comment
* already open on the same side, makes the two ends of a range; the thread
* then speaks to every line between them.
* @param {string} key @param {Object} line
* @param {{uri: string, cid: string}} [replyTo]
* @param {boolean} [shift]
*/
const compose = (key, line, replyTo, shift) => {
if (replyTo) {
setComposing((cur) =>
cur?.key === key && cur?.replyTo?.uri === replyTo.uri
? null
: { key, replyTo },
);
setNote('');
setError('');
return;
}
const side = line.newLine != null ? 'new' : 'old';
const at = line.newLine ?? line.oldLine;
setComposing((cur) => {
if (shift && cur?.range && cur.range.side === side) {
const anchor = cur.range.anchor;
const [start, end] = anchor <= at ? [anchor, at] : [at, anchor];
const [startText, endText] =
anchor <= at
? [cur.range.anchorText, line.text]
: [line.text, cur.range.anchorText];
return {
key,
range: {
side,
start,
end,
startText,
endText,
anchor,
anchorText: cur.range.anchorText,
},
};
}
if (cur?.key === key && !cur.replyTo) return null;
return {
key,
range: {
side,
start: at,
end: at,
startText: line.text,
endText: line.text,
anchor: at,
anchorText: line.text,
},
};
});
setNote('');
setError('');
};
/**
* Whether a row is in the range the composer is selecting now, so it marks
* the span a reader is about to comment on before they publish it.
* @param {Object} line
*/
const selecting = (line) => {
const range = composing?.range;
if (!range) return false;
const side = line.newLine != null ? 'new' : 'old';
const at = line.newLine ?? line.oldLine;
return (
side === range.side && at != null && at >= range.start && at <= range.end
);
};
// The gutter is cut to the widest line number this file will show. A fixed
// width fits whatever it was measured against and clips everything longer,
// and a file of a few thousand lines is ordinary.
const widest = (file.hunks ?? []).reduce(
(most, hunk) =>
Math.max(
most,
hunk.oldStart + hunk.oldLines,
hunk.newStart + hunk.newLines,
),
0,
);
const digits = Math.max(2, String(Math.max(0, widest)).length);
// ch is the advance of a figure in the face the gutter is set in, so the
// column holds exactly its digits plus its own padding.
const narrow = { width: `calc(${digits}ch + 0.75rem)` };
const wide = { width: `calc(${digits}ch + 1rem)` };
// Where the numbers end and the code begins, which is where the comment
// button sits. One column of numbers on a narrow window, two above it.
const seam = {
'--seam': `calc(${digits}ch + 0.75rem)`,
'--seam-wide': `calc(${digits * 2}ch + 2rem)`,
};
return (
<section
ref={ref}
// One rule between files, the way every other listing divides its rows.
// scroll-mt leaves room for the header that stops above the pane, so a
// file reached from the tree does not land under it.
className="scroll-mt-14 border-border border-b"
>
<header className="flex flex-wrap items-center gap-3 border-border border-b bg-muted px-(--gutter) py-2.5">
<button
type="button"
onClick={() => setOpen((was) => !was)}
aria-expanded={open}
aria-label={open ? `Hide ${file.path}` : `Show ${file.path}`}
className="-my-1 cursor-pointer rounded-sm p-1 text-faint transition-colors hover:bg-accent hover:text-foreground"
>
<Chevron className="size-4" />
</button>
<Badge className={cn('uppercase', STATUS_STYLE[file.status])}>
{file.status}
</Badge>
<span className="min-w-0 flex-1 break-all font-mono text-[13px]">
{file.path}
</span>
<span className="shrink-0 font-mono text-[12px] tabular-nums">
{file.additions > 0 && (
<span className="text-foreground">+{file.additions}</span>
)}
{file.additions > 0 && file.deletions > 0 && ' '}
{file.deletions > 0 && (
<span className="text-faint">-{file.deletions}</span>
)}
</span>
</header>
{!open ? null : file.hunks ? (
<div className="overflow-x-auto font-mono text-[12.5px] leading-6">
{/* One box as wide as the longest line in the file, with every row
filling it. Sized to each row instead, a short row's wash would
stop where its own text does and leave the rest of the scroll
unpainted. */}
<div className="w-max min-w-full" style={seam}>
{file.hunks.map((hunk) => (
<div key={`${hunk.oldStart}-${hunk.newStart}`}>
<div className="flex w-full whitespace-pre bg-muted text-diff-meta">
<span style={narrow} className="shrink-0 sm:hidden" />
<span
style={{ width: `calc(${digits * 2}ch + 2rem)` }}
className="hidden shrink-0 sm:block"
/>
<span className="px-3">
@@ -{hunk.oldStart},{hunk.oldLines} +{hunk.newStart},
{hunk.newLines} @@
</span>
</div>
{hunk.lines.map((line) => {
const key = lineKey(line);
const here = comments?.get(key) ?? [];
const onNew = line.newLine != null;
const at = line.newLine ?? line.oldLine;
const fragment =
onSelectLine && at != null
? lineFragment({
path: file.path,
side: onNew ? 'new' : 'old',
start: at,
end: at,
})
: null;
const select = (shift) => onSelectLine(line, shift);
const linked = inSelection(selection, file.path, line);
return (
<div key={`${line.type}-${line.oldLine}-${line.newLine}`}>
<div
id={lineId(file.path, key)}
className={cn(
'group relative flex w-full whitespace-pre',
ROW_STYLE[line.type],
(selecting(line) || inSpan(spans, line)) &&
'bg-key/10',
linked && 'bg-key/20',
)}
>
{/* Two columns of numbers cost a quarter of a phone's
width before a line of code begins. One column there
instead: a line belongs to one side or the other, and
the sign beside it says which. */}
<LineNumber
value={at}
fragment={fragment}
onSelect={select}
style={narrow}
className={cn(
'shrink-0 select-none px-1.5 text-right text-faint tabular-nums sm:hidden',
linked && 'text-key',
)}
/>
<LineNumber
value={line.oldLine}
fragment={onNew ? null : fragment}
onSelect={select}
style={wide}
className={cn(
'hidden shrink-0 select-none px-2 text-right text-faint tabular-nums sm:block',
linked && 'text-key',
)}
/>
<LineNumber
value={line.newLine}
fragment={onNew ? fragment : null}
onSelect={select}
style={wide}
className={cn(
'hidden shrink-0 select-none px-2 text-right text-faint tabular-nums sm:block',
linked && 'text-key',
)}
/>
{/* At the seam between the numbers and the code, the
way a forge's gutter button sits, floating so a
long line scrolls under it rather than pushing it
off the screen. */}
{onComment && (
<button
type="button"
aria-label={`Comment on line ${line.newLine ?? line.oldLine}; shift-click to end a range`}
onClick={(event) =>
compose(key, line, undefined, event.shiftKey)
}
className="-translate-x-1/2 -translate-y-1/2 absolute top-1/2 left-(--seam) z-10 flex size-[18px] cursor-pointer items-center justify-center rounded bg-key text-white opacity-0 shadow transition-opacity focus-visible:opacity-100 group-hover:opacity-100 sm:left-(--seam-wide)"
>
<PlusIcon className="size-3.5" />
</button>
)}
<span
className={cn(
'shrink-0 select-none pl-2 sm:pl-3',
SIGN_STYLE[line.type],
)}
>
{SIGN[line.type]}
</span>
<span
className="pr-3"
// biome-ignore lint/security/noDangerouslySetInnerHtml: the highlighter escapes its input
dangerouslySetInnerHTML={{ __html: colour(line) }}
/>
</div>
{/* The thread reads as a conversation rather than as
more code: a band across the row, and inside it the
same card the timeline draws, so one person's words
look the same wherever they are read. The band spans
the row and the cards keep a readable measure, so a
wide window does not stretch a sentence across it. */}
{here.length > 0 && (
<div
id={`comment-${file.path}-${key}`}
className="flex flex-col gap-2 border-border border-y bg-muted px-3 py-2.5 whitespace-normal"
>
{here.map((comment) => {
const said = verdictLook(comment.verdict);
return (
<StatementCard
key={`${comment.by?.did}:${comment.reviewedAt}`}
who={comment.by}
at={comment.reviewedAt}
note={comment.note}
className={cn(
'max-w-2xl',
comment.depth > 0 && 'ml-6',
)}
mark={
comment.verdict !== 'comment' && (
<span
className={`flex items-center gap-1 ${said.className}`}
>
<said.Icon className="size-3.5" />
{said.label}
</span>
)
}
action={
/* Only a statement with an address answers
to one; the visitor's own just-published
words gain theirs on the next read. */
onComment &&
comment.uri && (
<button
type="button"
onClick={() =>
compose(key, line, {
uri: comment.uri,
cid: comment.cid,
})
}
className="cursor-pointer text-[12px] text-key hover:underline"
>
Reply
</button>
)
}
/>
);
})}
</div>
)}
{composing?.key === key && (
<div className="border-border border-y bg-muted px-3 py-2.5 whitespace-normal">
<Textarea
value={note}
onChange={(event) => setNote(event.target.value)}
aria-label="Your comment"
placeholder={
composing.replyTo
? 'Your answer.'
: composing.range &&
composing.range.end !==
composing.range.start
? `What about lines ${composing.range.start} to ${composing.range.end}?`
: `What about line ${line.newLine ?? line.oldLine}?`
}
rows={2}
className="max-w-2xl bg-card"
disabled={busy}
/>
<div className="mt-1.5 flex items-center gap-2">
<Button
type="button"
size="xs"
disabled={busy || !note.trim()}
onClick={() => post()}
>
{composing.replyTo ? 'Reply' : 'Comment'}
</Button>
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => setComposing(null)}
>
Cancel
</Button>
{error && (
<span className="text-[12px] text-destructive">
{error}
</span>
)}
</div>
</div>
)}
</div>
);
})}
</div>
))}
</div>
</div>
) : file.oldImage || file.newImage ? (
<Card className="m-(--gutter) flex flex-wrap items-start justify-center gap-6 p-4">
{file.oldImage && (
<DiffImage
image={file.oldImage}
mediaType={file.mediaType}
label={twoImages ? 'before' : ''}
path={file.path}
/>
)}
{file.newImage && (
<DiffImage
image={file.newImage}
mediaType={file.mediaType}
label={twoImages ? 'after' : ''}
path={file.path}
/>
)}
</Card>
) : (
<p className="px-(--gutter) py-4 text-[13px] text-muted-foreground">
{file.binary
? `Binary file, ${bytes(file.newSize ?? file.oldSize)}.`
: 'Change too large to show.'}
</p>
)}
</section>
);
}