~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
408 lines · 20.8 KB Raw History
# @pdsjs/git-ui

A public repository browser for the git repositories `@pdsjs/git` hosts in an
atproto account. It deploys to the account's own site, so a visitor browses
trees, files, history and commit diffs with no sign-in. Everything it shows,
it can read for itself; one read takes a shortcut through the server where
the server offers one.

React, shadcn conventions and Base UI, the same stack the account pages use.

## The shape of a screen

The window is the app. The bar and the repository's sections hold their
place, and each screen takes what is left and scrolls inside itself, so
nothing but the reading area moves. A screen is `Screen`: a band fixed at the
top and a body with its own scroll. A listing fixes a `PageBar`, one height on
every route, holding the ref picker, the breadcrumb or the row of tabs on the
left and the one action on the right. A detail page fixes a `PageHead`
instead: the title at one size, a line of facts under it, and the way back.
A screen with nothing to show says so in a `PageNotice`, which takes the same
room the screen would.

Everything a reader reads sits in one centred column, the way a forge lays
out a list, a conversation or a run: the rules under a band run the width of
the window, and the words inside it align to the column. Two screens take the
whole window instead, because each holds a listing beside the thing it opens:
the source view inside a directory, with the listing beside the file, and a
diff, with the changed files beside the hunks. The repository's root is read
about rather than navigated, so it stays in the column: the files in one
panel with the latest commit on its header, the readme in the panel under it,
and an About column beside them with the description and the gauges. A pull request is read in the column and its
files changed tab is read wide, so the head above it follows the tab. `wide`
on `Screen`, `PageBar` and `PageHead` is what says which; `lib/layout.js`
holds the measure.

A listing sits in a `ListBox`: one bordered panel in the column, the rows
divided by rules inside it, the same panel whether the rows have arrived,
are absent or are still loading. A row inside takes its edge from the panel,
so it carries a fixed inset rather than the gutter.

The controls are atoms. A form takes `Input` and `Textarea`, which share one
border and the focus ring a `Button` has; a link that stands in a row of
buttons is a `ButtonLink`; a number beside a label is a `Count`, the same pill
on a section tab, a list tab and a filter.

## How it reads a repository

Two layers feed the app, and only one of them is a record read.

The repository list, the ref lists, the default branch and the bundle sizes
come from a single `dev.pdsjs.query.run` call against a `dev.pdsjs.query.def`
record. Reading is anonymous, so the list page, the branch menu and the clone
box cost no blob downloads at all.

Trees, file bodies, history and diffs live inside the git bundles in blob
storage, which only a pack reader can open. A server that serves the
`dev.pdsjs.git` reads (`community.lexicon.service.describe` lists them) opens
the repository from its own storage and answers each screen in one request,
through `GitClient` from `@pdsjs/git/client`; the answers are cached under
the record cid the listing named. Otherwise `@pdsjs/git/browser` does that
work in the page, and it also takes over a repository the server answers
`RepositoryTooLarge` for. A repository whose bundles carry object indexes opens off
those small blobs alone, and each screen then fetches just the objects it
shows, as ranged reads. A repository pushed before the index existed
downloads its whole chain instead. Blobs are fetched from this origin's
`/.blobs/<cid>` route, which the site handler serves immutably, so a second
visit reads them from the browser cache.

Having the refs in hand before any screen renders is what lets a URL like
`/repo/tree/feature/x/src/index.js` be split correctly: the ref and the path
both hold slashes, and the only reliable split is against the repository's own
ref names.

## Syntax colouring

File bodies, READMEs and diffs are coloured by highlight.js. The grammars are
chosen one by one rather than imported wholesale, because the build inlines
everything into one document and the full package is over a megabyte. A file
whose extension names no grammar renders plain.

A diff hunk is not a valid program: it holds the lines before a change and
the lines after it, interleaved. Colouring each row alone would end a block
comment at every line break, which in a JSDoc-heavy repository is most of
them. Each side of a hunk is rebuilt as its own text, coloured whole, and
handed back a row at a time.

## Markdown

A README renders rather than being shown as source. The text belongs to
whoever wrote the repository and the result goes straight into this page, so
two rules hold the line: raw HTML in the document is printed rather than
parsed, and a link or image address must name a scheme worth following.
Everything left is markup the renderer wrote itself, from a fixed set of
tags.

