diff --git a/package.json b/package.json index fba992d..18f162a 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "release:cut": "node scripts/cut-release.mjs", "build:ui": "npm run build --workspace=@pdsjs/account-ui", "build:worker": "node scripts/build-worker-bundle.mjs", - "build:start": "npm run build --workspace=@pdsjs/start" + "build:start": "npm run build --workspace=@pdsjs/start", + "build:git-ui": "npm run build --workspace=@pdsjs/git-ui" }, "dependencies": { "@pdsjs/core": "workspace:*", diff --git a/packages/git-ui/.gitignore b/packages/git-ui/.gitignore new file mode 100644 index 0000000..1521c8b --- /dev/null +++ b/packages/git-ui/.gitignore @@ -0,0 +1 @@ +dist diff --git a/packages/git-ui/README.md b/packages/git-ui/README.md new file mode 100644 index 0000000..0af4a08 --- /dev/null +++ b/packages/git-ui/README.md @@ -0,0 +1,131 @@ +# @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 and no server support. + +React, shadcn conventions and Base UI, the same stack the account pages use. + +## 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. `@pdsjs/git/browser` does that +work in the page. Bundle blobs are fetched from this origin's `/.blobs/` +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. + +## 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.`. 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:///git///raw// +``` + +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. +- Opening a repository downloads its whole bundle chain. That is fine at + personal scale and slow for a large repository. diff --git a/packages/git-ui/components.json b/packages/git-ui/components.json new file mode 100644 index 0000000..0661ec8 --- /dev/null +++ b/packages/git-ui/components.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": false, + "tailwind": { + "config": "", + "css": "src/style.css", + "baseColor": "zinc", + "cssVariables": true + }, + "aliases": { + "components": "#/components", + "utils": "#/lib/utils", + "ui": "#/components/ui", + "lib": "#/lib", + "hooks": "#/hooks" + } +} diff --git a/packages/git-ui/index.html b/packages/git-ui/index.html new file mode 100644 index 0000000..203988a --- /dev/null +++ b/packages/git-ui/index.html @@ -0,0 +1,20 @@ + + + + + + Repositories + + + + +
+ + + diff --git a/packages/git-ui/package.json b/packages/git-ui/package.json new file mode 100644 index 0000000..351ebb7 --- /dev/null +++ b/packages/git-ui/package.json @@ -0,0 +1,37 @@ +{ + "name": "@pdsjs/git-ui", + "private": true, + "version": "0.0.0", + "type": "module", + "description": "Public repository browser for the git repositories in an atproto account", + "scripts": { + "build": "vite build", + "dev": "vite", + "deploy": "npm run build && node scripts/deploy.mjs", + "lint": "node scripts/check-single-component.mjs" + }, + "dependencies": { + "@base-ui/react": "^1.6.0", + "@pdsjs/git": "workspace:*", + "@tanstack/react-query": "^5.101.4", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "highlight.js": "^11.12.0", + "lucide-react": "^1.28.0", + "marked": "^16.4.1", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "tailwind-merge": "^3.6.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@vitejs/plugin-react": "^6.0.5", + "tailwindcss": "^4.3.3", + "vite": "^8.2.0", + "vite-plugin-singlefile": "^2.3.3" + }, + "license": "MIT", + "imports": { + "#/*": "./src/*" + } +} diff --git a/packages/git-ui/scripts/check-single-component.mjs b/packages/git-ui/scripts/check-single-component.mjs new file mode 100644 index 0000000..50cc6c7 --- /dev/null +++ b/packages/git-ui/scripts/check-single-component.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node +// Guards the atomic library: every component file exports exactly one +// component. Biome has no rule for this, so this runs in `npm run lint`. + +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('../src/components/', import.meta.url)); +const offenders = []; + +/** @param {string} dir */ +function walk(dir) { + for (const name of readdirSync(dir)) { + const path = `${dir}/${name}`; + if (statSync(path).isDirectory()) { + walk(path); + } else if (name.endsWith('.jsx')) { + const source = readFileSync(path, 'utf8'); + const components = [ + ...source.matchAll(/^export\s+function\s+([A-Z]\w*)/gm), + ].map((match) => match[1]); + if (components.length > 1) { + offenders.push(`${path}: ${components.join(', ')}`); + } + } + } +} + +walk(root); + +if (offenders.length) { + console.error('Component files exporting more than one component:'); + for (const line of offenders) console.error(` ${line}`); + process.exit(1); +} +console.log('one-component-per-file: ok'); diff --git a/packages/git-ui/scripts/deploy.mjs b/packages/git-ui/scripts/deploy.mjs new file mode 100644 index 0000000..31c6adc --- /dev/null +++ b/packages/git-ui/scripts/deploy.mjs @@ -0,0 +1,220 @@ +// Deploy the repository browser to the account's own site. +// +// The build is one self-contained document, so the site record names one +// file and a visitor holding cached HTML can never ask for an asset the +// record no longer carries. +// +// Usage: PDS_URL=… PDS_DID=… PDS_APP_PASSWORD=… npm run deploy --workspace @pdsjs/git-ui + +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const PDS = process.env.PDS_URL; +const DID = process.env.PDS_DID; +if (!PDS || !DID) throw new Error('Set PDS_URL and PDS_DID.'); +const SITE = process.env.GIT_SITE || 'git'; +const SITES = 'dev.pdsjs.site.deploy'; +const DEFS = 'dev.pdsjs.query.def'; +const REPOS = 'dev.pdsjs.git.repo'; +const IDENTS = 'dev.pdsjs.git.identity'; +const QUERY = 'git-repos'; +const SORT = { field: 'updatedAt', direction: 'desc' }; + +async function xrpc(path, init = {}) { + const response = await fetch(`${PDS}/xrpc/${path}`, init); + if (!response.ok) { + throw new Error( + `${path.split('?')[0]} ${response.status}: ${(await response.text()).slice(0, 200)}`, + ); + } + return response.json(); +} + +const password = process.env.PDS_APP_PASSWORD; +if (!password) throw new Error('Set PDS_APP_PASSWORD.'); +const session = await xrpc('com.atproto.server.createSession', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ identifier: DID, password }), +}); +const auth = { Authorization: `Bearer ${session.accessJwt}` }; + +/** @param {string} collection @param {string} rkey @param {Record} record */ +async function put(collection, rkey, record) { + await xrpc('com.atproto.repo.putRecord', { + method: 'POST', + headers: { ...auth, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + repo: DID, + collection, + rkey, + record: { $type: collection, ...record }, + }), + }); + console.error(`put ${collection}/${rkey}`); +} + +/** @param {string} contentType @param {BodyInit} body */ +async function uploadBlob(contentType, body) { + const res = await fetch(`${PDS}/xrpc/com.atproto.repo.uploadBlob`, { + method: 'POST', + headers: { ...auth, 'Content-Type': contentType }, + body, + }); + if (!res.ok) throw new Error(`uploadBlob ${res.status}`); + return (await res.json()).blob; +} + +// ---- the query the app reads ---- + +await put(DEFS, QUERY, { + collection: REPOS, + sort: SORT, + createdAt: new Date().toISOString(), +}); + +// ---- the account's own git identities ---- + +// GIT_IDENTS is a comma-separated list of commit author addresses, or bare +// names for a history written without one. Append `=did:plc:...` to say the +// author is somebody other than this account, which is how a project account +// attributes commits to the people who wrote them. +// +// GIT_IDENTS='me@example.com,them@example.com=did:plc:...' +// +// The record is the account's, not the site's: it holds for every repository +// the account serves. Leaving GIT_IDENTS unset leaves whatever the account +// already claims untouched. +const idents = (process.env.GIT_IDENTS || '') + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => { + const [subject, did] = entry.split('=').map((part) => part.trim()); + const claim = subject.includes('@') + ? { email: subject } + : { name: subject }; + return did ? { ...claim, did } : claim; + }); + +if (idents.length > 0) { + const claimed = await fetch( + `${PDS}/xrpc/com.atproto.repo.getRecord?repo=${DID}&collection=${IDENTS}&rkey=self`, + ); + const createdAt = claimed.ok + ? ((await claimed.json()).value.createdAt ?? new Date().toISOString()) + : new Date().toISOString(); + await put(IDENTS, 'self', { + idents, + createdAt, + updatedAt: new Date().toISOString(), + }); + console.error( + `claimed ${idents.length} git ${idents.length === 1 ? 'ident' : 'idents'}.`, + ); +} + +// ---- does this PDS serve git over HTTP? ---- + +// The /git/ endpoint answers before the CORS wrapper, so the page cannot ask +// this itself. Resolve it here and bake the answer in. Probing needs a real +// repository, since the endpoint reports an unknown one the same way it +// reports being switched off. +const existing = await xrpc( + `com.atproto.repo.listRecords?repo=${DID}&collection=${REPOS}&limit=1`, +); +let httpClone = false; +const probe = existing.records[0]?.uri.split('/').pop(); +if (probe) { + const res = await fetch( + `${PDS}/git/${DID}/${probe}/info/refs?service=git-upload-pack`, + ); + httpClone = + res.ok && + (res.headers.get('content-type') || '').includes( + 'git-upload-pack-advertisement', + ); +} +console.error( + httpClone + ? 'git smart HTTP is on; showing https clone URLs.' + : 'git smart HTTP is off; showing atproto:// clone URLs only.', +); + +// Raw file serving arrived after the smart HTTP endpoint, so a server can +// answer one and not the other. Probe for it separately rather than assume +// the two travel together. +let rawFiles = false; +if (probe) { + const res = await fetch(`${PDS}/git/${DID}/${probe}/raw`); + rawFiles = + res.ok && (res.headers.get('content-type') || '').startsWith('text/plain'); +} +console.error( + rawFiles + ? 'raw file paths are served; linking to them.' + : 'raw file paths are not served by this PDS; hiding the links.', +); + +// ---- the built document ---- + +const distIndex = fileURLToPath(new URL('../dist/index.html', import.meta.url)); +let html; +try { + html = readFileSync(distIndex, 'utf8'); +} catch { + throw new Error('No build found. Run `npm run build` first.'); +} + +// The page reads its server-dependent settings off this global. It goes in +// the head so it is set before the module script runs. +const config = ``; +if (!html.includes('')) throw new Error('Built HTML has no head.'); +html = html.replace('', `${config}`); + +// ---- deploy ---- + +const blob = await uploadBlob('text/html; charset=utf-8', html); +await put(SITES, SITE, { + files: [ + { + path: 'index.html', + blob, + contentType: 'text/html; charset=utf-8', + cacheControl: 'public, max-age=300', + }, + ], + fallback: 'index.html', + createdAt: new Date().toISOString(), +}); +console.error( + `Deployed ${SITE}: ${Math.round(html.length / 1024)}KB, one file.`, +); + +// ---- publish it as an installable app ---- + +const ICON = ` + + + + + +`; +const icon = await uploadBlob('image/svg+xml', ICON); + +const deployed = await xrpc( + `com.atproto.repo.getRecord?repo=${DID}&collection=${SITES}&rkey=${SITE}`, +); +await put('dev.pdsjs.app.manifest', 'git', { + name: 'Git', + description: + 'A public forge for the git repositories in your account. Visitors browse trees, files, history and commit diffs with no sign-in, because the page reads the repository records through a query and opens the bundle blobs itself.', + icon, + site: { uri: deployed.uri, cid: deployed.cid }, + suggestedSite: 'git', + primitives: ['serve', 'query'], + external: false, + queries: [{ name: QUERY, collection: REPOS, sort: SORT }], + createdAt: new Date().toISOString(), +}); +console.error('Published dev.pdsjs.app.manifest/git.'); diff --git a/packages/git-ui/src/app.jsx b/packages/git-ui/src/app.jsx new file mode 100644 index 0000000..63f51e2 --- /dev/null +++ b/packages/git-ui/src/app.jsx @@ -0,0 +1,145 @@ +import { useQuery } from '@tanstack/react-query'; +import { Avatar } from '#/components/atoms/avatar.jsx'; +import { Link } from '#/components/atoms/link.jsx'; +import { Skeleton } from '#/components/atoms/skeleton.jsx'; +import { initial, loadAuthors, owner } from '#/lib/authors.js'; +import { + defaultRef, + discover, + loadRepos, + repoRecord, + splitRefPath, +} from '#/lib/git.js'; +import { NavigationProvider, useLocation } from '#/lib/navigation.jsx'; +import { cn } from '#/lib/utils.js'; +import { CommitPage } from '#/pages/commit.jsx'; +import { CommitsPage } from '#/pages/commits.jsx'; +import { FilePage } from '#/pages/file.jsx'; +import { ReposPage } from '#/pages/repos.jsx'; +import { TreePage } from '#/pages/tree.jsx'; + +const CONFIG = globalThis.GIT_APP || {}; + +/** + * Pick the screen for a path. The repository record is already in hand, which + * is what lets a ref carrying slashes be told apart from the path after it. + */ +function Screen({ path, httpClone }) { + const segments = path.split('/').filter(Boolean).map(decodeURIComponent); + if (segments.length === 0) return ; + + const [repo, kind, ...rest] = segments; + const record = repoRecord(repo); + const tail = rest.join('/'); + + if (!record) { + return

No such repository.

; + } + if (kind === 'commit') return ; + if (kind === 'commits') { + return ; + } + if (kind === 'blob') { + const { ref, path: filePath } = splitRefPath(record, tail); + return ; + } + if (kind === 'tree') { + const { ref, path: dirPath } = splitRefPath(record, tail); + return ( + + ); + } + return ( + + ); +} + +export function App() { + const { path, navigate } = useLocation(); + const wide = path.split('/').filter(Boolean)[1] === 'commit'; + + // Discovery names the account and its PDS, and the repository list has to + // be in hand before any screen renders: every route below the list reads + // its refs out of that record. + const { data: account, error } = useQuery({ + queryKey: ['account'], + queryFn: async () => { + const found = await discover(); + // The identity claims have to be in hand before a commit renders, so + // an author does not change from a bare name to a handle under the + // reader. They cost one request each and never change while the tab + // is open. + await Promise.all([loadRepos(), loadAuthors()]); + return found; + }, + staleTime: Number.POSITIVE_INFINITY, + }); + + return ( + +
+
+ + Repositories + + {account && ( + + + {initial(owner()?.displayName || account.handle)} + + } + className="size-6" + /> + + {account.handle} + + + )} +
+ + {error ? ( +

+ Could not reach the PDS. {error.message} +

+ ) : !account ? ( + + ) : ( + + )} + + +
+
+ ); +} diff --git a/packages/git-ui/src/components/atoms/avatar.jsx b/packages/git-ui/src/components/atoms/avatar.jsx new file mode 100644 index 0000000..6820bdf --- /dev/null +++ b/packages/git-ui/src/components/atoms/avatar.jsx @@ -0,0 +1,24 @@ +import { Avatar as BaseAvatar } from '@base-ui/react/avatar'; +import { cn } from '#/lib/utils.js'; + +/** + * A user or app avatar. Shows `src` once it loads and the `fallback` node + * (an initial, usually) until or unless it does. + */ +export function Avatar({ src, fallback, className }) { + return ( + + {src && ( + + )} + + {fallback} + + + ); +} diff --git a/packages/git-ui/src/components/atoms/badge.jsx b/packages/git-ui/src/components/atoms/badge.jsx new file mode 100644 index 0000000..baf9dcc --- /dev/null +++ b/packages/git-ui/src/components/atoms/badge.jsx @@ -0,0 +1,21 @@ +import { cva } from 'class-variance-authority'; +import { cn } from '#/lib/utils.js'; + +const badgeVariants = cva( + 'inline-flex items-center rounded-[4px] border px-1.5 py-px font-mono text-[10.5px] leading-4', + { + variants: { + variant: { + default: 'border-border bg-muted text-muted-foreground', + scope: 'border-key/25 bg-key/10 text-key', + }, + }, + defaultVariants: { variant: 'default' }, + }, +); + +export function Badge({ className, variant, ...props }) { + return ( + + ); +} diff --git a/packages/git-ui/src/components/atoms/button.jsx b/packages/git-ui/src/components/atoms/button.jsx new file mode 100644 index 0000000..d358c10 --- /dev/null +++ b/packages/git-ui/src/components/atoms/button.jsx @@ -0,0 +1,68 @@ +import { cva } from 'class-variance-authority'; +import { cn } from '#/lib/utils.js'; + +const buttonVariants = cva( + "inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-xl text-sm font-semibold whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground hover:bg-primary/90', + destructive: + 'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40', + outline: + 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', + secondary: + 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline', + // A card-shaped button: the surface and hover of a tile, with layout + // (direction, padding) left to the caller. + tile: 'w-full rounded-2xl border border-border bg-muted text-left hover:border-input hover:bg-accent', + // A full-width dashed affordance, e.g. "add a passkey". + dashed: + 'w-full rounded-2xl border border-border border-dashed bg-transparent text-faint hover:border-ring hover:text-key', + }, + size: { + default: 'h-9 px-4 py-2 has-[>svg]:px-3', + xs: "h-6 gap-1 px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: 'h-8 gap-1.5 px-3 has-[>svg]:px-2.5', + lg: 'h-10 px-6 has-[>svg]:px-4', + // A prominent header/action pill. + pill: 'h-11 px-5 text-[13px] font-extrabold', + // A full-width block CTA, the same height as an input. + block: 'h-11 w-full', + // A free-height surface sized by its own padding (tiles, dashed). + surface: 'h-auto', + icon: 'size-9', + 'icon-xs': "size-6 [&_svg:not([class*='size-'])]:size-3", + 'icon-sm': 'size-8', + 'icon-lg': 'size-10', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +); + +function Button({ + className, + variant = 'default', + size = 'default', + type = 'button', + ...props +}) { + return ( + + + + ))} + + + + + + ); +} diff --git a/packages/git-ui/src/components/molecules/diff-file.jsx b/packages/git-ui/src/components/molecules/diff-file.jsx new file mode 100644 index 0000000..d225064 --- /dev/null +++ b/packages/git-ui/src/components/molecules/diff-file.jsx @@ -0,0 +1,121 @@ +import { ChevronDownIcon, ChevronRightIcon } from 'lucide-react'; +import { useState } from 'react'; +import { Badge } from '#/components/atoms/badge.jsx'; +import { bytes } from '#/lib/format.js'; +import { highlightHunk, languageFor } from '#/lib/highlight.js'; +import { cn } from '#/lib/utils.js'; + +const STATUS_STYLE = { + added: 'border-success/25 bg-success-dim text-success', + deleted: 'border-destructive/25 bg-destructive-dim text-destructive', + modified: '', +}; + +const ROW_STYLE = { add: 'bg-diff-add', delete: 'bg-diff-del', context: '' }; +const SIGN_STYLE = { + add: 'text-diff-add-ink', + delete: 'text-diff-del-ink', + context: 'text-faint', +}; +const SIGN = { add: '+', delete: '-', context: ' ' }; + +/** + * One changed file: its status, its counts, and its hunks. A commit that + * touches many files is easier to read with some of them shut, so each one + * folds away on its own. + */ +export function DiffFile({ file, ref }) { + const language = languageFor(file.path); + const [open, setOpen] = useState(true); + const Chevron = open ? ChevronDownIcon : ChevronRightIcon; + + return ( +
+
+ + + {file.status} + + + {file.path} + + + {file.additions > 0 && ( + +{file.additions} + )} + {file.additions > 0 && file.deletions > 0 && ' '} + {file.deletions > 0 && ( + -{file.deletions} + )} + +
+ + {!open ? null : file.hunks ? ( +
+ {file.hunks.map((hunk) => { + const coloured = highlightHunk(hunk.lines, language); + return ( +
+
+ + + @@ -{hunk.oldStart},{hunk.oldLines} +{hunk.newStart}, + {hunk.newLines} @@ + +
+ {hunk.lines.map((line, index) => ( +
+ + {line.oldLine ?? ''} + + + {line.newLine ?? ''} + + + {SIGN[line.type]} + + +
+ ))} +
+ ); + })} +
+ ) : ( +

+ {file.binary + ? `Binary file, ${bytes(file.newSize ?? file.oldSize)}.` + : 'Change too large to show.'} +

+ )} +
+ ); +} diff --git a/packages/git-ui/src/components/molecules/diff-tree.jsx b/packages/git-ui/src/components/molecules/diff-tree.jsx new file mode 100644 index 0000000..f7c4c49 --- /dev/null +++ b/packages/git-ui/src/components/molecules/diff-tree.jsx @@ -0,0 +1,139 @@ +import { + ChevronDownIcon, + ChevronRightIcon, + FileIcon, + FolderIcon, + SearchIcon, +} from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { buildFileTree, filterFiles } from '#/lib/file-tree.js'; +import { cn } from '#/lib/utils.js'; + +const STATUS_MARK = { added: '+', deleted: '−', modified: '±' }; +const STATUS_COLOR = { + added: 'text-success', + deleted: 'text-destructive', + modified: 'text-faint', +}; + +/** + * One row per node, indented by depth. Directories carry their own open + * state, so closing one leaves the rest as they were. + */ +function Node({ node, depth, onPick, picked }) { + const [open, setOpen] = useState(true); + const pad = { paddingLeft: `${depth * 12 + 8}px` }; + + if (node.type === 'file') { + const status = node.file.status; + return ( + + ); + } + + const Chevron = open ? ChevronDownIcon : ChevronRightIcon; + return ( +
+ + {open && + node.children.map((child) => ( + + ))} +
+ ); +} + +/** + * The changed files of a commit, as the tree they came from, beside the + * diffs themselves. Picking one moves to it rather than opening a page: the + * diffs are all on this screen already. + */ +export function DiffTree({ files, onPick, picked }) { + const [filter, setFilter] = useState(''); + const shown = useMemo(() => filterFiles(files, filter), [files, filter]); + const tree = useMemo(() => buildFileTree(shown), [shown]); + + return ( +
+
+ + setFilter(event.target.value)} + placeholder="Filter files" + aria-label="Filter changed files" + className="h-8 w-full rounded-xl border border-input bg-background pr-2.5 pl-8 text-[13px] outline-none transition-colors placeholder:text-faint focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50" + /> +
+ {shown.length === 0 ? ( +

+ No file matches that. +

+ ) : ( +
+ {tree.map((node) => ( + + ))} +
+ )} + {shown.length !== files.length && ( + // Only while a filter is narrowing things. The commit card above + // already says how many files changed. +

+ {shown.length} of {files.length} shown +

+ )} +
+ ); +} diff --git a/packages/git-ui/src/components/molecules/empty-state.jsx b/packages/git-ui/src/components/molecules/empty-state.jsx new file mode 100644 index 0000000..06c9f32 --- /dev/null +++ b/packages/git-ui/src/components/molecules/empty-state.jsx @@ -0,0 +1,30 @@ +import { cn } from '#/lib/utils.js'; + +/** + * The empty placeholder: an icon chip, a title, and a line of guidance. Pass + * the icon element as `icon`. + */ +export function EmptyState({ icon, title, description, className }) { + return ( +
+ {icon && ( +
+ {icon} +
+ )} + {title && ( +
{title}
+ )} + {description && ( +

+ {description} +

+ )} +
+ ); +} diff --git a/packages/git-ui/src/components/molecules/latest-commit.jsx b/packages/git-ui/src/components/molecules/latest-commit.jsx new file mode 100644 index 0000000..3d3897a --- /dev/null +++ b/packages/git-ui/src/components/molecules/latest-commit.jsx @@ -0,0 +1,53 @@ +import { HistoryIcon } from 'lucide-react'; +import { Link } from '#/components/atoms/link.jsx'; +import { AuthorLine } from '#/components/molecules/author-line.jsx'; +import { timeAgo } from '#/lib/format.js'; + +/** + * The commit at the tip of the ref in view, above the file listing: who wrote + * it, what it said, and how far the history goes. + * + * The count comes from the same walk that produced the commit, so it is the + * number of commits reachable from this ref rather than a total for the + * repository. A walk that hit its ceiling reads as "N+". + */ +export function LatestCommit({ repo, refName, commit, count, truncated }) { + const subject = commit.message.split('\n')[0]; + const short = commit.sha.slice(0, 7); + + return ( +
+ + + {subject} + + + + {short} + + + {timeAgo(commit.committedAt)} + + + + + {count} + {truncated ? '+' : ''} + + + {count === 1 && !truncated ? 'commit' : 'commits'} + + +
+ ); +} diff --git a/packages/git-ui/src/components/molecules/markdown.jsx b/packages/git-ui/src/components/molecules/markdown.jsx new file mode 100644 index 0000000..29378fb --- /dev/null +++ b/packages/git-ui/src/components/molecules/markdown.jsx @@ -0,0 +1,67 @@ +import { useEffect, useMemo, useRef } from 'react'; +import { account, readerFor } from '#/lib/git.js'; +import { renderMarkdown } from '#/lib/markdown.js'; + +/** + * A rendered markdown document from the repository. + * + * An image the document names by a relative path is a file in the repository, + * and the repository's objects are already in memory by the time this + * renders. The renderer leaves those images without a source and puts the + * path on the tag; this fills them in from the object store, which is why a + * README's diagrams appear without the server serving anything. + */ +export function Markdown({ text, repo, refName, basePath = '' }) { + const container = useRef(null); + const html = useMemo( + () => renderMarkdown(text, { repo, refName, basePath }), + [text, repo, refName, basePath], + ); + + // The effect reads the DOM that html produced, so it has to run again + // whenever html changes, even though it names no variable from it. + // biome-ignore lint/correctness/useExhaustiveDependencies: html rebuilds the DOM this reads + useEffect(() => { + const node = container.current; + if (!node) return undefined; + const images = node.querySelectorAll('img[data-repo-path]'); + if (images.length === 0) return undefined; + + let live = true; + /** @type {string[]} */ + const urls = []; + const reader = readerFor(repo); + + (async () => { + for (const image of images) { + const path = image.getAttribute('data-repo-path'); + try { + const file = await reader.readBytes(account.did, repo, refName, path); + if (!live || !file) return; + const url = URL.createObjectURL(new Blob([file.bytes])); + urls.push(url); + image.setAttribute('src', url); + } catch { + // A path the document names but the tree does not hold. The alt + // text stands in, the way a missing image always does. + } + } + })(); + + return () => { + live = false; + for (const url of urls) URL.revokeObjectURL(url); + }; + }, [html, repo, refName]); + + return ( +
+ ); +} diff --git a/packages/git-ui/src/components/molecules/reading-repo.jsx b/packages/git-ui/src/components/molecules/reading-repo.jsx new file mode 100644 index 0000000..64119f8 --- /dev/null +++ b/packages/git-ui/src/components/molecules/reading-repo.jsx @@ -0,0 +1,66 @@ +import { useEffect, useState, useSyncExternalStore } from 'react'; +import { bytes } from '#/lib/format.js'; +import { onRepoProgress, repoProgress } from '#/lib/git.js'; + +/** + * How long a read has to run before it is worth telling anyone about. A + * repository already open answers in a few milliseconds, and a bar that + * appears and leaves inside one frame reads as a flicker rather than as + * progress. + */ +const QUIET_MS = 400; + +/** + * What a repository being opened looks like while it opens. + * + * Browsing needs the whole bundle chain, because a pack names its objects + * only once every one of them is read. The record says how large that is + * before the first byte arrives, so the wait can be an honest measure rather + * than a spinner. + * + * Nothing renders until the read has run past QUIET_MS, so the common case, + * a listing served from a repository already in memory, shows no wait at + * all. Whatever appears after that fades in, so a read that finishes just + * over the line does not snap on and straight off again. + */ +export function ReadingRepo({ size }) { + // The third argument is the snapshot for a render with no client behind + // it. The store is a plain module value, so it reads the same either way. + const progress = useSyncExternalStore( + onRepoProgress, + repoProgress, + repoProgress, + ); + const [slow, setSlow] = useState(false); + + useEffect(() => { + const timer = setTimeout(() => setSlow(true), QUIET_MS); + return () => clearTimeout(timer); + }, []); + + if (!slow) return null; + + const total = progress.total || size || 0; + const done = total > 0 ? Math.min(1, progress.loaded / total) : 0; + + return ( +
+
+ Reading the repository + + {total > 0 ? `${bytes(progress.loaded)} of ${bytes(total)}` : ''} + +
+
+
+
+

+ History arrives as one pack, so the whole of it is read before the first + listing. It is cached from here on. +

+
+ ); +} diff --git a/packages/git-ui/src/components/molecules/ref-select.jsx b/packages/git-ui/src/components/molecules/ref-select.jsx new file mode 100644 index 0000000..3a1f69c --- /dev/null +++ b/packages/git-ui/src/components/molecules/ref-select.jsx @@ -0,0 +1,69 @@ +import { Select } from '@base-ui/react/select'; +import { CheckIcon, ChevronsUpDownIcon, GitBranchIcon } from 'lucide-react'; +import { branchRefs, shortRef, tagRefs } from '#/lib/git.js'; +import { cn } from '#/lib/utils.js'; + +/** + * The branch and tag switcher. Both groups come from the repository record, + * so opening the menu costs nothing. Choosing a ref lands on that ref's root: + * the path in view may not exist on it. + */ +export function RefSelect({ repo, value, onSelect, className }) { + const branches = branchRefs(repo).map((ref) => shortRef(ref.name)); + const tags = tagRefs(repo).map((ref) => shortRef(ref.name)); + if (branches.length + tags.length === 0) return null; + + const groups = [ + ['Branches', branches], + ['Tags', tags], + ].filter(([, names]) => names.length > 0); + + return ( + + + + + + + + + + + + {groups.map(([label, names]) => ( + + + {label} + + {names.map((name) => ( + + {/* The indicator renders only for the selected item, so + the fixed box keeps every label on one edge. */} + + + + + + + {name} + + + ))} + + ))} + + + + + ); +} diff --git a/packages/git-ui/src/components/molecules/tree-list.jsx b/packages/git-ui/src/components/molecules/tree-list.jsx new file mode 100644 index 0000000..bcef649 --- /dev/null +++ b/packages/git-ui/src/components/molecules/tree-list.jsx @@ -0,0 +1,58 @@ +import { FileIcon, FolderIcon, LinkIcon, PackageIcon } from 'lucide-react'; +import { Link } from '#/components/atoms/link.jsx'; +import { bytes } from '#/lib/format.js'; +import { cn } from '#/lib/utils.js'; + +const ICONS = { + dir: FolderIcon, + file: FileIcon, + symlink: LinkIcon, + submodule: PackageIcon, +}; + +/** One directory's entries, directories first, then files, both by name. */ +export function TreeList({ repo, refName, path, entries }) { + return ( +
    + {entries.map((entry) => { + const child = path ? `${path}/${entry.name}` : entry.name; + const kind = entry.type === 'dir' ? 'tree' : 'blob'; + const href = `/${encodeURIComponent(repo)}/${kind}/${encodeURI(refName)}/${encodeURI(child)}`; + const Icon = ICONS[entry.type] ?? FileIcon; + const row = ( + <> + + {entry.name} + + {entry.type === 'submodule' ? 'submodule' : bytes(entry.size)} + + + ); + return ( +
  • + {entry.type === 'submodule' ? ( + + {row} + + ) : ( + + {row} + + )} +
  • + ); + })} +
+ ); +} diff --git a/packages/git-ui/src/lib/authors.js b/packages/git-ui/src/lib/authors.js new file mode 100644 index 0000000..9ab30f0 --- /dev/null +++ b/packages/git-ui/src/lib/authors.js @@ -0,0 +1,118 @@ +// Showing a commit author as an atproto identity, where one can be found. +// +// A commit names its author with a name and an address that the author's own +// git client wrote. Nothing signs either, so the page shows an atproto +// identity 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. + +import { + matchIdent, + parseIdentityRecord, + splitIdent, +} from '@pdsjs/git/authors'; +import { account } from '#/lib/git.js'; + +const APPVIEW = 'https://public.api.bsky.app'; +const IDENTITY_COLLECTION = 'dev.pdsjs.git.identity'; + +/** The appview takes at most this many actors per call. */ +const PROFILE_BATCH = 25; + +/** The claims the hosting account makes, about itself and about others. */ +let claims = []; + +/** Profiles by DID, for every DID the claims name plus the account itself. */ +const profiles = new Map(); + +/** + * @param {string[]} dids + * @returns {Promise} resolved once every profile the appview knows is + * in the map. A DID it does not know is simply absent. + */ +async function loadProfiles(dids) { + for (let at = 0; at < dids.length; at += PROFILE_BATCH) { + const params = new URLSearchParams(); + for (const did of dids.slice(at, at + PROFILE_BATCH)) { + params.append('actors', did); + } + const body = await fetch( + `${APPVIEW}/xrpc/app.bsky.actor.getProfiles?${params}`, + ) + .then((res) => (res.ok ? res.json() : null)) + .catch(() => null); + for (const profile of body?.profiles ?? []) { + profiles.set(profile.did, { + did: profile.did, + handle: profile.handle, + displayName: profile.displayName || '', + avatar: profile.avatar || '', + }); + } + } +} + +/** + * Read the account's identity claims, then the profile of every identity + * they name. Both are anonymous, and both are needed before any commit + * renders, so this runs once alongside the repository list. + */ +export async function loadAuthors() { + const params = new URLSearchParams({ + repo: account.did, + collection: IDENTITY_COLLECTION, + rkey: 'self', + }); + const identity = await fetch( + `${account.pds}/xrpc/com.atproto.repo.getRecord?${params}`, + ) + .then((res) => (res.ok ? res.json() : null)) + .catch(() => null); + + claims = identity ? parseIdentityRecord(identity.value) : []; + profiles.clear(); + const wanted = new Set([account.did]); + for (const claim of claims) if (claim.did) wanted.add(claim.did); + await loadProfiles([...wanted]); + return { claims: claims.length, profiles: profiles.size }; +} + +export function owner() { + return profiles.get(account.did) ?? null; +} + +/** The first letter to show while an avatar loads, or in place of one. */ +export function initial(text) { + return (String(text || '?').trim()[0] || '?').toUpperCase(); +} + +/** + * How to show one commit author. + * + * A claim carrying no DID is the account speaking about itself. A claim + * naming another DID is the account vouching for somebody else, which the + * page shows the same way: the reader already decided to trust this account + * by opening its repositories. + * @param {string} ident - the commit's author ident + */ +export function describeAuthor(ident) { + const { name, email } = splitIdent(ident); + const claim = matchIdent(claims, ident); + const profile = claim ? profiles.get(claim.did || account.did) : null; + if (profile) { + return { + name: profile.displayName || profile.handle, + handle: profile.handle, + avatar: profile.avatar, + atproto: true, + title: `${ident} · claimed by @${owner()?.handle ?? account.handle}`, + }; + } + return { + name: name || email || 'unknown', + handle: '', + avatar: '', + atproto: false, + title: ident, + }; +} diff --git a/packages/git-ui/src/lib/file-tree.js b/packages/git-ui/src/lib/file-tree.js new file mode 100644 index 0000000..3df3c9d --- /dev/null +++ b/packages/git-ui/src/lib/file-tree.js @@ -0,0 +1,117 @@ +// Arranging a commit's changed paths into a tree. +// +// A diff names files by their whole path. Read as a list that buries what +// changed under directory names repeated on every row, so the paths are +// folded back into the tree they came from. A directory holding nothing but +// one more directory is shown as a single row, because the intermediate step +// carries no information a reader needs. + +/** + * @typedef {Object} FileNode + * @property {'file'} type + * @property {string} name - the last segment + * @property {string} path - the whole path, as the diff names it + * @property {unknown} file - the caller's entry for this path + */ + +/** + * @typedef {Object} DirNode + * @property {'dir'} type + * @property {string} name - one or more segments, joined where a chain folded + * @property {string} path - the whole path to this directory + * @property {Array} children + */ + +/** + * Fold a directory holding nothing but one directory into its child, so a + * lone chain reads as one row rather than one row per empty step. + * @param {DirNode} dir + * @returns {DirNode} + */ +function collapse(dir) { + let node = dir; + while (node.children.length === 1 && node.children[0].type === 'dir') { + const only = /** @type {DirNode} */ (node.children[0]); + node = { + type: 'dir', + name: `${node.name}/${only.name}`, + path: only.path, + children: only.children, + }; + } + return node; +} + +/** + * Directories first, then files, each by name. The same order the repository + * listing uses, so a reader moving between the two finds things in the same + * place. + * @param {Array} nodes + */ +function sortNodes(nodes) { + return nodes.sort((a, b) => { + if ((a.type === 'dir') !== (b.type === 'dir')) { + return a.type === 'dir' ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); +} + +/** + * Build the tree for a set of changed files. + * @param {Array<{path: string}>} files - a commit's changed files + * @returns {Array} the roots, sorted + */ +export function buildFileTree(files) { + /** @type {DirNode} */ + const root = { type: 'dir', name: '', path: '', children: [] }; + /** @type {Map} */ + const dirs = new Map([['', root]]); + + for (const file of files) { + const segments = file.path.split('/'); + const name = /** @type {string} */ (segments.pop()); + let parent = root; + let walked = ''; + for (const segment of segments) { + walked = walked ? `${walked}/${segment}` : segment; + let dir = dirs.get(walked); + if (!dir) { + dir = { type: 'dir', name: segment, path: walked, children: [] }; + dirs.set(walked, dir); + parent.children.push(dir); + } + parent = dir; + } + parent.children.push({ type: 'file', name, path: file.path, file }); + } + + /** @param {DirNode} dir */ + const finish = (dir) => { + const folded = collapse(dir); + folded.children = sortNodes( + folded.children.map((child) => + child.type === 'dir' ? finish(/** @type {DirNode} */ (child)) : child, + ), + ); + return folded; + }; + + return sortNodes( + root.children.map((child) => + child.type === 'dir' ? finish(/** @type {DirNode} */ (child)) : child, + ), + ); +} + +/** + * The files a filter matches, by substring of the whole path. An empty + * filter matches everything, so the tree is whole until someone types. + * @param {Array<{path: string}>} files + * @param {string} filter + */ +export function filterFiles(files, filter) { + const needle = filter.trim().toLowerCase(); + if (!needle) return files; + return files.filter((file) => file.path.toLowerCase().includes(needle)); +} diff --git a/packages/git-ui/src/lib/format.js b/packages/git-ui/src/lib/format.js new file mode 100644 index 0000000..063fd55 --- /dev/null +++ b/packages/git-ui/src/lib/format.js @@ -0,0 +1,38 @@ +/** Byte sizes, relative times, and git idents, formatted for display. */ + +/** @param {number|null|undefined} size */ +export function bytes(size) { + if (size === null || size === undefined) return ''; + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; + return `${(size / (1024 * 1024)).toFixed(1)} MB`; +} + +const UNITS = [ + ['second', 60], + ['minute', 60], + ['hour', 24], + ['day', 7], + ['week', 4.35], + ['month', 12], + ['year', Number.POSITIVE_INFINITY], +]; + +/** @param {string|number} when - an ISO string or epoch milliseconds */ +export function timeAgo(when) { + const then = typeof when === 'number' ? when : Date.parse(when); + if (!then) return ''; + let value = Math.max(1, (Date.now() - then) / 1000); + for (const [unit, size] of UNITS) { + if (value < size) { + const count = Math.floor(value); + return `${count} ${unit}${count === 1 ? '' : 's'} ago`; + } + value /= size; + } + return ''; +} + +/** An ident is "Name
"; the page shows the name. */ +export const identName = (ident) => + String(ident || '').replace(/\s*<[^>]*>$/, ''); diff --git a/packages/git-ui/src/lib/git.js b/packages/git-ui/src/lib/git.js new file mode 100644 index 0000000..89c8ed5 --- /dev/null +++ b/packages/git-ui/src/lib/git.js @@ -0,0 +1,205 @@ +// Reaching the repositories, from a page that knows only its own origin. +// +// Two layers feed the app. The repository list, refs and bundle manifest come +// from one dev.pdsjs.query.run call, which is a record read. Trees, file +// bodies, history and diffs come from the packfiles inside the bundle blobs, +// which only a pack reader can open. The query hydrates everything above the +// packfile, so the list page and the ref menus cost no blob downloads. + +import { createGitBrowser } from '@pdsjs/git/browser'; + +const REPO_COLLECTION = 'dev.pdsjs.git.repo'; +const QUERY = 'git-repos'; + +/** Filled by discover() before any query runs. */ +export const account = { did: '', pds: '', handle: '' }; + +/** The repository records by name, filled by loadRepos(). */ +const records = new Map(); + +/** + * One reader per repository. Each holds that repository's parsed object + * store, so returning to a repository already opened costs nothing. + * createGitBrowser caches a single repository, which is why this is a map of + * readers rather than one reader for all of them. + */ +const readers = new Map(); + +/** + * Serve the git reader's XRPC calls. Record reads come from the query result + * already in memory, and blob reads prefer this origin, where the site + * handler serves any blob in the repo immutably. + * @param {Request} request + * @returns {Promise} + */ +async function xrpc(request) { + const url = new URL(request.url); + + if (url.pathname === '/xrpc/com.atproto.repo.getRecord') { + const row = records.get(url.searchParams.get('rkey')); + if (!row) return new Response('Not found', { status: 404 }); + return Response.json({ uri: row.uri, cid: row.cid, value: row.value }); + } + + if (url.pathname === '/xrpc/com.atproto.sync.getBlob') { + const local = await fetch(`/.blobs/${url.searchParams.get('cid')}`); + // A deployment serving the app from somewhere other than the account's + // own site has no /.blobs route, so the PDS answers instead. + if (local.ok) return local; + } + + return fetch(`${account.pds}${url.pathname}${url.search}`); +} + +function didDocumentUrl(did) { + if (did.startsWith('did:plc:')) return `https://plc.directory/${did}`; + if (did.startsWith('did:web:')) { + return `https://${did.slice('did:web:'.length)}/.well-known/did.json`; + } + throw new Error(`Unsupported DID method: ${did}`); +} + +/** Learn whose repo this is from the hostname, then where its PDS is. */ +export async function discover() { + const res = await fetch('/.well-known/atproto-did'); + if (!res.ok) throw new Error('This hostname serves no atproto account.'); + account.did = (await res.text()).trim(); + + const doc = await (await fetch(didDocumentUrl(account.did))).json(); + const service = (doc.service || []).find((entry) => + entry.id.endsWith('#atproto_pds'), + ); + if (!service) throw new Error('The DID document names no PDS.'); + account.pds = String(service.serviceEndpoint).replace(/\/$/, ''); + + const aka = (doc.alsoKnownAs || []).find((name) => name.startsWith('at://')); + account.handle = aka ? aka.slice('at://'.length) : account.did; + return account; +} + +/** + * The repository list, newest push first. The query def sorts server-side in + * one request; a deployment that serves no queries falls back to listRecords, + * which answers in record-key order and needs the same sort applied here. + */ +export async function loadRepos() { + const query = new URLSearchParams({ query: QUERY, limit: '100' }); + const ran = await fetch(`${account.pds}/xrpc/dev.pdsjs.query.run?${query}`); + let rows; + if (ran.ok) { + rows = (await ran.json()).records; + } else { + const params = new URLSearchParams({ + repo: account.did, + collection: REPO_COLLECTION, + limit: '100', + }); + const listed = await fetch( + `${account.pds}/xrpc/com.atproto.repo.listRecords?${params}`, + ); + if (!listed.ok) throw new Error('Could not read the repository list.'); + rows = (await listed.json()).records.sort((a, b) => + String(b.value.updatedAt || '').localeCompare( + String(a.value.updatedAt || ''), + ), + ); + } + records.clear(); + for (const row of rows) records.set(row.uri.split('/').pop(), row); + return rows.map((row) => ({ name: row.uri.split('/').pop(), ...row.value })); +} + +/** @param {string} name */ +export function repoRecord(name) { + return records.get(name)?.value ?? null; +} + +/** + * How much of the repository being opened has arrived. One repository opens + * at a time, so a single value carries it. + */ +let progress = { loaded: 0, total: 0 }; +/** @type {Set<() => void>} */ +const progressListeners = new Set(); + +export function repoProgress() { + return progress; +} + +/** @param {() => void} listener */ +export function onRepoProgress(listener) { + progressListeners.add(listener); + return () => progressListeners.delete(listener); +} + +/** @param {string} name */ +export function readerFor(name) { + let reader = readers.get(name); + if (!reader) { + reader = createGitBrowser({ + xrpc, + onProgress(next) { + progress = next; + for (const listener of progressListeners) listener(); + }, + }); + readers.set(name, reader); + } + return reader; +} + +// ---- reading the record ---- + +export const shortRef = (name) => name.replace(/^refs\/(heads|tags)\//, ''); + +export const branchRefs = (repo) => + (repo?.refs || []).filter((ref) => ref.name.startsWith('refs/heads/')); + +export const tagRefs = (repo) => + (repo?.refs || []).filter((ref) => ref.name.startsWith('refs/tags/')); + +export const defaultRef = (repo) => repo?.defaultBranch || 'main'; + +export const isEmpty = (repo) => (repo?.refs || []).length === 0; + +/** Every bundle chunk is a blob ref, so the size needs no download. */ +export function repoSize(repo) { + let total = 0; + for (const bundle of repo?.bundles || []) { + for (const part of bundle.parts || []) total += part.size || 0; + } + return total; +} + +/** + * Split a URL tail into its ref and its path. Both may hold slashes, so the + * only reliable split is against the repository's own ref names, longest + * first. The refs are in hand from the query, which is what makes this work + * without a round trip. + */ +export function splitRefPath(repo, tail) { + const names = (repo?.refs || []) + .map((ref) => shortRef(ref.name)) + .sort((a, b) => b.length - a.length); + for (const name of names) { + if (tail === name) return { ref: name, path: '' }; + if (tail.startsWith(`${name}/`)) { + return { ref: name, path: tail.slice(name.length + 1) }; + } + } + return { ref: defaultRef(repo), path: tail }; +} + +/** + * The URL that serves a file's bytes, for a reader that wants the source + * rather than the page around it. The PDS answers this route; the site does + * too where the two share a worker, but the PDS host is the one that always + * holds. + * @param {string} repo + * @param {string} ref + * @param {string} path + */ +export function rawUrl(repo, ref, path) { + const at = `${encodeURIComponent(account.handle)}/${encodeURIComponent(repo)}`; + return `${account.pds}/git/${at}/raw/${encodeURI(ref)}/${encodeURI(path)}`; +} diff --git a/packages/git-ui/src/lib/highlight.js b/packages/git-ui/src/lib/highlight.js new file mode 100644 index 0000000..c0fdf54 --- /dev/null +++ b/packages/git-ui/src/lib/highlight.js @@ -0,0 +1,219 @@ +// Syntax colouring for file bodies and diffs. +// +// The whole app is one inlined document, so the grammars are chosen rather +// than imported wholesale: highlight.js ships over a megabyte of them and +// this list is what a repository usually holds. A file whose extension is +// not here renders as plain text, which is the same thing the page does for +// a file it cannot colour. + +import hljs from 'highlight.js/lib/core'; +import bash from 'highlight.js/lib/languages/bash'; +import css from 'highlight.js/lib/languages/css'; +import diff from 'highlight.js/lib/languages/diff'; +import dockerfile from 'highlight.js/lib/languages/dockerfile'; +import go from 'highlight.js/lib/languages/go'; +import ini from 'highlight.js/lib/languages/ini'; +import javascript from 'highlight.js/lib/languages/javascript'; +import json from 'highlight.js/lib/languages/json'; +import markdown from 'highlight.js/lib/languages/markdown'; +import plaintext from 'highlight.js/lib/languages/plaintext'; +import python from 'highlight.js/lib/languages/python'; +import rust from 'highlight.js/lib/languages/rust'; +import sql from 'highlight.js/lib/languages/sql'; +import typescript from 'highlight.js/lib/languages/typescript'; +import xml from 'highlight.js/lib/languages/xml'; +import yaml from 'highlight.js/lib/languages/yaml'; + +for (const [name, grammar] of Object.entries({ + bash, + css, + diff, + dockerfile, + go, + ini, + javascript, + json, + markdown, + plaintext, + python, + rust, + sql, + typescript, + xml, + yaml, +})) { + hljs.registerLanguage(name, grammar); +} + +/** File extension to grammar. Anything absent renders plain. */ +const BY_EXTENSION = { + bash: 'bash', + cjs: 'javascript', + css: 'css', + diff: 'diff', + go: 'go', + htm: 'xml', + html: 'xml', + ini: 'ini', + js: 'javascript', + json: 'json', + jsonc: 'json', + jsx: 'javascript', + markdown: 'markdown', + md: 'markdown', + mjs: 'javascript', + patch: 'diff', + py: 'python', + rs: 'rust', + sh: 'bash', + sql: 'sql', + svg: 'xml', + toml: 'ini', + ts: 'typescript', + tsx: 'typescript', + xml: 'xml', + yaml: 'yaml', + yml: 'yaml', + zsh: 'bash', +}; + +/** Files with no extension that still have a known grammar. */ +const BY_NAME = { + dockerfile: 'dockerfile', + gemfile: 'plaintext', + justfile: 'bash', + makefile: 'plaintext', +}; + +/** + * Colouring a very large file costs more than it is worth, and the page + * already refuses to inline one past its own preview cap. + */ +const MAX_BYTES = 200 * 1024; + +/** + * The grammar for a path, or '' when the page should render it plain. + * @param {string} path + * @returns {string} + */ +export function languageFor(path) { + const name = path.slice(path.lastIndexOf('/') + 1).toLowerCase(); + const dot = name.lastIndexOf('.'); + if (dot > 0) { + const language = BY_EXTENSION[name.slice(dot + 1)]; + if (language) return language; + } + return BY_NAME[name] ?? ''; +} + +/** @param {string} text */ +function escapeHtml(text) { + return text + .replace(/&/g, '&') + .replace(//g, '>'); +} + +/** + * Colour a whole text. The result is HTML, and highlight.js escapes the + * source it is given, so nothing in the file can become markup. + * @param {string} text + * @param {string} language - from languageFor, '' to render plain + * @returns {string} HTML + */ +export function highlightBlock(text, language) { + if (!language || text.length > MAX_BYTES) return escapeHtml(text); + try { + return hljs.highlight(text, { language, ignoreIllegals: true }).value; + } catch { + // An unregistered grammar or a pathological input: the text still has to + // reach the page. + return escapeHtml(text); + } +} + +/** + * Colour a text and cut it into one HTML string per line. + * + * A span opened by highlight.js can cross a newline, and a diff prints each + * line in its own row, so every span still open at a line break is closed + * and reopened on the next line. + * @param {string} text + * @param {string} language + * @returns {string[]} one HTML string per line + */ +export function highlightToLines(text, language) { + const html = highlightBlock(text, language); + /** @type {string[]} */ + const lines = []; + /** @type {string[]} */ + const open = []; + let current = ''; + + const addText = (chunk) => { + const parts = chunk.split('\n'); + for (let i = 0; i < parts.length; i++) { + if (i > 0) { + current += ''.repeat(open.length); + lines.push(current); + current = open.map((cls) => ``).join(''); + } + current += parts[i]; + } + }; + + const tag = /|<\/span>/g; + let at = 0; + let match = tag.exec(html); + while (match) { + addText(html.slice(at, match.index)); + at = tag.lastIndex; + if (match[1] === undefined) open.pop(); + else open.push(match[1]); + current += match[0]; + match = tag.exec(html); + } + addText(html.slice(at)); + lines.push(current); + return lines; +} + +/** + * Colour a diff hunk's two sides. + * + * A 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, so each side is rebuilt as its own text, + * coloured whole, and handed back a row at a time. + * + * @param {Array<{type: string, text: string}>} lines - the hunk's rows + * @param {string} language + * @returns {string[]} HTML for each row, in the order the rows are printed + */ +export function highlightHunk(lines, language) { + const beforeHtml = highlightToLines( + lines + .filter((line) => line.type !== 'add') + .map((line) => line.text) + .join('\n'), + language, + ); + const afterHtml = highlightToLines( + lines + .filter((line) => line.type !== 'delete') + .map((line) => line.text) + .join('\n'), + language, + ); + + let atBefore = 0; + let atAfter = 0; + return lines.map((line) => { + if (line.type === 'delete') return beforeHtml[atBefore++] ?? ''; + if (line.type === 'add') return afterHtml[atAfter++] ?? ''; + // A context line reads the same on both sides; take the newer colouring + // and keep both cursors moving. + atBefore++; + return afterHtml[atAfter++] ?? ''; + }); +} diff --git a/packages/git-ui/src/lib/markdown.js b/packages/git-ui/src/lib/markdown.js new file mode 100644 index 0000000..37d8407 --- /dev/null +++ b/packages/git-ui/src/lib/markdown.js @@ -0,0 +1,146 @@ +// Rendering a repository's markdown. +// +// The text comes out of somebody's repository and the result is put into this +// page, so nothing in it may become markup of its own choosing. Two rules +// hold that line: +// +// Raw HTML in the source is printed, never parsed. Markdown allows HTML +// through, and allowing it here would let a README run script on the site's +// own origin. +// +// A link or image address must name a scheme this page is willing to +// follow. `javascript:` is the one that matters, and an allowlist refuses +// it along with everything else nobody expected. +// +// What is left is markup this file generated from a fixed set of tags, which +// is a surface small enough to read. + +import { Marked } from 'marked'; +import { highlightBlock, languageFor } from '#/lib/highlight.js'; + +/** Schemes a rendered link or image may point at. */ +const SAFE_SCHEMES = new Set(['http:', 'https:', 'mailto:']); + +/** @param {string} text */ +export function escapeHtml(text) { + return String(text) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Whether an address is one this page will follow. A relative address names + * no scheme and stays inside the repository, so it is allowed and resolved + * by the caller. + * @param {string} href + * @returns {boolean} + */ +export function isSafeHref(href) { + const value = String(href ?? '').trim(); + if (value === '') return false; + // A control character can hide a scheme from a careless reader, and the + // URL parser strips some of them before deciding what the scheme is. + // Refuse any address carrying one rather than reason about which. + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + if (code <= 0x1f || code === 0x7f) return false; + } + if (value.startsWith('#')) return true; + try { + // A base is needed to parse a relative address at all. One that parses + // relative to it has no scheme of its own, so it is repository-relative. + const parsed = new URL(value, 'https://repo.invalid/'); + if (!/^[a-z][a-z0-9+.-]*:/i.test(value)) return true; + return SAFE_SCHEMES.has(parsed.protocol); + } catch { + return false; + } +} + +/** @param {string} href */ +const isRelative = (href) => + !/^[a-z][a-z0-9+.-]*:/i.test(href) && + !href.startsWith('/') && + !href.startsWith('#'); + +/** + * Resolve a repository-relative address against the directory the document + * sits in, so `../img.png` in `docs/guide.md` names `img.png`. + * @param {string} basePath - directory of the document, '' at the root + * @param {string} href + * @returns {string} + */ +export function resolveRepoPath(basePath, href) { + const segments = basePath ? basePath.split('/') : []; + for (const part of href.split('/')) { + if (part === '' || part === '.') continue; + if (part === '..') segments.pop(); + else segments.push(part); + } + return segments.join('/'); +} + +/** + * @typedef {Object} MarkdownContext + * @property {string} repo + * @property {string} refName + * @property {string} [basePath] - directory the document sits in + */ + +/** + * Render markdown to HTML for the page. + * @param {string} text + * @param {MarkdownContext} ctx + * @returns {string} HTML + */ +export function renderMarkdown(text, ctx) { + const basePath = ctx.basePath ?? ''; + const marked = new Marked({ gfm: true, breaks: false }); + + marked.use({ + renderer: { + /** Print raw HTML rather than passing it through. */ + html({ text: raw }) { + return escapeHtml(raw); + }, + + /** Fenced code reads the same as a file does elsewhere in the app. */ + code({ text: source, lang }) { + const language = lang ? languageFor(`x.${lang.split(/\s+/)[0]}`) : ''; + return `
${highlightBlock(source, language)}
`; + }, + + link({ href, title, tokens }) { + const label = this.parser.parseInline(tokens); + if (!isSafeHref(href)) return label; + const target = isRelative(href) + ? `/${encodeURIComponent(ctx.repo)}/blob/${encodeURI(ctx.refName)}/${encodeURI(resolveRepoPath(basePath, href))}` + : href; + const attrs = title ? ` title="${escapeHtml(title)}"` : ''; + // An address outside the repository opens away from this page and is + // told not to carry it along. + const away = isRelative(href) + ? '' + : ' target="_blank" rel="noopener noreferrer"'; + return `${label}`; + }, + + image({ href, title, text: alt }) { + if (!isSafeHref(href)) return escapeHtml(alt ?? ''); + const attrs = title ? ` title="${escapeHtml(title)}"` : ''; + if (!isRelative(href)) { + return `${escapeHtml(alt ?? '')}`; + } + // The file is in the repository, whose objects are already in memory. + // The path travels on the tag and the view fills in the bytes. + const path = resolveRepoPath(basePath, href); + return `${escapeHtml(alt ?? '')}`; + }, + }, + }); + + return marked.parse(text, { async: false }); +} diff --git a/packages/git-ui/src/lib/navigation.jsx b/packages/git-ui/src/lib/navigation.jsx new file mode 100644 index 0000000..6aea085 --- /dev/null +++ b/packages/git-ui/src/lib/navigation.jsx @@ -0,0 +1,44 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from 'react'; + +const NavigationContext = createContext(() => {}); + +/** + * Path routing over the History API. The site record serves index.html for + * every unmatched path, so a reload or a shared link lands on the same screen + * a click does. + */ +export function useLocation() { + const [path, setPath] = useState(() => window.location.pathname); + + useEffect(() => { + const onPop = () => setPath(window.location.pathname); + window.addEventListener('popstate', onPop); + return () => window.removeEventListener('popstate', onPop); + }, []); + + const navigate = useCallback((next) => { + window.history.pushState({}, '', next); + setPath(new URL(next, window.location.origin).pathname); + window.scrollTo(0, 0); + }, []); + + return { path, navigate }; +} + +export function NavigationProvider({ navigate, children }) { + return ( + + {children} + + ); +} + +export function useNavigate() { + return useContext(NavigationContext); +} diff --git a/packages/git-ui/src/lib/router.js b/packages/git-ui/src/lib/router.js new file mode 100644 index 0000000..739ef56 --- /dev/null +++ b/packages/git-ui/src/lib/router.js @@ -0,0 +1,49 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +/** + * Path routing over the History API. The server serves this document for + * every account path, so a reload or a shared link lands on the same screen + * the nav does. Both the pathname and the query string are tracked, so a + * detail view like a collection is a real, addressable page. + */ +export function useRouter() { + const [href, setHref] = useState( + () => window.location.pathname + window.location.search, + ); + // Depth in this app's own history stack. 0 is the first page loaded, so Back + // can tell a real step back (still inside the app) from one that would leave. + const depth = useRef(window.history.state?.idx ?? 0); + + useEffect(() => { + if (window.history.state?.idx == null) { + window.history.replaceState({ idx: 0 }, ''); + } + const onPop = (event) => { + depth.current = event.state?.idx ?? 0; + setHref(window.location.pathname + window.location.search); + }; + window.addEventListener('popstate', onPop); + return () => window.removeEventListener('popstate', onPop); + }, []); + + const navigate = useCallback((next) => { + depth.current += 1; + window.history.pushState({ idx: depth.current }, '', next); + setHref(next); + window.scrollTo(0, 0); + }, []); + + // Step back to wherever the visitor came from. When they landed here directly + // (a shared link or a reload, nothing to step back to), take the fallback so + // Back still lands somewhere sensible instead of leaving the app. + const back = useCallback( + (fallback) => { + if (depth.current > 0) window.history.back(); + else if (fallback) navigate(fallback); + }, + [navigate], + ); + + const url = new URL(href, window.location.origin); + return { path: url.pathname, params: url.searchParams, navigate, back }; +} diff --git a/packages/git-ui/src/lib/utils.js b/packages/git-ui/src/lib/utils.js new file mode 100644 index 0000000..4832900 --- /dev/null +++ b/packages/git-ui/src/lib/utils.js @@ -0,0 +1,12 @@ +import { clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +/** + * Merge class lists, letting a caller's utility win over a component's + * default. The shadcn convention, and what every component here uses. + * @param {...unknown} inputs + * @returns {string} + */ +export function cn(...inputs) { + return twMerge(clsx(inputs)); +} diff --git a/packages/git-ui/src/main.jsx b/packages/git-ui/src/main.jsx new file mode 100644 index 0000000..7b20df4 --- /dev/null +++ b/packages/git-ui/src/main.jsx @@ -0,0 +1,26 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { App } from '#/app.jsx'; +import './style.css'; + +// Every read is of an immutable object: a tree, a blob, or a commit, named by +// a ref that only a push moves. Nothing here goes stale while the tab is +// open, so a view already visited comes back without touching the network. +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: Number.POSITIVE_INFINITY, + retry: false, + refetchOnWindowFocus: false, + }, + }, +}); + +createRoot(document.getElementById('root')).render( + + + + + , +); diff --git a/packages/git-ui/src/pages/commit.jsx b/packages/git-ui/src/pages/commit.jsx new file mode 100644 index 0000000..bef7a0b --- /dev/null +++ b/packages/git-ui/src/pages/commit.jsx @@ -0,0 +1,123 @@ +import { useQuery } from '@tanstack/react-query'; +import { GitCommitVerticalIcon } from 'lucide-react'; +import { useCallback, useRef, useState } from 'react'; +import { buttonVariants } from '#/components/atoms/button.jsx'; +import { Card } from '#/components/atoms/card.jsx'; +import { Link } from '#/components/atoms/link.jsx'; +import { AuthorLine } from '#/components/molecules/author-line.jsx'; +import { Breadcrumbs } from '#/components/molecules/breadcrumbs.jsx'; +import { DiffFile } from '#/components/molecules/diff-file.jsx'; +import { DiffTree } from '#/components/molecules/diff-tree.jsx'; +import { ReadingRepo } from '#/components/molecules/reading-repo.jsx'; +import { + account, + defaultRef, + readerFor, + repoRecord, + repoSize, +} from '#/lib/git.js'; +import { cn } from '#/lib/utils.js'; + +export function CommitPage({ repo, sha }) { + const record = repoRecord(repo); + /** Each file's section, so the tree can move to one. */ + const sections = useRef(new Map()); + const [picked, setPicked] = useState(''); + + const { data, error } = useQuery({ + queryKey: ['commit', repo, sha], + enabled: Boolean(record), + queryFn: () => readerFor(repo).readCommit(account.did, repo, sha), + }); + + const pick = useCallback((path) => { + setPicked(path); + sections.current.get(path)?.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); + }, []); + + if (!record) { + return

No such repository.

; + } + + const [subject, ...rest] = (data?.message ?? '').split('\n'); + const body = rest.join('\n').trim(); + + return ( + <> +
+ + + + History + +
+ + {error &&

{error.message}

} + {!data && !error && ( +
+ +
+ )} + + {data && ( + <> + +

{subject}

+ {body && ( +
+                {body}
+              
+ )} +
+ + {new Date(data.committedAt).toLocaleString()} + {data.sha} +
+
+ + {data.files.length} {data.files.length === 1 ? 'file' : 'files'}{' '} + changed + + {data.parents.length > 1 && ( + merge, shown against the first parent + )} +
+
+ {data.files.length > 1 ? ( +
+ {/* The tree stays put while the diffs scroll under it, and + steps aside on a narrow screen where there is no room for + two columns. */} + +
+ {data.files.map((file) => ( + { + if (node) sections.current.set(file.path, node); + else sections.current.delete(file.path); + }} + /> + ))} +
+
+ ) : ( + data.files.map((file) => ) + )} + + )} + + ); +} diff --git a/packages/git-ui/src/pages/commits.jsx b/packages/git-ui/src/pages/commits.jsx new file mode 100644 index 0000000..05be19b --- /dev/null +++ b/packages/git-ui/src/pages/commits.jsx @@ -0,0 +1,79 @@ +import { useQuery } from '@tanstack/react-query'; +import { Link } from '#/components/atoms/link.jsx'; +import { AuthorLine } from '#/components/molecules/author-line.jsx'; +import { Breadcrumbs } from '#/components/molecules/breadcrumbs.jsx'; +import { ReadingRepo } from '#/components/molecules/reading-repo.jsx'; +import { RefSelect } from '#/components/molecules/ref-select.jsx'; +import { timeAgo } from '#/lib/format.js'; +import { account, readerFor, repoRecord, repoSize } from '#/lib/git.js'; +import { useNavigate } from '#/lib/navigation.jsx'; + +const LIMIT = 50; + +export function CommitsPage({ repo, refName }) { + const navigate = useNavigate(); + const record = repoRecord(repo); + + const { data, error } = useQuery({ + queryKey: ['commits', repo, refName], + enabled: Boolean(record), + queryFn: () => + readerFor(repo).listCommits(account.did, repo, refName, LIMIT), + }); + + if (!record) { + return

No such repository.

; + } + + return ( + <> +
+ + + navigate(`/${encodeURIComponent(repo)}/commits/${encodeURI(next)}`) + } + /> +
+ {error &&

{error.message}

} + {!data && !error && ( +
+ +
+ )} + {data && ( +
    + {data.commits.map((commit) => ( +
  • + + + {commit.message.split('\n')[0]} + + + + {commit.sha.slice(0, 8)} + + + {timeAgo(commit.committedAt)} + + +
  • + ))} +
+ )} + {data?.truncated && ( +

+ Showing the newest {LIMIT} commits. +

+ )} + + ); +} diff --git a/packages/git-ui/src/pages/file.jsx b/packages/git-ui/src/pages/file.jsx new file mode 100644 index 0000000..5285c0c --- /dev/null +++ b/packages/git-ui/src/pages/file.jsx @@ -0,0 +1,128 @@ +import { useQuery } from '@tanstack/react-query'; +import { FileCodeIcon } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { buttonVariants } from '#/components/atoms/button.jsx'; +import { Card } from '#/components/atoms/card.jsx'; +import { Breadcrumbs } from '#/components/molecules/breadcrumbs.jsx'; +import { ReadingRepo } from '#/components/molecules/reading-repo.jsx'; +import { bytes } from '#/lib/format.js'; +import { account, rawUrl, readerFor, repoRecord, repoSize } from '#/lib/git.js'; +import { highlightBlock, languageFor } from '#/lib/highlight.js'; +import { cn } from '#/lib/utils.js'; + +const CONFIG = globalThis.GIT_APP || {}; + +/** The bytes of an image, as an object URL that lives as long as the view. */ +function useObjectUrl(image) { + const [url, setUrl] = useState(''); + useEffect(() => { + if (!image) return undefined; + const next = URL.createObjectURL( + new Blob([image.bytes], { type: image.mediaType }), + ); + setUrl(next); + return () => URL.revokeObjectURL(next); + }, [image]); + return url; +} + +export function FilePage({ repo, refName, path }) { + const record = repoRecord(repo); + + const { data, error } = useQuery({ + queryKey: ['file', repo, refName, path], + enabled: Boolean(record), + queryFn: async () => { + const reader = readerFor(repo); + const file = await reader.readFile(account.did, repo, refName, path); + const image = file?.mediaType + ? await reader.readImage(account.did, repo, refName, path) + : null; + return { file, image }; + }, + }); + + const imageUrl = useObjectUrl(data?.image); + + if (!record) { + return

No such repository.

; + } + + return ( + <> +
+ + {CONFIG.rawFiles && ( + + + Raw + + )} +
+ {error &&

{error.message}

} + {!data && !error && ( +
+ +
+ )} + {data?.file && ( + <> +
+ {bytes(data.file.size)} + {data.file.mode === '120000' && symlink} +
+ {data.image ? ( + + {imageUrl && ( + {path} + )} + + ) : data.file.text !== null ? ( + +
+ + {/* highlight.js escapes the source it is given, so nothing + in the file can reach the page as markup. */} +
+              
+
+ ) : ( +

+ {data.file.binary + ? `Binary file, ${bytes(data.file.size)}.` + : `File is ${bytes(data.file.size)}, too large to show.`} +

+ )} + + )} + + ); +} diff --git a/packages/git-ui/src/pages/repos.jsx b/packages/git-ui/src/pages/repos.jsx new file mode 100644 index 0000000..7924fbc --- /dev/null +++ b/packages/git-ui/src/pages/repos.jsx @@ -0,0 +1,87 @@ +import { useQuery } from '@tanstack/react-query'; +import { FolderGitIcon } from 'lucide-react'; +import { Card } from '#/components/atoms/card.jsx'; +import { Link } from '#/components/atoms/link.jsx'; +import { Skeleton } from '#/components/atoms/skeleton.jsx'; +import { EmptyState } from '#/components/molecules/empty-state.jsx'; +import { bytes, timeAgo } from '#/lib/format.js'; +import { + branchRefs, + isEmpty, + loadRepos, + repoSize, + tagRefs, +} from '#/lib/git.js'; + +/** One line summarizing a repository's shape. */ +function summary(repo) { + const facts = []; + if (isEmpty(repo)) { + facts.push('nothing pushed yet'); + } else { + const branches = branchRefs(repo).length; + facts.push(`${branches} ${branches === 1 ? 'branch' : 'branches'}`); + const tags = tagRefs(repo).length; + if (tags > 0) facts.push(`${tags} ${tags === 1 ? 'tag' : 'tags'}`); + facts.push(bytes(repoSize(repo))); + } + if (repo.updatedAt) facts.push(`pushed ${timeAgo(repo.updatedAt)}`); + return facts.join(' · '); +} + +export function ReposPage() { + const { data, error } = useQuery({ + queryKey: ['repos'], + queryFn: loadRepos, + }); + + if (error) { + return ( +

{error.message}

+ ); + } + if (!data) { + return ( +
+ + +
+ ); + } + if (data.length === 0) { + return ( + } + title="No repositories yet" + description="Push one with the git helper and it appears here." + /> + ); + } + + return ( +
    + {data.map((repo) => ( +
  • + + + + {repo.name} + + {repo.description && ( + + {repo.description} + + )} + + {summary(repo)} + + + +
  • + ))} +
+ ); +} diff --git a/packages/git-ui/src/pages/tree.jsx b/packages/git-ui/src/pages/tree.jsx new file mode 100644 index 0000000..d31e1ee --- /dev/null +++ b/packages/git-ui/src/pages/tree.jsx @@ -0,0 +1,184 @@ +import { useQuery } from '@tanstack/react-query'; +import { GitBranchIcon, GitCommitVerticalIcon, TagIcon } from 'lucide-react'; +import { buttonVariants } from '#/components/atoms/button.jsx'; +import { Card } from '#/components/atoms/card.jsx'; +import { Link } from '#/components/atoms/link.jsx'; +import { Breadcrumbs } from '#/components/molecules/breadcrumbs.jsx'; +import { CodeMenu } from '#/components/molecules/code-menu.jsx'; +import { EmptyState } from '#/components/molecules/empty-state.jsx'; +import { LatestCommit } from '#/components/molecules/latest-commit.jsx'; +import { Markdown } from '#/components/molecules/markdown.jsx'; +import { ReadingRepo } from '#/components/molecules/reading-repo.jsx'; +import { RefSelect } from '#/components/molecules/ref-select.jsx'; +import { TreeList } from '#/components/molecules/tree-list.jsx'; +import { + account, + branchRefs, + isEmpty, + readerFor, + repoRecord, + repoSize, + tagRefs, +} from '#/lib/git.js'; +import { useNavigate } from '#/lib/navigation.jsx'; +import { cn } from '#/lib/utils.js'; + +const README = /^readme(\.md|\.txt|\.markdown)?$/i; + +/** + * Deep enough to count every commit a repository of this shape holds. The + * walk runs over the objects already in memory, so the ceiling is a guard + * rather than a budget. + */ +const COUNT_LIMIT = 50000; + +export function TreePage({ repo, refName, path, httpClone }) { + const navigate = useNavigate(); + const record = repoRecord(repo); + + const { data, error } = useQuery({ + queryKey: ['tree', repo, refName, path], + enabled: Boolean(record) && !isEmpty(record), + queryFn: async () => { + const reader = readerFor(repo); + const listing = await reader.listTree(account.did, repo, refName, path); + const readme = listing?.entries.find( + (entry) => entry.type === 'file' && README.test(entry.name), + ); + const file = readme + ? await reader.readFile( + account.did, + repo, + refName, + path ? `${path}/${readme.name}` : readme.name, + ) + : null; + // The tip of the ref, and how far its history runs. Both come from the + // objects this listing already loaded, so neither touches the network. + const log = await reader.listCommits( + account.did, + repo, + refName, + COUNT_LIMIT, + ); + return { + listing, + readme: readme ? { name: readme.name, file } : null, + head: log?.commits[0] ?? null, + count: log?.commits.length ?? 0, + truncated: Boolean(log?.truncated), + }; + }, + }); + + if (!record) { + return

No such repository.

; + } + + const branches = branchRefs(record).length; + const tags = tagRefs(record).length; + + const toolbar = ( +
+
+ + navigate(`/${encodeURIComponent(repo)}/tree/${encodeURI(next)}`) + } + /> + + + {branches} + {branches === 1 ? 'branch' : 'branches'} + + {tags > 0 && ( + + + {tags} + {tags === 1 ? 'tag' : 'tags'} + + )} +
+
+ + + History + + +
+
+ ); + + return ( + <> +
+ + {!path && record.description && ( +

+ {record.description} +

+ )} +
+ + {isEmpty(record) ? ( +
+ +
+ ) : ( + <> + {toolbar} + {error &&

{error.message}

} + {!data && !error && } + {data?.listing && ( +
+ {/* Only at the root, where the tip of the ref is the repository's + latest commit. Inside a directory a reader would take it for + the last change to that directory, which it is not. */} + {!path && data.head && ( + + )} + +
+ )} + {data?.readme?.file?.text && ( + +

+ {data.readme.name} +

+
+ +
+
+ )} + + )} + + ); +} diff --git a/packages/git-ui/src/style.css b/packages/git-ui/src/style.css new file mode 100644 index 0000000..1d723a4 --- /dev/null +++ b/packages/git-ui/src/style.css @@ -0,0 +1,402 @@ +@import "tailwindcss"; + +/* The same semantic tokens the account pages use, so a repository browsed + here looks like the rest of pds.js. Fonts stay on the system stacks: the + build inlines every asset into one document, and a webfont would multiply + its size. */ + +@custom-variant dark { + &:where([data-theme="dark"], [data-theme="dark"] *) { + @slot; + } + @media (prefers-color-scheme: dark) { + &:where( + :root:not([data-theme="light"]), + :root:not([data-theme="light"]) * + ) { + @slot; + } + } +} + +@theme inline { + --color-background: var(--background); + --color-card: var(--card); + --color-muted: var(--muted); + --color-accent: var(--accent); + --color-border: var(--border); + --color-input: var(--input); + + --color-foreground: var(--foreground); + --color-muted-foreground: var(--muted-foreground); + --color-faint: var(--faint); + + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-accent-foreground: var(--accent-foreground); + + --color-destructive: var(--destructive); + --color-destructive-dim: var(--destructive-dim); + --color-success: var(--success); + --color-success-dim: var(--success-dim); + + --color-ring: var(--ring); + --color-key: var(--key); + + /* Diff surfaces. Each pairs a wash for the row with ink for its text, so + an added and a deleted line stay apart for a red-green viewer by + lightness as well as hue. */ + --color-diff-add: var(--diff-add); + --color-diff-add-ink: var(--diff-add-ink); + --color-diff-del: var(--diff-del); + --color-diff-del-ink: var(--diff-del-ink); + --color-diff-meta: var(--diff-meta); + + --radius-lg: 0.75rem; + + --font-sans: system-ui, -apple-system, "Segoe UI", sans-serif; + --font-mono: ui-monospace, SFMono-Regular, menlo, monospace; +} + +:root { + color-scheme: light dark; + + --background: #fafafb; + --card: #ffffff; + --muted: #f2f2f6; + --accent: #eaeaf1; + --border: #e5e5ec; + --input: #d4d4de; + + --foreground: #18181d; + --muted-foreground: #63636f; + --faint: #8e8e9a; + + --primary: #18181d; + --primary-foreground: #fafafb; + --secondary: #eeeef3; + --secondary-foreground: #18181d; + --accent-foreground: #18181d; + + --destructive: #dc2626; + --destructive-dim: #fdecec; + --success: #15a34a; + --success-dim: #e9faf0; + + --ring: #6d6df0; + --key: #5656d6; + + --diff-add: #e6f6ec; + --diff-add-ink: #0f6b35; + --diff-del: #fdecef; + --diff-del-ink: #a3253c; + --diff-meta: #6b6b9a; + + --code-comment: #7b7b8c; + --code-keyword: #8250df; + --code-string: #0a7d55; + --code-number: #9a5b00; + --code-title: #1f5fb0; + --code-attr: #b3452b; + --code-meta: #63636f; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --background: #0a0a0c; + --card: #101014; + --muted: #15151a; + --accent: #17171d; + --border: #1e1e25; + --input: #26262f; + + --foreground: #f4f4f7; + --muted-foreground: #8b8b9c; + --faint: #6c6c7c; + + --primary: #f4f4f7; + --primary-foreground: #0a0a0c; + --secondary: #23232b; + --secondary-foreground: #f4f4f7; + --accent-foreground: #f4f4f7; + + --destructive: #ef4444; + --destructive-dim: #1c1216; + --success: #4ade80; + --success-dim: #10221a; + + --ring: #6d6df0; + --key: #a6a6f0; + + --diff-add: #10251a; + --diff-add-ink: #7ee2a0; + --diff-del: #26121a; + --diff-del-ink: #f592a8; + --diff-meta: #8f8fbe; + + --code-comment: #74748a; + --code-keyword: #c4a2f5; + --code-string: #7fd8a8; + --code-number: #f0b866; + --code-title: #85b7f0; + --code-attr: #f0a58a; + --code-meta: #9a9ab0; + } +} + +:root[data-theme="dark"] { + --background: #0a0a0c; + --card: #101014; + --muted: #15151a; + --accent: #17171d; + --border: #1e1e25; + --input: #26262f; + + --foreground: #f4f4f7; + --muted-foreground: #8b8b9c; + --faint: #6c6c7c; + + --primary: #f4f4f7; + --primary-foreground: #0a0a0c; + --secondary: #23232b; + --secondary-foreground: #f4f4f7; + --accent-foreground: #f4f4f7; + + --destructive: #ef4444; + --destructive-dim: #1c1216; + --success: #4ade80; + --success-dim: #10221a; + + --ring: #6d6df0; + --key: #a6a6f0; + + --diff-add: #10251a; + --diff-add-ink: #7ee2a0; + --diff-del: #26121a; + --diff-del-ink: #f592a8; + --diff-meta: #8f8fbe; + + --code-comment: #74748a; + --code-keyword: #c4a2f5; + --code-string: #7fd8a8; + --code-number: #f0b866; + --code-title: #85b7f0; + --code-attr: #f0a58a; + --code-meta: #9a9ab0; +} + +body { + margin: 0; + background: var(--background); + color: var(--foreground); + font-family: var(--font-sans); +} + +/* highlight.js token classes. The grammars emit many names for a handful of + roles, so each rule lists every name that should read the same way. */ +.hljs-comment, +.hljs-quote { + color: var(--code-comment); + font-style: italic; +} +.hljs-keyword, +.hljs-selector-tag, +.hljs-literal, +.hljs-type, +.hljs-built_in, +.hljs-name, +.hljs-tag { + color: var(--code-keyword); +} +.hljs-string, +.hljs-regexp, +.hljs-addition, +.hljs-template-string, +.hljs-link { + color: var(--code-string); +} +.hljs-number, +.hljs-symbol, +.hljs-bullet, +.hljs-variable, +.hljs-template-variable { + color: var(--code-number); +} +.hljs-title, +.hljs-title\.function_, +.hljs-title\.class_, +.hljs-section, +.hljs-selector-id, +.hljs-selector-class { + color: var(--code-title); +} +.hljs-attr, +.hljs-attribute, +.hljs-property, +.hljs-params, +.hljs-doctag { + color: var(--code-attr); +} +.hljs-meta, +.hljs-comment .hljs-doctag, +.hljs-deletion { + color: var(--code-meta); +} +.hljs-strong { + font-weight: 600; +} +.hljs-emphasis { + font-style: italic; +} + +/* The read indicator eases in rather than appearing, so a read that finishes + just past the quiet window does not snap on and straight off again. */ +@keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +.fade-in { + animation: fade-in 180ms ease-out both; +} +@media (prefers-reduced-motion: reduce) { + .fade-in { + animation: none; + } +} + +/* Rendered markdown. Sized to sit beside the file listing rather than to be + read as a page of prose on its own. */ +.markdown { + font-size: 15px; + line-height: 1.65; + overflow-wrap: anywhere; +} +.markdown > *:first-child { + margin-top: 0; +} +.markdown > *:last-child { + margin-bottom: 0; +} +.markdown h1, +.markdown h2, +.markdown h3, +.markdown h4, +.markdown h5, +.markdown h6 { + margin: 1.6em 0 0.6em; + font-weight: 650; + line-height: 1.3; + letter-spacing: -0.01em; +} +.markdown h1 { + font-size: 1.6em; + padding-bottom: 0.3em; + border-bottom: 1px solid var(--border); +} +.markdown h2 { + font-size: 1.3em; + padding-bottom: 0.3em; + border-bottom: 1px solid var(--border); +} +.markdown h3 { + font-size: 1.1em; +} +.markdown h4, +.markdown h5, +.markdown h6 { + font-size: 1em; +} +.markdown p, +.markdown ul, +.markdown ol, +.markdown blockquote, +.markdown table, +.markdown pre { + margin: 0 0 1em; +} +.markdown ul, +.markdown ol { + padding-left: 1.6em; +} +.markdown ul { + list-style: disc; +} +.markdown ol { + list-style: decimal; +} +.markdown li { + margin: 0.25em 0; +} +.markdown li > ul, +.markdown li > ol { + margin: 0.25em 0; +} +.markdown a { + color: var(--key); + text-decoration: underline; + text-underline-offset: 2px; +} +.markdown blockquote { + padding: 0.1em 0 0.1em 1em; + border-left: 3px solid var(--border); + color: var(--muted-foreground); +} +.markdown code { + font-family: var(--font-mono); + font-size: 0.87em; + background: var(--muted); + border: 1px solid var(--border); + border-radius: 5px; + padding: 0.1em 0.35em; +} +.markdown pre.md-code { + background: var(--muted); + border: 1px solid var(--border); + border-radius: 12px; + padding: 14px 16px; + overflow-x: auto; +} +/* A fence carries its own surface, so the span inside it drops the one a + lone code span wears. */ +.markdown pre.md-code code { + background: none; + border: 0; + padding: 0; + font-size: 12.5px; + line-height: 1.6; +} +.markdown table { + border-collapse: collapse; + display: block; + overflow-x: auto; + font-size: 0.93em; +} +.markdown th, +.markdown td { + border: 1px solid var(--border); + padding: 6px 12px; + text-align: left; +} +.markdown th { + background: var(--muted); + font-weight: 620; +} +.markdown hr { + margin: 1.6em 0; + border: 0; + border-top: 1px solid var(--border); +} +.markdown img { + max-width: 100%; + height: auto; + border-radius: 8px; +} +.markdown input[type="checkbox"] { + margin-right: 0.4em; +} diff --git a/packages/git-ui/test/file-tree.test.js b/packages/git-ui/test/file-tree.test.js new file mode 100644 index 0000000..e4bce41 --- /dev/null +++ b/packages/git-ui/test/file-tree.test.js @@ -0,0 +1,104 @@ +/** + * Folding a commit's changed paths back into the tree they came from. + */ + +import { describe, expect, it } from 'vitest'; +import { buildFileTree, filterFiles } from '../src/lib/file-tree.js'; + +/** A compact shape for comparing trees: "name" for a file, "name/" for a dir. */ +function shape(nodes) { + return nodes.map((node) => + node.type === 'dir' + ? { [`${node.name}/`]: shape(node.children) } + : node.name, + ); +} + +const tree = (paths) => buildFileTree(paths.map((path) => ({ path }))); + +describe('buildFileTree', () => { + it('keeps a root file at the root', () => { + expect(shape(tree(['README.md']))).toEqual(['README.md']); + }); + + it('nests a file under its directory', () => { + expect(shape(tree(['src/index.js', 'README.md']))).toEqual([ + { 'src/': ['index.js'] }, + 'README.md', + ]); + }); + + it('folds a chain of directories with nothing else in them', () => { + // The step through .github says nothing on its own, so it reads as one + // row the way git and every diff viewer show it. + expect(shape(tree(['.github/workflows/containers.yml']))).toEqual([ + { '.github/workflows/': ['containers.yml'] }, + ]); + }); + + it('stops folding where a directory holds more than one thing', () => { + expect(shape(tree(['a/b/one.txt', 'a/c/two.txt']))).toEqual([ + { 'a/': [{ 'b/': ['one.txt'] }, { 'c/': ['two.txt'] }] }, + ]); + }); + + it('does not fold a directory holding one file', () => { + // Folding here would hide the directory the file lives in. + expect(shape(tree(['a/b/one.txt', 'a/two.txt']))).toEqual([ + { 'a/': [{ 'b/': ['one.txt'] }, 'two.txt'] }, + ]); + }); + + it('sorts directories before files, each by name', () => { + expect(shape(tree(['z.txt', 'a.txt', 'src/b.js', 'docs/c.md']))).toEqual([ + { 'docs/': ['c.md'] }, + { 'src/': ['b.js'] }, + 'a.txt', + 'z.txt', + ]); + }); + + it('carries the caller entry and the whole path on each file', () => { + const files = [{ path: 'a/b/c.txt', status: 'added' }]; + const [dir] = buildFileTree(files); + const [file] = /** @type {any} */ (dir).children; + expect(file.path).toBe('a/b/c.txt'); + expect(file.file).toBe(files[0]); + }); + + it('reads no files as no tree', () => { + expect(buildFileTree([])).toEqual([]); + }); + + it('groups several files in one folded directory', () => { + expect(shape(tree(['a/b/one.txt', 'a/b/two.txt']))).toEqual([ + { 'a/b/': ['one.txt', 'two.txt'] }, + ]); + }); +}); + +describe('filterFiles', () => { + const files = [ + { path: 'packages/git/src/http.js' }, + { path: 'packages/core/src/pds.js' }, + { path: 'README.md' }, + ]; + + it('matches anywhere in the path', () => { + expect(filterFiles(files, 'git').map((f) => f.path)).toEqual([ + 'packages/git/src/http.js', + ]); + expect(filterFiles(files, 'src/').length).toBe(2); + }); + + it('ignores case and surrounding space', () => { + expect(filterFiles(files, ' README ').map((f) => f.path)).toEqual([ + 'README.md', + ]); + }); + + it('matches everything when nothing is typed', () => { + expect(filterFiles(files, '')).toBe(files); + expect(filterFiles(files, ' ')).toBe(files); + }); +}); diff --git a/packages/git-ui/test/highlight.test.js b/packages/git-ui/test/highlight.test.js new file mode 100644 index 0000000..9cc8faf --- /dev/null +++ b/packages/git-ui/test/highlight.test.js @@ -0,0 +1,162 @@ +/** + * Cutting coloured code into lines. A diff prints each line in its own row, + * so a span the grammar opened across a line break has to be closed and + * reopened without changing what the line says. + */ + +import { describe, expect, it } from 'vitest'; +import { + highlightBlock, + highlightHunk, + highlightToLines, + languageFor, +} from '../src/lib/highlight.js'; + +/** The text a browser would show for a run of HTML. */ +function textOf(html) { + return html + .replace(/<[^>]*>/g, '') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&'); +} + +describe('languageFor', () => { + it('reads the grammar off the extension', () => { + expect(languageFor('src/index.js')).toBe('javascript'); + expect(languageFor('a/b/main.rs')).toBe('rust'); + expect(languageFor('README.md')).toBe('markdown'); + expect(languageFor('deploy.yml')).toBe('yaml'); + }); + + it('names a grammar for a file that has no extension', () => { + expect(languageFor('Dockerfile')).toBe('dockerfile'); + expect(languageFor('docker/Dockerfile')).toBe('dockerfile'); + }); + + it('leaves an unknown file plain', () => { + expect(languageFor('LICENSE')).toBe(''); + expect(languageFor('notes.xyz')).toBe(''); + expect(languageFor('.gitignore')).toBe(''); + }); +}); + +describe('highlightBlock', () => { + it('escapes the source so a file cannot become markup', () => { + const html = highlightBlock('', ''); + expect(html).not.toContain('', '```'].join('\n')); + expect(html).not.toContain(''); + expect(html).not.toContain('