From 53c71a51b4e8feeaef69fccf9154aead3da5f9ba Mon Sep 17 00:00:00 2001 From: Chad Miller Date: Tue, 18 Aug 2026 22:14:58 -0700 Subject: [PATCH] feat(git-ui): a public repository browser served from the account's site Deploys to git. as one self-contained document. A visitor browses trees, files, history and commit diffs with no sign-in, because the page reads the repository records through a materialized query and opens the bundle blobs itself. Two layers feed it. The repository list, refs, default branch and sizes come from one dev.pdsjs.query.run call, so the list page, the branch menu and the clone box cost no blob downloads. Trees, file bodies, history and diffs live inside the packfiles, which the pack reader opens in the page. Having the refs in hand first is what lets a URL like /repo/tree/feature/x/src/index.js be split correctly, since the ref and the path both hold slashes. React, shadcn conventions and Base UI, the same stack the account pages use. Syntax colouring for files and diffs, with each side of a hunk coloured whole so a block comment does not end at every line break. READMEs render as markdown, with raw HTML printed rather than parsed and addresses held to schemes worth following. A commit shows its changed files as the tree they came from. The build is one file on purpose: the site record names a file by path and the page HTML caches for minutes, so hashed assets would leave a visitor holding old HTML asking for a script the record no longer names. --- package.json | 3 +- packages/git-ui/.gitignore | 1 + packages/git-ui/README.md | 131 ++++++ packages/git-ui/components.json | 19 + packages/git-ui/index.html | 20 + packages/git-ui/package.json | 37 ++ .../git-ui/scripts/check-single-component.mjs | 36 ++ packages/git-ui/scripts/deploy.mjs | 220 ++++++++++ packages/git-ui/src/app.jsx | 145 +++++++ .../git-ui/src/components/atoms/avatar.jsx | 24 ++ .../git-ui/src/components/atoms/badge.jsx | 21 + .../git-ui/src/components/atoms/button.jsx | 68 +++ packages/git-ui/src/components/atoms/card.jsx | 11 + packages/git-ui/src/components/atoms/link.jsx | 35 ++ .../git-ui/src/components/atoms/skeleton.jsx | 6 + .../src/components/molecules/author-line.jsx | 34 ++ .../src/components/molecules/breadcrumbs.jsx | 33 ++ .../src/components/molecules/code-menu.jsx | 87 ++++ .../src/components/molecules/diff-file.jsx | 121 ++++++ .../src/components/molecules/diff-tree.jsx | 139 ++++++ .../src/components/molecules/empty-state.jsx | 30 ++ .../components/molecules/latest-commit.jsx | 53 +++ .../src/components/molecules/markdown.jsx | 67 +++ .../src/components/molecules/reading-repo.jsx | 66 +++ .../src/components/molecules/ref-select.jsx | 69 +++ .../src/components/molecules/tree-list.jsx | 58 +++ packages/git-ui/src/lib/authors.js | 118 +++++ packages/git-ui/src/lib/file-tree.js | 117 +++++ packages/git-ui/src/lib/format.js | 38 ++ packages/git-ui/src/lib/git.js | 205 +++++++++ packages/git-ui/src/lib/highlight.js | 219 ++++++++++ packages/git-ui/src/lib/markdown.js | 146 +++++++ packages/git-ui/src/lib/navigation.jsx | 44 ++ packages/git-ui/src/lib/router.js | 49 +++ packages/git-ui/src/lib/utils.js | 12 + packages/git-ui/src/main.jsx | 26 ++ packages/git-ui/src/pages/commit.jsx | 123 ++++++ packages/git-ui/src/pages/commits.jsx | 79 ++++ packages/git-ui/src/pages/file.jsx | 128 ++++++ packages/git-ui/src/pages/repos.jsx | 87 ++++ packages/git-ui/src/pages/tree.jsx | 184 ++++++++ packages/git-ui/src/style.css | 402 ++++++++++++++++++ packages/git-ui/test/file-tree.test.js | 104 +++++ packages/git-ui/test/highlight.test.js | 162 +++++++ packages/git-ui/test/markdown.test.js | 178 ++++++++ packages/git-ui/vite.config.js | 28 ++ pnpm-lock.yaml | 65 +++ tsconfig.json | 1 + vitest.config.js | 1 + 49 files changed, 4049 insertions(+), 1 deletion(-) create mode 100644 packages/git-ui/.gitignore create mode 100644 packages/git-ui/README.md create mode 100644 packages/git-ui/components.json create mode 100644 packages/git-ui/index.html create mode 100644 packages/git-ui/package.json create mode 100644 packages/git-ui/scripts/check-single-component.mjs create mode 100644 packages/git-ui/scripts/deploy.mjs create mode 100644 packages/git-ui/src/app.jsx create mode 100644 packages/git-ui/src/components/atoms/avatar.jsx create mode 100644 packages/git-ui/src/components/atoms/badge.jsx create mode 100644 packages/git-ui/src/components/atoms/button.jsx create mode 100644 packages/git-ui/src/components/atoms/card.jsx create mode 100644 packages/git-ui/src/components/atoms/link.jsx create mode 100644 packages/git-ui/src/components/atoms/skeleton.jsx create mode 100644 packages/git-ui/src/components/molecules/author-line.jsx create mode 100644 packages/git-ui/src/components/molecules/breadcrumbs.jsx create mode 100644 packages/git-ui/src/components/molecules/code-menu.jsx create mode 100644 packages/git-ui/src/components/molecules/diff-file.jsx create mode 100644 packages/git-ui/src/components/molecules/diff-tree.jsx create mode 100644 packages/git-ui/src/components/molecules/empty-state.jsx create mode 100644 packages/git-ui/src/components/molecules/latest-commit.jsx create mode 100644 packages/git-ui/src/components/molecules/markdown.jsx create mode 100644 packages/git-ui/src/components/molecules/reading-repo.jsx create mode 100644 packages/git-ui/src/components/molecules/ref-select.jsx create mode 100644 packages/git-ui/src/components/molecules/tree-list.jsx create mode 100644 packages/git-ui/src/lib/authors.js create mode 100644 packages/git-ui/src/lib/file-tree.js create mode 100644 packages/git-ui/src/lib/format.js create mode 100644 packages/git-ui/src/lib/git.js create mode 100644 packages/git-ui/src/lib/highlight.js create mode 100644 packages/git-ui/src/lib/markdown.js create mode 100644 packages/git-ui/src/lib/navigation.jsx create mode 100644 packages/git-ui/src/lib/router.js create mode 100644 packages/git-ui/src/lib/utils.js create mode 100644 packages/git-ui/src/main.jsx create mode 100644 packages/git-ui/src/pages/commit.jsx create mode 100644 packages/git-ui/src/pages/commits.jsx create mode 100644 packages/git-ui/src/pages/file.jsx create mode 100644 packages/git-ui/src/pages/repos.jsx create mode 100644 packages/git-ui/src/pages/tree.jsx create mode 100644 packages/git-ui/src/style.css create mode 100644 packages/git-ui/test/file-tree.test.js create mode 100644 packages/git-ui/test/highlight.test.js create mode 100644 packages/git-ui/test/markdown.test.js create mode 100644 packages/git-ui/vite.config.js 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('