A fenced code block is coloured the same way a file is. A link to another
file in the repository opens that file in this app rather than leaving it,
and an image named by a relative path is read out of the repository's own
objects, which are already in memory: a README's diagrams appear without the
server serving anything.

## Commit authors

A commit records its author as a name and an email address, written by that
author's own git client. Nothing signs either, so a commit on its own says
nothing about who wrote it. The page shows an avatar and a handle only for an
author the hosting account has claimed, in its `dev.pdsjs.git.identity`
record. Every other author reads as the plain name the commit carries, beside
an initial.

That record lives in the account's own repo under rkey `self`, so it covers
every repository the account serves and nobody can write it for somebody
else. An entry may name a `did`, which is how a project account attributes
commits to the people who wrote them rather than to itself.

`GIT_IDENTS` on a deploy writes the record:

```sh
GIT_IDENTS='me@example.com,them@example.com=did:plc:…'
```

The trust this buys is worth stating plainly. A visitor is trusting the
account whose repositories they are reading, and that account is saying which
addresses belong to whom. It is not proof that the person holding the handle
wrote the commit.

## Pull requests

A repository whose config record names collaborators gets a pull request list,
at `/<repo>/pulls`. It stores nothing.

Two tabs, open and merged, at `/<repo>/pulls` and `/<repo>/pulls/merged`. Each
is an address rather than component state, so a reload and a shared link both
land where the reader was. One read fills both, so moving between them costs
nothing.

A pull request is a branch in one account's own copy of the repository, on
their own server, and what identifies it is that account's DID and the ref
name. The pair survives amend, rebase, force-push and a commit added on top,
so a check or a review written against one commit still names the work after
any of them.

A branch canonical main does not reach is open; a tip main reaches is merged.
Both come from the object graph, so the screen cannot show a pull request that
was merged last week or miss one pushed a minute ago. Ahead and behind are
counted by intersecting the two commit walks: neither server holds the other's
objects, but a commit id is the same id everywhere.

A force-push gives the same work a new sha and the graph keeps no record of
the one before it. Two things remember: the runner's push log, which saw every
tip the branch carried, and the checks and reviews written against a tip. The
union is what says a commit was replaced rather than lost, so a comment on a
rewritten commit reads as answering one and a check against it reads as stale.

The pushes read on the conversation, each one a rule across the story with
what came before it above and what answers it below. 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: the runner checks a push, and a commit arriving under a later one is
checked only as part of what it arrived with. There is no numbering:
a forge does not number them either, and a reader following an argument wants
to know a push happened, not which ordinal it was.

Reviews come from the reviewer's own repo, in `dev.pdsjs.git.review` records
naming the account, the ref and the commit read. Nothing here gates a push: a
verdict is a signed statement and the config's reviewer list is the
repository's answer to whose statements it shows. A review agent is a DID in
that list like any other account.

A pull request may carry its author's own words beside the derived facts: a
title, a `draft` or `withdrawn` badge and a note, from a `dev.pdsjs.git.pull`
record in the author's repo. The note is markdown, as is a review, an issue
and every word under one, so a description carries a code block and a table
the way a readme does. It goes through the renderer a readme goes through, for
the reason a readme needs it: the words come from an account this site does not
control, so raw HTML in them is printed rather than parsed and a link may only
name a scheme this page will follow. A relative address is left as its words,
because prose about the work names no tree for a path to be relative to. The graph says whether the branch is open; only
the author can say whether they want it read. The badge decorates and never
hides. The form that writes the record is on the page, for the account holding
the branch and nobody else.

Work from accounts the repository has not named appears in the same list,
marked as coming from a fork. Nothing finds those records on its own, so the
repository's runner publishes the index it saw and this reads it, the same way
it reads the runner's checks. The indexes of every runner the config names are
unioned, so a single runner quietly omitting one does not bury it. Adding the
account to `collaborators` drops the fork mark.

A shared path is not a claim that two branches conflict textually, which would
need a three-way merge per pair; it is the cheap half of the question, and the
half that says where to look, at the moment a reader is deciding. The same pass
reports the files main has changed since a branch was taken.

