// Reaching the repositories, from a page that knows only its own origin. // // Two layers feed the app. The repository list and each repository's refs // come from the server's dev.pdsjs.git.listRepos. Trees, file bodies, // history and diffs come from the packfiles inside the bundle blobs, which // only a pack reader can open. A server that serves the dev.pdsjs.git reads // opens them from its own storage and answers each screen in one request; // the pack reader in this page covers a server that does not, and a // repository the server will not open whole. import { createGitBrowser } from '@pdsjs/git/browser'; import { GitClient, XrpcError } from '@pdsjs/git/client'; import { repoNameOf, rkeyOf } from '@pdsjs/git/record'; import { GIT_CONFIG_COLLECTION } from '@pdsjs/git/rules'; import { authedFetch, currentSession } from '#/lib/oauth.js'; /** Filled by discover() before any query runs. */ export const account = { did: '', pds: '', handle: '' }; /** * The repository records by name, and by space for the repositories held in * one. A private repository and a public one may share a name, so the key is * the space URI when there is one. */ const records = new Map(); /** * The same rows by record key, for the pack reader: it addresses a * repository by the key its record is at, where every screen addresses one * by name. * @type {Map}>} */ const byRkey = new Map(); /** @param {string} name @param {string} [space] */ const keyOf = (name, space) => (space ? `${space}#${name}` : name); /** * One in-page 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(); /** Whether the repository rows in hand came from the server this visit. */ let rowsAreFresh = false; /** * The record cid a read names, so the server's answer caches immutably. A * row from the last visit's cache may be behind the server, so only a fresh * row names one. * @param {string} name * @param {string} [space] * @returns {string|undefined} */ const cidOf = (name, space) => rowsAreFresh ? /** @type {{cid?: string}} */ (records.get(keyOf(name, space)))?.cid : undefined; /** @type {GitClient|null} */ let client = null; /** * The decision in flight or made: the base the dev.pdsjs.git.* methods are * answered from, this page's own origin when something there serves them (a * git appview, or the PDS itself when it hosts the page), the account's PDS * otherwise. Records and blobs stay on the PDS either way. */ let decision = null; /** * Ask each candidate for one cheap anonymous read. The first that answers * is the git service for this visit; the decision is made once and shared * by every caller, including the ones that arrive while the probes run. * @returns {Promise} */ function decideGitService() { decision ??= (async () => { for (const base of [...new Set([location.origin, account.pds])]) { try { const res = await fetch(`${base}/xrpc/dev.pdsjs.git.listRepos`); // A host with a single-page fallback answers any path with the app's // own HTML and a 200, so the answer has to be JSON to count. if ( res.ok && (res.headers.get('content-type') ?? '').includes('application/json') ) { return base; } } catch { // A base that answers nothing is not the git service. } } return account.pds; })(); return decision; } /** The account's git service, as a client of its dev.pdsjs.git methods. */ export async function serverClient() { const service = await decideGitService(); if (!client || client.service !== service) { client = new GitClient({ service }); } return client; } /** The one authenticated client, over the account's own PDS. */ let owner = null; /** * The account's PDS as a client of its dev.pdsjs.git methods, authenticated * as the signed-in account. The reads of a repository in a space must go * here: the space gate answers a bearer token naming the account, and the * PDS alone holds the space's storage. * @returns {import('@pdsjs/git/client').GitClient} */ export function ownerClient() { owner ??= new GitClient({ service: account.pds, fetch: (input, init) => { const session = currentSession(); if (!session) throw new Error('Sign in to read this repository.'); return authedFetch(session, String(input), init); }, }); return owner; } /** Repositories the server refused to open whole. This page reads them. */ const localOnly = new Set(); /** * Where the last read went: the server, or this page. Null before the * first read decides. Progress listeners hear a change, since the wait a * page shows depends on it. * @type {'server'|'page'|null} */ let source = null; export function readSource() { return source; } /** @param {string} name */ async function viaServer(name) { const server = !localOnly.has(name) && (await (await serverClient()).servesReads()); const next = server ? 'server' : 'page'; if (next !== source) { source = next; notifyProgress(); } return server; } /** * 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 = byRkey.get(url.searchParams.get('rkey') ?? ''); if (!row) return new Response('Not found', { status: 404 }); // The listing summarizes a record; the pack reader needs the bundle // manifest, which only the record carries. if (!Array.isArray(row.value?.bundles)) { return fetch(`${account.pds}${url.pathname}${url.search}`); } return Response.json({ uri: row.uri, cid: row.cid, value: row.value }); } if (url.pathname === '/xrpc/com.atproto.sync.getBlob') { const range = request.headers.get('range'); const headers = range ? { range } : undefined; const local = await fetch(`/.blobs/${url.searchParams.get('cid')}`, { headers, }); // 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; try { return await fetch(`${account.pds}${url.pathname}${url.search}`, { headers, }); } catch (err) { // A PDS whose CORS allowance predates the Range header rejects the // preflight. Asking again without the header returns the whole blob, // which the reader slices itself. if (!range) throw err; return fetch(`${account.pds}${url.pathname}${url.search}`); } } return fetch(`${account.pds}${url.pathname}${url.search}`); } /** * The directory this build resolves a did:plc against. A local stack runs a * PLC of its own, holding DIDs the public directory has never seen, and * `VITE_PLC_URL` at build time is what points the page at it. */ const PLC_URL = String( import.meta.env?.VITE_PLC_URL || 'https://plc.directory', ).replace(/\/$/, ''); /** @param {string} did */ export function didDocumentUrl(did) { if (did.startsWith('did:plc:')) return `${PLC_URL}/${did}`; if (did.startsWith('did:web:')) { return `https://${did.slice('did:web:'.length)}/.well-known/did.json`; } throw new Error(`Unsupported DID method: ${did}`); } /** * Where a visit remembers the account behind this hostname, so the next one * skips the directory round trip. The entry carries what an earlier visit * read from this hostname's own well-known route and the directory, and * every visit re-resolves behind the first paint, so a moved PDS corrects * itself. */ const DISCOVERY_KEY = 'git-ui:discovery'; /** Resolve the account fresh: well-known, then the DID document. */ async function resolveAccount() { const res = await fetch('/.well-known/atproto-did'); if (!res.ok) throw new Error('This hostname serves no atproto account.'); const did = (await res.text()).trim(); const doc = await (await fetch(didDocumentUrl(did))).json(); const service = (doc.service || []).find( (/** @type {{id: string, serviceEndpoint?: string}} */ entry) => entry.id.endsWith('#atproto_pds'), ); if (!service) throw new Error('The DID document names no PDS.'); const aka = (doc.alsoKnownAs || []).find((/** @type {string} */ name) => name.startsWith('at://'), ); return { did, pds: String(service.serviceEndpoint).replace(/\/$/, ''), handle: aka ? aka.slice('at://'.length) : did, }; } function readDiscoveryCache() { try { const row = JSON.parse(localStorage.getItem(DISCOVERY_KEY) || 'null'); if ( row && row.host === location.hostname && row.did && row.pds && row.handle ) { return row; } } catch { // Blocked or malformed storage reads as no cache. } return null; } function writeDiscoveryCache() { try { localStorage.setItem( DISCOVERY_KEY, JSON.stringify({ host: location.hostname, ...account }), ); } catch { // A page that cannot write storage just resolves fresh next time. } } /** Learn whose repo this is from the hostname, then where its PDS is. */ export async function discover() { const cached = readDiscoveryCache(); if (cached) { account.did = cached.did; account.pds = cached.pds; account.handle = cached.handle; // Re-resolve behind the paint. Requests already in flight keep the // cached addresses; anything after the update reads the fresh ones. resolveAccount() .then((fresh) => { Object.assign(account, fresh); writeDiscoveryCache(); }) .catch(() => {}); return account; } Object.assign(account, await resolveAccount()); writeDiscoveryCache(); return account; } // ---- what the PDS serves beyond record reads ---- /** * Two things the account's own server may or may not do. Filled by * loadCapabilities() before any screen renders. */ export const capabilities = { httpClone: false, rawFiles: false }; /** * The origin the git HTTP surface answered from. The PDS's own until a * probe learns better, which is what loadCapabilities() runs. */ let gitHttpBase = ''; /** The install settings, which name a forced answer as 'on' or 'off'. */ const FORCED = { on: true, off: false }; /** * Ask for one URL and say whether the answer is the content type that means * the feature is on. An unknown repository reads the same as a switched-off * endpoint, so the caller passes a repository that exists. A server that * withholds the open origin rejects the request, which reads as off. * @param {string} url * @param {string} type * @returns {Promise} */ async function serves(url, type) { try { const res = await fetch(url); return res.ok && (res.headers.get('content-type') || '').includes(type); } catch { return false; } } /** * Settings chosen at install, from the public install record naming this * site. The developer's own instance has no install record, so the probe * stands alone there. * @returns {Promise>} */ async function installConfig() { try { const params = new URLSearchParams({ repo: account.did, collection: 'dev.pdsjs.app.install', limit: '100', }); const res = await fetch( `${account.pds}/xrpc/com.atproto.repo.listRecords?${params}`, ); if (!res.ok) return {}; const site = location.hostname.split('.')[0]; const rows = (await res.json()).records || []; const mine = rows.find( (/** @type {{value?: {site?: string, config?: unknown}}} */ row) => row.value?.site === site, ); return mine?.value?.config ?? {}; } catch { return {}; } } /** * Decide both capabilities: the setting when it forces one, the probe * otherwise. Call it after loadRepos(), which is what puts a repository name * in hand for the probe. * * The git HTTP surface may live beside this page (a git appview serving the * app from its own origin) or on the PDS (embedded hosting). Whichever * answers the advertisement first is the base every clone and raw URL * builds on. */ export async function loadCapabilities() { const config = await installConfig(); // The probe names a repository for the server to open. A repository in a // space is keyed by space and name, and the HTTP surface does not serve // one, so a public repository is the one the probe tries. const [name] = [...records.keys()].find((key) => !key.includes('#')) ?? []; /** * @param {string} key - the setting that forces this answer * @param {string} path - what to ask the server for * @param {string} type - the content type that means the feature is on * @param {string[]} bases - the origins to ask, most likely first * @returns {Promise} */ const decide = async (key, path, type, bases) => { const forced = FORCED[/** @type {keyof typeof FORCED} */ (String(config[key]))]; if (forced !== undefined) return forced; for (const base of bases) { if ( name && (await serves(`${base}/git/${account.did}/${name}${path}`, type)) ) { gitHttpBase = base; return true; } } return false; }; const bases = [...new Set([location.origin, account.pds].filter(Boolean))]; const [httpClone, rawFiles] = await Promise.all([ decide( 'httpClone', '/info/refs?service=git-upload-pack', 'git-upload-pack-advertisement', bases, ), decide('rawFiles', '/raw', 'text/plain', bases), ]); capabilities.httpClone = httpClone; capabilities.rawFiles = rawFiles; return capabilities; } /** Where a visit remembers the repository rows the last one listed. */ const REPOS_KEY = 'git-ui:repos'; /** * @param {Array<{uri: string, cid: string, value: Record}>} rows * @param {boolean} fresh - whether the rows came from the server this visit */ function fillRecords(rows, fresh) { records.clear(); byRkey.clear(); for (const row of rows) { const rkey = rkeyOf(row.uri); records.set( keyOf(repoNameOf(row.value, rkey), String(row.value?.space ?? '')), row, ); byRkey.set(rkey, row); } rowsAreFresh = fresh; } /** @param {Array<{uri: string, value: Record}>} rows */ const toList = (rows) => rows.map((row) => ({ name: repoNameOf(row.value, rkeyOf(row.uri)), ...row.value, })); /** * Start from what the last visit stored: the account and the repository * rows. Screens render from these at once, real content rather than * placeholders, while the fresh reads run behind the paint. * @returns {Array>|null} the repository list, or * null when either half is missing */ export function primeFromCache() { const cached = readDiscoveryCache(); if (!cached) return null; /** @type {{host: string, rows: Array<{uri: string, cid: string, value: Record}>}|null} */ let stored = null; try { stored = JSON.parse(localStorage.getItem(REPOS_KEY) || 'null'); } catch { return null; } if ( !stored || stored.host !== location.hostname || !Array.isArray(stored.rows) ) { return null; } account.did = cached.did; account.pds = cached.pds; account.handle = cached.handle; fillRecords(stored.rows, false); return toList(stored.rows); } /** * The repository list, newest push first, as the server lists it. A row * holds what every screen reads of a repository: its name, refs, default * branch, description, size and record version. The signed-in owner's list * comes from their own PDS, which adds the repositories in spaces; anyone * else sees the public ones, wherever the git service answers. */ export async function loadRepos() { const mine = currentSession()?.did === account.did; /** @type {import('@pdsjs/git/client').RepoView[]} */ let repos; try { const client = mine ? ownerClient() : await serverClient(); ({ repos } = await client.listRepos()); } catch (err) { if (!mine) throw err; // An owner whose session has ended reads the public list rather than // holding the whole app on the account gate. The session provider clears // the session and the list refetches as a guest. const { repos: publicRepos } = await (await serverClient()).listRepos(); repos = publicRepos; } const rows = repos.map((repo) => ({ uri: repo.uri, cid: repo.cid, value: repo, })); fillRecords(rows, true); // The owner's rows name their private repositories, so they are not left // for the next visit to read before its own list lands; a guest would see // names their session never asked for. The public rows cache for everyone. if (!mine) { try { localStorage.setItem( REPOS_KEY, JSON.stringify({ host: location.hostname, rows }), ); } catch { // A page that cannot write storage just lists fresh next time. } } return toList(rows); } /** * Each public repository's summary, keyed by name: its open pull request and * issue counts, and the commit dates its activity gauge draws. The server * reads them, once for the listing rather than by each card. A repository in * a space keeps its statements and history there, so it is absent from the * map. * @returns {Promise>} */ export async function loadRepoSummary() { const { repos } = await (await serverClient()).listRepos({ summary: true }); const summary = new Map(); for (const repo of repos) { if (repo.space || typeof repo.openPulls !== 'number') continue; summary.set(repo.name, { openPulls: repo.openPulls, openIssues: repo.openIssues ?? 0, activity: repo.activity ?? [], }); } return summary; } /** * @param {string} name * @param {string} [space] - the space holding a private repository */ export function repoRecord(name, space) { return records.get(keyOf(name, space || spaceOf(name)))?.value ?? null; } /** * The space holding a repository of this name, or '' for one in the * account's own repo. A name is looked up in the listing rather than * carried on the address, so a private repository opens at the same address * a public one does. Where both exist under one name the public one wins, * which a caller naming the space directly gets past. * @param {string} name * @returns {string} */ export function spaceOf(name) { if (records.has(name)) return ''; const suffix = `#${name}`; for (const key of records.keys()) { if (key.endsWith(suffix)) return key.slice(0, -suffix.length); } return ''; } /** * The repository row whole: the record value with its address and version. * What a writer needs, where repoRecord serves the screens: a review or an * offer names the exact record version it was written against. * @param {string} name * @returns {{uri: string, cid: string, value: Record}|null} */ export function repoRow(name) { return records.get(name) ?? null; } /** * The record key of the repository one name addresses. A screen addresses a * repository by name; the pack reader and the config record address one by * the key its record is at. * @param {string} name * @param {string} [space] * @returns {string} */ export function repoKeyOf(name, space) { const row = records.get(keyOf(name, space || spaceOf(name))); return row ? rkeyOf(row.uri) : name; } /** * The list the records map already holds, from the cache or an earlier * load. Empty means nothing is in hand, not an account with no * repositories; the live answer tells those apart. */ export function repoList() { return [...records.entries()].map(([key, row]) => ({ name: key.split('#').pop(), .../** @type {{value: Record}} */ (row).value, })); } /** * The query key of the repository list. The owner's listing adds the * repositories in spaces, so the key changes with the viewer: signing in or * out refetches it, rather than holding a list from the other side of the * session. * @returns {string} */ export function reposKey() { return `repos-${currentSession()?.did === account.did ? 'owner' : 'guest'}`; } /** * One config record per repository, kept once it is read. Two screens want * it, the checks badge and the package list, and it changes only when its * owner edits the settings. * @type {Map>} */ const configs = new Map(); /** * The dev.pdsjs.git.config record beside a repository, or null where the * repository has none. A repository with no config has no rules, no runner * and no packages, which is the same answer an unreachable PDS gives. * @param {string} name - what the repository is called * @returns {Promise} */ export function repoConfig(name) { let pending = configs.get(name); if (!pending) { pending = (async () => { try { const params = new URLSearchParams({ repo: account.did, collection: GIT_CONFIG_COLLECTION, // The config sits at the repository's own key, beside the record. rkey: repoKeyOf(name), }); const res = await fetch( `${account.pds}/xrpc/com.atproto.repo.getRecord?${params}`, ); if (!res.ok) return null; return ( /** @type {{value?: unknown}} */ (await res.json()).value ?? null ); } catch { return null; } })(); configs.set(name, pending); } return pending; } /** * Drop the config record in hand, so the next read asks the server. The * settings write changes it, and the tabs read it to decide which sections * a repository has. * @param {string} name - what the repository is called */ export function forgetConfig(name) { configs.delete(name); } /** * The commit that last changed one path. The server walks the bundle chain * from its own storage and answers in one request; the same walk in this * page reads the repository's commits and trees over the network, which on * a repository of a few thousand commits is megabytes. * @param {string} repo * @param {string} refName * @param {string} path * @param {string} [space] */ export function lastCommitOf(repo, refName, path, space) { return readerFor(repo, undefined, space).lastCommit( account.did, repo, refName, path, ); } /** * @typedef {Object} CommitLabel * @property {string} subject - the message's first line * @property {string} author - the commit's author ident */ /** * What to call each commit reachable from the named refs, by sha. A run names * the commit it checked by sha alone, and a sha names nothing to a reader. * * This opens the repository, which the checks page otherwise never needs. The * caller runs it behind the paint and labels its rows when it answers. * @param {string} name - what the repository is called * @param {string[]} refs - full ref names, e.g. refs/heads/main * @param {number} [limit] - commits to walk per ref * @param {string} [space] * @returns {Promise>} */ export async function commitLabels(name, refs, limit = 100, space) { const reader = readerFor(name, undefined, space); /** @type {Map} */ const labels = new Map(); for (const ref of refs) { try { const listing = await reader.listCommits(account.did, name, ref, limit); for (const commit of listing?.commits ?? []) { if (labels.has(commit.sha)) continue; labels.set(commit.sha, { subject: String(commit.message).split('\n')[0], author: commit.author, }); } } catch { // A ref the repository no longer holds labels nothing, and the rows // that wanted it read as their sha. } } return labels; } /** * 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(); function notifyProgress() { for (const listener of progressListeners) listener(); } export function repoProgress() { return progress; } /** @param {() => void} listener */ export function onRepoProgress(listener) { progressListeners.add(listener); return () => progressListeners.delete(listener); } /** @param {string} name */ function localReader(name) { let reader = readers.get(name); if (!reader) { reader = createGitBrowser({ xrpc, onProgress(next) { progress = next; notifyProgress(); }, }); readers.set(name, reader); } return reader; } /** * One read, on the server where it serves the reads, in this page * otherwise. A server that refuses the repository as too large to open * whole sends this repository's reads here from then on. A server that no * longer holds the record version the read named is asked once more * without it. A repository in a space is read only from the account's own * PDS, signed in as the owner. * @template T * @param {string} name - what the repository is called * @param {string|undefined} space * @param {(client: GitClient, cid: string|undefined) => Promise} remote * @param {(reader: ReturnType) => Promise} local * @returns {Promise} */ async function read(name, space, remote, local) { if (space) return remote(ownerClient(), undefined); if (await viaServer(name)) { const cid = cidOf(name); try { return await remote(await serverClient(), cid); } catch (err) { if (!(err instanceof XrpcError)) throw err; if (err.error === 'RepoNotFound' && cid) { rowsAreFresh = false; return remote(await serverClient(), undefined); } if (err.error !== 'RepositoryTooLarge') throw err; localOnly.add(name); } } return local(localReader(name)); } /** * The bytes of one file at a ref, from the server. * @param {GitClient} client * @param {{repo: string, ref?: string, path: string, cid?: string}} params */ async function rawFrom(client, params) { const res = await client.getRaw(params); const bytes = new Uint8Array(await res.arrayBuffer()); return { ref: params.ref ?? '', path: params.path, size: bytes.length, mediaType: res.headers.get('content-type') ?? 'application/octet-stream', bytes, }; } /** * A reader of one repository with the in-page reader's methods, each * answered by the server or by that reader. * * With `from`, the reader is of another account's copy of the repository, * and every call goes to this server, which reads the copy from the server * that holds it. Nothing of a copy is in this page. With `space`, the * repository is private, and every call goes to the account's own PDS as * the owner. * @param {string} name - what the repository is called * @param {string} [from] - the DID whose copy to read * @param {string} [space] - the space holding a private repository */ export function readerFor(name, from, space) { /** * A read, the way `read` makes one, or on the server alone for a copy or * a repository in a space. * @template T * @param {(client: GitClient, cid: string|undefined) => Promise} remote * @param {(reader: ReturnType) => Promise} local */ const via = async (remote, local) => { if (space) return remote(ownerClient(), undefined); if (from) return remote(await serverClient(), undefined); return read(name, space, remote, local); }; /** * The parameters of a call, naming the copy where one is read. * @template {object} P * @param {P} params * @returns {P & {from?: string, space?: string}} */ const p = (params) => ({ ...params, ...(from ? { from } : {}), ...(space ? { space } : {}), }); // The methods take a name, because that is what a screen holds. The // in-page reader reads the record itself, so it takes the key. const key = repoKeyOf(name, space); /** * One commit or one range as the server answers it, with the bytes of an * image on either side, which the server names and this fetches. * @param {GitClient} client * @param {string|undefined} cid * @param {string} repo * @param {string} sha - one commit, or base..tip */ const commitFrom = async (client, cid, repo, sha) => { const commit = await client.getCommit(p({ repo, sha, cid })); // The server's diff names an image on either side; the bytes come // from the commit and its first parent. const files = await Promise.all( commit.files.map(async (file) => { if (!file.mediaType) return file; const side = (/** @type {string|undefined} */ at) => at ? rawFrom(client, p({ repo, ref: at, path: file.path, cid })) .then((image) => image.bytes) .catch(() => null) : Promise.resolve(null); const [oldImage, newImage] = await Promise.all([ side(file.status === 'added' ? undefined : commit.parents[0]), side(file.status === 'deleted' ? undefined : commit.sha), ]); return { ...file, oldImage, newImage }; }), ); return { ...commit, files }; }; return { /** * @param {string} did @param {string} repo @param {string} [ref] * @param {string} [path] */ listTree: (did, repo, ref, path) => via( (client, cid) => client.getTree(p({ repo, ref, path: path ?? '', cid })), (reader) => reader.listTree(did, key, ref, path), ), /** * @param {string} did @param {string} repo @param {string} [ref] * @param {number} [limit] * @param {string} [path] - a path, for the commits that changed it */ listCommits: (did, repo, ref, limit, path) => via( (client, cid) => client.listCommits( p({ repo, ref, limit, cid, ...(path ? { path } : {}) }), ), (reader) => reader.listCommits(did, key, ref, limit, undefined, path), ), /** * The commits the tip reaches that the base does not. * @param {string} did @param {string} repo @param {string|null} base * @param {string} tip @param {number} [limit] */ listRange: (did, repo, base, tip, limit) => via( (client, cid) => client.listCommits( p({ repo, range: `${base ?? ''}..${tip}`, limit, cid }), ), (reader) => reader.listRange(did, key, base, tip, limit), ), /** @param {string} did @param {string} repo @param {string} sha */ readCommit: (did, repo, sha) => via( (client, cid) => commitFrom(client, cid, repo, sha), (reader) => reader.readCommit(did, key, sha), ), /** * The changes across a range as one diff. * @param {string} did @param {string} repo @param {string} base * @param {string} tip */ readRange: (did, repo, base, tip) => via( (client, cid) => commitFrom(client, cid, repo, `${base}..${tip}`), // A range has a base and no parents; the page reads either shape. (reader) => /** @type {Promise>|null>} */ ( /** @type {unknown} */ (reader.readRange(did, key, base, tip)) ), ), /** * @param {string} did @param {string} repo @param {string} [ref] * @param {string} [path] */ readFile: (did, repo, ref, path) => via( (client, cid) => client.getFile(p({ repo, ref, path: path ?? '', cid })), (reader) => reader.readFile(did, key, ref, path), ), /** * @param {string} did @param {string} repo @param {string} [ref] * @param {string} [path] */ readImage: (did, repo, ref, path) => via( (client, cid) => rawFrom(client, p({ repo, ref, path: path ?? '', cid })), (reader) => reader.readImage(did, key, ref, path), ), /** @param {string} did @param {string} repo @param {string} [ref] */ listFiles: (did, repo, ref) => via( (client, cid) => client.listFiles(p({ repo, ref, cid })), (reader) => reader.listFiles(did, key, ref), ), /** * @param {string} did @param {string} repo @param {string} [ref] * @param {string} [path] */ lastCommit: (did, repo, ref, path) => via( async (client, cid) => (await client.getLastCommit(p({ repo, ref, path, cid }))).commit, (reader) => reader.lastCommit(did, key, ref, path), ), }; } /** * One commit, or as base..tip the changes across a range, from whichever * copy holds it: this account's, or another's read through this server. * What a commit page shows, and what its route warms. * @param {string} repo - what the repository is called * @param {string} sha - one commit, or base..tip * @param {string} [from] - the DID whose copy holds it, empty for this account * @param {string} [space] - the space holding a private repository */ export function readChange(repo, sha, from, space) { const copy = from && from !== account.did ? from : undefined; const reader = readerFor(repo, copy, space); const did = copy ?? account.did; const [base, tip] = sha.includes('..') ? sha.split('..') : ['', sha]; return base ? reader.readRange(did, repo, base, tip) : reader.readCommit(did, repo, tip); } // ---- reading the record ---- /** * The repository record, as every screen reads it. The fields are optional * because a record may be read before the account's own list has landed. * @typedef {Object} RepoRecord * @property {string} [name] * @property {string} [description] * @property {number} [size] - every bundle part's bytes, as the listing sums them * @property {string} [defaultBranch] * @property {{name: string, sha?: string}[]} [refs] * @property {{parts?: {size?: number}[]}[]} [bundles] */ /** @param {string} name */ export const shortRef = (name) => name.replace(/^refs\/(heads|tags)\//, ''); /** @param {RepoRecord|null|undefined} repo */ export const branchRefs = (repo) => (repo?.refs || []).filter((ref) => ref.name.startsWith('refs/heads/')); /** @param {RepoRecord|null|undefined} repo */ export const tagRefs = (repo) => (repo?.refs || []).filter((ref) => ref.name.startsWith('refs/tags/')); /** @param {RepoRecord|null|undefined} repo */ export const defaultRef = (repo) => repo?.defaultBranch || 'main'; /** @param {RepoRecord|null|undefined} repo */ export const isEmpty = (repo) => (repo?.refs || []).length === 0; /** * Every bundle chunk is a blob ref, so the size needs no download. * @param {RepoRecord|null|undefined} repo */ export function repoSize(repo) { if (typeof repo?.size === 'number') return repo.size; let total = 0; for (const bundle of Array.isArray(repo?.bundles) ? 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. * @param {RepoRecord|null|undefined} repo * @param {string} tail */ 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 */ /** * The URL that serves a file's bytes, for a reader that wants the source * rather than the page around it. The git HTTP surface is whichever origin * answered the capability probe: the appview that serves this page, or the * PDS. * @param {string} repo * @param {string} ref * @param {string} path */ export function rawUrl(repo, ref, path) { const at = `${encodeURIComponent(account.handle)}/${encodeURIComponent(repo)}`; const base = gitHttpBase || account.pds; return `${base}/git/${at}/raw/${encodeURI(ref)}/${encodeURI(path)}`; }