A commit on a collaborator's branch is in their bundles on their server and
nowhere else, so it opens at `/<repo>/commit/<did>/<sha>`, read from that
account directly. One reached from a pull request opens under it instead, at
`/<repo>/pull/<did>/<branch>/~/commit/<sha>`, so the section a reader is in is
the section they came from, and the way back names the pull request. A pull request opens whole rather than at its tip, addressed
`<base>..<tip>`: eleven commits of work is one thing to read, and reading it a
commit at a time shows a file once per commit that touched it.

The account that owns the repository gets Merge and Squash on the page. A
merge keeps the branch in the graph, as a second parent; a squash writes one
commit and names the branch in `Merged-from` and `Merged-ref` trailers, which
is the decentralised form of a forge's `(#123)`. Both need the branch to be up
to date with main, so a branch main has moved under is told to rebase rather
than offered a button that cannot work.

Merging runs on the account's own server, at `/account/api/git/merge`, under the
session cookie the account page answers to. The page is served from that
account's site, so the endpoint is same-origin. It is the one act here that
needs authority over the repository, and an atproto `repo:` scope names a
whole collection: a page holding one could rewrite every repository the account
owns.

A merged pull request opens from the merge that brought it in. The merge
commit's second parent is the tip that was merged and its first parents are
main's own trunk, so the branch's earliest commit outside that trunk is where
the work began. A branch fast-forwarded into main leaves no such commit, and
it opens at its tip alone. A diff opened off the list carries a breadcrumb back
to the tab it came from.

Its commits are its own, at `/<repo>/commits/<did>/<base>..<tip>`: the commits
the tip reaches that the base does not. That is a different question from the
branch's history, which reaches back through everything the branch was cut
from.

A pull request written by an agent touches tens of files, so a file list on a
card is a count and the directories the files are in, opening to the paths.
`just dev-git-scale-demo` is that case: pds.js itself, with pull requests of 41
and 24 files overlapping on 24.

`just dev-git-collab` runs the whole thing locally: four accounts on four
servers, one repository, and the records to read.

## Reviewing from the page

The header holds the visitor's session: who they are and the way out, or a
button that opens a dialog asking for a handle or DID. Signing in is the one
thing on the page that leaves it, so the dialog has room to say where the
visitor is going, what their words are written to, and what failed when
something does. The header had room for none of that: a form there widened the
bar as it opened and left an error to be read from a tooltip. The app
is an atproto OAuth client with no backend, PKCE for the code and DPoP for
the token, the same client shape `@pdsjs/spaces-ui` uses. The scope names the
collections the page writes and the action it writes each with: the reviews,
pull requests and issues a participant publishes, and the repository and
config records the owner's own creates, merges and settings touch.

A pull request page carries the review composer above its diffs, so the
verdict is given where the work is summarised rather than after every hunk. A
diff line takes a comment of its own: the anchor names the path, the line, the
side, and the line's own text, in the tree the review's sha names. On that
that commit placement is exact whatever happens to the branch; a comment made
against a commit the branch has replaced is counted rather than misplaced, and
marked as answering one.

A comment answers to a reply: a review record naming the review it answers by
its exact version, still naming the pull request and carrying its own anchor,
so a reader that does not know `replyTo` shows the reply beside what it
answers rather than losing it. A line's comments read as a conversation,
oldest first, each reply under its parent, one level deep. The statements
shown come from the config's reviewers and collaborators together, because a
conversation needs the author's side.

The review is written to the reviewer's own repo on their own PDS. This
repository is never written to, and publishing carries no promise of being
displayed: the config's reviewers list is the repository's answer to whose
statements its readers see.

Sign-in needs the app's OAuth registration served beside the page, which the
deploy writes when `GIT_ORIGIN` names the site's https origin. On localhost
the client states its registration in the client_id itself, so `npm run dev`
signs in with no registration at all.

## Issues

`/<repo>/issues` lists every thread, newest activity first; a thread is a
page at `/<repo>/issues/<did>/<rkey>`, addressed by its opener. Filing one is
a page too, at `/<repo>/issues/new`, with a tab that reads the words back the
way the thread will. An issue is somebody's account of what went wrong and it
is markdown wherever it is shown, so it is worth the room to write and the tab
to check; a review takes the same field. Filing and replying write
`dev.pdsjs.git.issue` records to the visitor's own repo, the same session the
reviews use. The state shown is the owner's latest
disposition and nobody else's; another account's "marked resolved" renders
as the comment it is. Listed voices read directly; everybody else arrives
through the runners' issue index, each record fetched from the server that
holds it.

## Packages

A repository shows what it publishes to the same account's registries: the
npm packages and container images its `dev.pdsjs.git.config` record names in
its `packages` list. Each one appears with the command that installs it from
this account, and with every version or tag it holds. A reader installs from
the host that serves this page.

The link is that list and nothing else. A `dev.pdsjs.npm.package` record
carries no repository name and atproto has no reverse index, so the owner sets
the list in the repository's settings, which offers the packages the account
has already published.

## Settings

`/<repo>/settings` is the owner's, and the tab appears for their sign-in
alone. Four groups, each saving on its own, because
`dev.pdsjs.git.updateSettings` takes one setting per request and leaves the
rest as it found them: the description and the default branch, the protected
branches, the check runner, and the packages the repository builds. Below
them the repository is renamed and deleted.

Where a setting is written decides what a push can do to it. The description
and the default branch are fields of the `dev.pdsjs.git.repo` record, which
every push rewrites, so the server reads that record and patches the two
rather than replacing it. The rest are in the `dev.pdsjs.git.config` record
beside it, which no push touches, so a protected branch outlives every push it
refuses.

The runner field takes a handle or a DID, and resolves a handle to the DID the
record holds. A private repository is configured the same way: the write names
the space, and the server's space admin makes it.

Renaming writes one field. The record key is the repository's identity, so
its issues, pull requests and checks stay with it; the address is what moves,
and the panel says the clone remotes have to be re-pointed. A private
repository keeps its name, because the space holding it is what an address
resolves to and a space is keyed by that name.

Deleting is the last panel, in its own colour, and asks for the repository's
name to be typed before the button works. What goes is stated rather than
implied: the record, its settings and every bundle blob. A repository holding
a protected branch refuses, and the server says which branch.

The write runs as the owner, on the owner's own PDS, under the owner's grant.
The server accepts it only from a token whose account is the site's, so
nobody else's session configures a repository here.

## Running it against a local PDS

`npm run dev` serves the app on a port of its own, so two routes the account's
site would answer come from the PDS through the dev server's proxy: the DID
the hostname publishes, and the blob route the pack reader opens. `PDS_URL`
names that server, default `http://localhost:2471`.

A did:plc resolves against `https://plc.directory`. A local stack runs a PLC
of its own, holding DIDs the public directory has never seen, so point the
build at it:

```sh
PDS_URL=http://localhost:2471 VITE_PLC_URL=http://localhost:2582 \
  npm run dev --workspace @pdsjs/git-ui
```

Both are read at build time. A deploy that sets neither resolves against the
public directory and reads the account whose site serves it.

## Deploying

```sh
PDS_URL=https://pds.example.com \
PDS_DID=did:plc:… \
PDS_APP_PASSWORD=… \
npm run deploy --workspace @pdsjs/git-ui
```

The site name comes from `GIT_SITE`, default `git`, so it answers at
`git.<your site domain>`. The deploy also writes the query def it reads and
publishes a `dev.pdsjs.app.manifest`, which makes it installable by anyone
else running pds.js.

The build is one self-contained document. The site record names a file by
path and the page HTML caches for minutes, so a build split across hashed
assets would leave a visitor holding old HTML asking for a script the record
no longer names, and the SPA fallback would answer with HTML where the page
expects JavaScript. A single document cannot fall out of step with itself.

Fonts stay on the system stacks for the same reason: every asset is inlined,
and a webfont would multiply the document's size.

## Raw files

The page is rendered in the browser, so a reader that is not a browser gets
the shell and no content. Raw bytes come from the PDS instead:

```
GET https://<pds-host>/git/<handle>/<repo>/raw/<ref>/<path>
```

A directory lists its entries one per line, so the same route walks a tree.
The file view links to it, but only where the deploy found the route
answering: it arrived after the smart HTTP endpoint, so a PDS can serve one
and not the other.

## The clone URLs

`atproto://` always works wherever the helper is installed. The `https://`
form needs the read-only smart HTTP endpoint (`PDS_EXPERIMENTAL_GIT_HTTP`).
The deploy probes for it and bakes the answer into the page, because that
endpoint answers before the CORS wrapper and the page cannot ask on its own.

## Limits

- Public repositories only. A repository in a permissioned space needs
  storage access the page does not have.
- A repository whose record predates the bundle index downloads its whole
  chain to open. Any push with a current helper writes the index.