diff --git a/packages/git-ui/package.json b/packages/git-ui/package.json
index 1367112..d00889c 100644
--- a/packages/git-ui/package.json
+++ b/packages/git-ui/package.json
@@ -13,6 +13,7 @@
"dependencies": {
"@base-ui/react": "^1.6.0",
"@pdsjs/git": "workspace:*",
+ "@pdsjs/git-ci": "workspace:*",
"@pdsjs/npm": "workspace:*",
"@pdsjs/oci": "workspace:*",
"@tanstack/react-query": "^5.101.4",
diff --git a/packages/git-ui/src/app.jsx b/packages/git-ui/src/app.jsx
index c213a0b..fd7a0ad 100644
--- a/packages/git-ui/src/app.jsx
+++ b/packages/git-ui/src/app.jsx
@@ -2,10 +2,10 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo } from 'react';
import { Avatar } from '#/components/atoms/avatar.jsx';
import { Link } from '#/components/atoms/link.jsx';
-import {
- RepoListSkeleton,
- RepoPageSkeleton,
-} from '#/components/molecules/page-skeleton.jsx';
+import { CodeMenu } from '#/components/molecules/code-menu.jsx';
+import { RepoListSkeleton } from '#/components/molecules/repo-list-skeleton.jsx';
+import { RepoPageSkeleton } from '#/components/molecules/repo-page-skeleton.jsx';
+import { RepoTabs, SECTION_TITLES } from '#/components/molecules/repo-tabs.jsx';
import { initial, loadAuthors, owner } from '#/lib/authors.js';
import {
account as accountInfo,
@@ -20,6 +20,7 @@ import {
} from '#/lib/git.js';
import { NavigationProvider, useLocation } from '#/lib/navigation.jsx';
import { cn } from '#/lib/utils.js';
+import { CheckPage } from '#/pages/check.jsx';
import { ChecksPage } from '#/pages/checks.jsx';
import { CommitPage } from '#/pages/commit.jsx';
import { CommitsPage } from '#/pages/commits.jsx';
@@ -32,7 +33,7 @@ import { TreePage } from '#/pages/tree.jsx';
* 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 }) {
+function Screen({ path }) {
const segments = path.split('/').filter(Boolean).map(decodeURIComponent);
if (segments.length === 0) return ;
@@ -44,7 +45,13 @@ function Screen({ path, httpClone }) {
return
No such repository.
;
}
if (kind === 'commit') return ;
- if (kind === 'checks') return ;
+ if (kind === 'checks') {
+ return tail ? (
+
+ ) : (
+
+ );
+ }
if (kind === 'packages') return ;
if (kind === 'commits') {
return ;
@@ -55,29 +62,30 @@ function Screen({ path, httpClone }) {
}
if (kind === 'tree') {
const { ref, path: dirPath } = splitRefPath(record, tail);
- return (
-
- );
+ return ;
}
- return (
-
- );
+ return ;
+}
+
+/**
+ * Which section of a repository a path names. The tab bar marks it, and each
+ * page belongs to exactly one: a commit is part of the history, a run is part
+ * of the checks, a file is part of the code.
+ * @param {string|undefined} kind - the path segment after the repository
+ * @returns {string}
+ */
+function sectionOf(kind) {
+ if (kind === 'commits' || kind === 'commit') return 'commits';
+ if (kind === 'checks') return 'checks';
+ if (kind === 'packages') return 'packages';
+ return 'code';
}
export function App() {
const { path, navigate } = useLocation();
const queryClient = useQueryClient();
- const wide = path.split('/').filter(Boolean)[1] === 'commit';
+ const segments = path.split('/').filter(Boolean).map(decodeURIComponent);
+ const wide = segments[1] === 'commit';
// The previous visit's account and repository rows, read synchronously.
// With them in hand every screen renders real content on the first frame,
@@ -117,66 +125,127 @@ export function App() {
staleTime: Number.POSITIVE_INFINITY,
});
+ // The repository in view, and which of its sections. Both come from the
+ // path, so every page carries the same heading and the same tabs without
+ // passing them down.
+ const repo = ready && segments.length > 0 ? segments[0] : '';
+ const known = Boolean(repo) && Boolean(repoRecord(repo));
+ const section = sectionOf(segments[1]);
+
+ // Every screen holds the window and scrolls inside itself, so the bar and
+ // whatever each page fixes above its list stay put. A screen that has not
+ // been given that shape yet reads as a column of prose instead.
+ const filled = segments.length === 0 || known;
+
return (
-
-
-
- Repositories
-
- {ready && (
-
-
- {initial(owner()?.displayName || accountInfo.handle)}
-
- }
- className="size-6"
- />
-
- {accountInfo.handle}
-
-
- )}
+ {/* The window is the app: the bar holds its place and each pane below
+ takes its own scroll, so nothing but the reading area ever moves. */}
+
+ {/* Where the reader is, and nothing else. Which section is open and
+ what that section is looking at each get a band of their own
+ below, so no one bar carries three different jobs. */}
+
+
- ) : !ready ? (
- // The URL says which screen is coming, so the placeholder can hold
- // its shape rather than a bare block.
- path.split('/').filter(Boolean).length === 0 ? (
-
- ) : (
-
- )
- ) : (
-
+ {/* The repository's sections, with the one action that belongs to the
+ repository rather than to any one of them. */}
+ {known && (
+
+
+
+
+
+
+
+
)}
-
+
+ {error && !ready ? (
+
+ Could not reach the PDS. {error.message}
+
+ ) : !ready ? (
+ // The URL says which screen is coming, so the placeholder can hold
+ // its shape rather than a bare block.
+ segments.length === 0 ? (
+
+ ) : (
+
+ )
+ ) : filled ? (
+
+ ) : (
+
+
+
+ )}
+
);
diff --git a/packages/git-ui/src/components/molecules/account-panel.jsx b/packages/git-ui/src/components/molecules/account-panel.jsx
new file mode 100644
index 0000000..e252324
--- /dev/null
+++ b/packages/git-ui/src/components/molecules/account-panel.jsx
@@ -0,0 +1,86 @@
+import { Avatar } from '#/components/atoms/avatar.jsx';
+import { initial, owner } from '#/lib/authors.js';
+import { bytes } from '#/lib/format.js';
+import { account, repoSize } from '#/lib/git.js';
+
+/** One reading of the account, label above value. */
+function Field({ label, children }) {
+ return (
+
+
+ {label}
+
+
+ {children}
+
+
+ );
+}
+
+/**
+ * Whose repositories these are, beside the list of them: the account, the
+ * name it answers to, and the server holding it. Every repository on this
+ * page is one account's, so the account is named once here rather than on
+ * each row.
+ */
+export function AccountPanel({ repos }) {
+ const profile = owner();
+ const total = (repos ?? []).reduce((sum, repo) => sum + repoSize(repo), 0);
+
+ return (
+
+ );
+}
diff --git a/packages/git-ui/src/components/molecules/activity-ticks.jsx b/packages/git-ui/src/components/molecules/activity-ticks.jsx
new file mode 100644
index 0000000..b2fd658
--- /dev/null
+++ b/packages/git-ui/src/components/molecules/activity-ticks.jsx
@@ -0,0 +1,55 @@
+import { ACTIVITY_WEEKS, WEEK_MS } from '#/lib/activity.js';
+import { cn } from '#/lib/utils.js';
+
+/**
+ * How often this ref was committed to, week by week, as a row of ticks read
+ * like a gauge. The dates come from the walk the page already made for its
+ * latest commit, so this costs no read of its own.
+ *
+ * A week with no commits keeps a floor tick, so the row reads as a scale
+ * rather than as gaps. Heights are against the busiest week in view, which
+ * makes the shape of the work legible whatever the repository's volume.
+ */
+export function ActivityTicks({ dates, className }) {
+ const now = Date.now();
+ /** @type {{start: number, count: number}[]} oldest first */
+ const weeks = Array.from({ length: ACTIVITY_WEEKS }, (_, index) => ({
+ start: now - (ACTIVITY_WEEKS - index) * WEEK_MS,
+ count: 0,
+ }));
+ for (const at of dates) {
+ const week = Math.floor((now - at) / WEEK_MS);
+ if (week >= 0 && week < ACTIVITY_WEEKS) {
+ weeks[ACTIVITY_WEEKS - 1 - week].count += 1;
+ }
+ }
+
+ const busiest = Math.max(...weeks.map((week) => week.count));
+ if (busiest === 0) return null;
+ const total = weeks.reduce((sum, week) => sum + week.count, 0);
+ const words = `${total} ${total === 1 ? 'commit' : 'commits'} in the last ${ACTIVITY_WEEKS} weeks`;
+
+ return (
+
+ );
+}
diff --git a/packages/git-ui/src/components/molecules/check-disclosure.jsx b/packages/git-ui/src/components/molecules/check-disclosure.jsx
new file mode 100644
index 0000000..ad3a5de
--- /dev/null
+++ b/packages/git-ui/src/components/molecules/check-disclosure.jsx
@@ -0,0 +1,52 @@
+import { ChevronDownIcon, ChevronRightIcon } from 'lucide-react';
+import { useState } from 'react';
+import { Link } from '#/components/atoms/link.jsx';
+import { CheckLog } from '#/components/molecules/check-log.jsx';
+import { CheckRow } from '#/components/molecules/check-row.jsx';
+import { CheckSteps } from '#/components/molecules/check-steps.jsx';
+
+/**
+ * One run as a line that opens in place: the steps it ran and the log behind
+ * them. The log is fetched only once opened, so a commit checked by several
+ * workflows costs one request per run the reader actually opens.
+ *
+ * A failed run opens itself. Someone reading a commit whose check failed came
+ * for the output.
+ */
+export function CheckDisclosure({ runner, check, runHref }) {
+ const failed = check.status === 'failure' || check.status === 'error';
+ const [open, setOpen] = useState(failed);
+ const steps = Array.isArray(check.steps) ? check.steps : [];
+ const Chevron = open ? ChevronDownIcon : ChevronRightIcon;
+
+ return (
+
+
+
+ {runHref && (
+
+ Details
+
+ )}
+
+ {open && (
+
+ {steps.length > 0 && }
+ {/* A log opened beside its neighbours is capped, so one long run
+ does not push the rest of them off the page. */}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/packages/git-ui/src/components/molecules/check-filter.jsx b/packages/git-ui/src/components/molecules/check-filter.jsx
new file mode 100644
index 0000000..447efd7
--- /dev/null
+++ b/packages/git-ui/src/components/molecules/check-filter.jsx
@@ -0,0 +1,43 @@
+import { cn } from '#/lib/utils.js';
+
+/**
+ * Which workflow a run listing shows, with how many runs each holds. A
+ * repository checked by one workflow has nothing to choose between, so the
+ * filter appears from two workflows upwards.
+ */
+export function CheckFilter({ workflows, counts, total, value, onSelect }) {
+ if (workflows.length < 2) return null;
+
+ const options = [
+ { key: '', title: 'All workflows', count: total },
+ ...workflows.map((name) => ({
+ key: name,
+ title: name,
+ count: counts[name],
+ })),
+ ];
+
+ return (
+
+ {options.map((option) => (
+
+ ))}
+
+ );
+}
diff --git a/packages/git-ui/src/components/molecules/check-group.jsx b/packages/git-ui/src/components/molecules/check-group.jsx
new file mode 100644
index 0000000..3089e79
--- /dev/null
+++ b/packages/git-ui/src/components/molecules/check-group.jsx
@@ -0,0 +1,77 @@
+import { Link } from '#/components/atoms/link.jsx';
+import { CheckRow } from '#/components/molecules/check-row.jsx';
+import {
+ checkLook,
+ latestPerWorkflow,
+ summarize,
+ summaryWords,
+} from '#/lib/checks.js';
+import { shortRef } from '#/lib/git.js';
+import { cn } from '#/lib/utils.js';
+
+/**
+ * One commit's runs, under a heading that names the commit. A sha names
+ * nothing to a reader, so the heading reads as the commit's subject line once
+ * the repository is open, and as the short sha until then.
+ *
+ * The heading's status covers the commit: the newest run of each workflow
+ * decides it, so a workflow asked for twice counts once.
+ */
+export function CheckGroup({ repo, group, label }) {
+ const summary = summarize(latestPerWorkflow(group.runs));
+ const look = checkLook(summary?.status);
+ const commitHref = `/${encodeURIComponent(repo)}/commit/${group.sha}`;
+ const short = group.sha.slice(0, 8);
+ const ref = group.runs.find((run) => run.ref)?.ref;
+
+ return (
+
diff --git a/packages/git-ui/src/components/molecules/check-row.jsx b/packages/git-ui/src/components/molecules/check-row.jsx
new file mode 100644
index 0000000..1ecf02e
--- /dev/null
+++ b/packages/git-ui/src/components/molecules/check-row.jsx
@@ -0,0 +1,36 @@
+import { checkLook, runDuration, workflowOf } from '#/lib/checks.js';
+import { timeAgo } from '#/lib/format.js';
+import { cn } from '#/lib/utils.js';
+
+/**
+ * One run as a single line: which workflow ran, how long it took, and how it
+ * ended. Every listing of runs sits under something that already names the
+ * commit, so the line carries no sha and no ref.
+ *
+ * This holds no anchor and no button of its own. The caller decides whether
+ * the line opens the run's page or expands in place.
+ */
+export function CheckRow({ check, className }) {
+ const look = checkLook(check.status);
+ const took = runDuration(check);
+
+ return (
+
+
+
+ {workflowOf(check)}
+
+ {/* Each column holds its width whether or not the run fills it, so the
+ durations and times read down the page as columns. */}
+
+ {took}
+ {timeAgo(check.startedAt)}
+
+
+ );
+}
diff --git a/packages/git-ui/src/components/molecules/check-run.jsx b/packages/git-ui/src/components/molecules/check-run.jsx
index 1f6bd2b..8ae3532 100644
--- a/packages/git-ui/src/components/molecules/check-run.jsx
+++ b/packages/git-ui/src/components/molecules/check-run.jsx
@@ -1,107 +1,82 @@
-import { ChevronDownIcon, ChevronRightIcon } from 'lucide-react';
-import { useState } from 'react';
-import { Card } from '#/components/atoms/card.jsx';
import { Link } from '#/components/atoms/link.jsx';
import { CheckLog } from '#/components/molecules/check-log.jsx';
-import { checkLook, duration, runDuration } from '#/lib/checks.js';
+import { CheckSteps } from '#/components/molecules/check-steps.jsx';
+import { checkLook, runDuration, workflowOf } from '#/lib/checks.js';
import { shortRef } from '#/lib/git.js';
import { cn } from '#/lib/utils.js';
-/** A step's own line: the exit code decides how its name reads. */
-function StepRow({ step }) {
- const failed = step.exitCode !== 0;
+/** One field of the run's summary block. */
+function Field({ label, children }) {
return (
-
);
}
/**
- * One run in full: what the runner ran, how each step ended, and the log
- * behind a disclosure. The log is fetched only once opened, so a commit with
- * several runs costs one request per run the reader actually opens.
- *
- * A failed run opens itself. Someone reading a commit whose check failed came
- * for the output.
- *
- * `commitHref` names the commit under test. A page that already names the
- * commit passes nothing, and the header leaves the sha out.
+ * One run in full, which is what the run's own page shows: what the runner
+ * ran it against, how each step ended, and the whole log. The log is on this
+ * page because a reader who opened one run came for its output.
*/
export function CheckRun({ runner, check, commitHref }) {
- const failed = check.status === 'failure' || check.status === 'error';
- const [open, setOpen] = useState(failed);
const look = checkLook(check.status);
const took = runDuration(check);
const steps = Array.isArray(check.steps) ? check.steps : [];
- const Chevron = open ? ChevronDownIcon : ChevronRightIcon;
return (
-
-
- )}
+ {/* The log takes what the window has left, so a long run reads without
+ moving the readings above it. */}
+
+
-
+
);
}
diff --git a/packages/git-ui/src/components/molecules/check-status.jsx b/packages/git-ui/src/components/molecules/check-status.jsx
index b89ff5a..cfe7e39 100644
--- a/packages/git-ui/src/components/molecules/check-status.jsx
+++ b/packages/git-ui/src/components/molecules/check-status.jsx
@@ -1,28 +1,25 @@
-import { useQuery } from '@tanstack/react-query';
-import { checkLook, loadChecks } from '#/lib/checks.js';
+import { checkLook, summaryWords } from '#/lib/checks.js';
import { cn } from '#/lib/utils.js';
/**
- * The check the repository's runner published for one commit, as one icon.
- * Absent means the repository names no runner, or the runner has not reported
- * on this commit; neither is worth showing.
+ * How a commit's checks ended, as one icon. A commit checked by several
+ * workflows reads as one status: a single failure makes the commit failed,
+ * whatever its other workflows say.
*
* The icon carries its words in a title, so a commit list row stays one line
- * and one anchor. The commit page shows the run itself instead.
+ * and one anchor. The commit page shows the runs themselves instead.
*/
-export function CheckStatus({ repo, sha, className }) {
- const { data } = useQuery({
- queryKey: ['checks', repo],
- queryFn: () => loadChecks(repo),
- });
- const check = data?.bySha.get(sha);
- if (!check) return null;
- const look = checkLook(check.status);
- const words = `${check.workflow ?? 'ci'} ${look.label}`;
+export function CheckStatus({ summary, runs, className }) {
+ if (!summary) return null;
+ const look = checkLook(summary.status);
+ const words = summaryWords(summary, runs);
return (
-
+ {words}
);
diff --git a/packages/git-ui/src/components/molecules/check-steps.jsx b/packages/git-ui/src/components/molecules/check-steps.jsx
new file mode 100644
index 0000000..735f684
--- /dev/null
+++ b/packages/git-ui/src/components/molecules/check-steps.jsx
@@ -0,0 +1,44 @@
+import { duration } from '#/lib/checks.js';
+import { cn } from '#/lib/utils.js';
+
+/**
+ * What the runner ran, in order, and how each step ended. The exit code
+ * decides how a step's name reads.
+ */
+export function CheckSteps({ steps }) {
+ return (
+
+ );
+}
diff --git a/packages/git-ui/src/components/molecules/code-menu.jsx b/packages/git-ui/src/components/molecules/code-menu.jsx
index f1bec1a..f5a5b71 100644
--- a/packages/git-ui/src/components/molecules/code-menu.jsx
+++ b/packages/git-ui/src/components/molecules/code-menu.jsx
@@ -30,15 +30,18 @@ export function CodeMenu({ repo, httpClone }) {
return (
+ {/* The same control the ref picker is. Cloning is the one thing this
+ page offers, but a filled button for it would be the only loud
+ surface on a page of flat panels. */}
-
- Code
-
+
+ Clone
+
diff --git a/packages/git-ui/src/components/molecules/commit-check-status.jsx b/packages/git-ui/src/components/molecules/commit-check-status.jsx
new file mode 100644
index 0000000..b986816
--- /dev/null
+++ b/packages/git-ui/src/components/molecules/commit-check-status.jsx
@@ -0,0 +1,24 @@
+import { useQuery } from '@tanstack/react-query';
+import { CheckStatus } from '#/components/molecules/check-status.jsx';
+import { loadChecks, summarize } from '#/lib/checks.js';
+
+/**
+ * The badge for one commit in a history listing, from the runner's run
+ * listing. Absent means the repository names no runner, or the runner has not
+ * reported on this commit; neither is worth showing.
+ */
+export function CommitCheckStatus({ repo, sha, className }) {
+ const { data } = useQuery({
+ queryKey: ['checks', repo],
+ queryFn: () => loadChecks(repo),
+ });
+ const latest = data?.bySha.get(sha) ?? [];
+
+ return (
+
+ );
+}
diff --git a/packages/git-ui/src/components/molecules/commit-checks.jsx b/packages/git-ui/src/components/molecules/commit-checks.jsx
index 21b3ed5..5626d8d 100644
--- a/packages/git-ui/src/components/molecules/commit-checks.jsx
+++ b/packages/git-ui/src/components/molecules/commit-checks.jsx
@@ -1,11 +1,21 @@
import { useQuery } from '@tanstack/react-query';
-import { CheckRun } from '#/components/molecules/check-run.jsx';
-import { loadChecks } from '#/lib/checks.js';
+import { CheckDisclosure } from '#/components/molecules/check-disclosure.jsx';
+import {
+ checkLook,
+ loadChecks,
+ summarize,
+ summaryWords,
+} from '#/lib/checks.js';
+import { cn } from '#/lib/utils.js';
/**
- * Every run the repository's runner published for one commit, newest first.
- * A commit usually has one. It has more when someone asked for the check
- * again, and each of those is a separate result worth reading.
+ * Every run the repository's runner published for one commit, newest first,
+ * one line each. A commit usually has one line per workflow. It has more when
+ * someone asked for a workflow again, and each of those is a separate result
+ * worth reading.
+ *
+ * The heading counts the workflows rather than the runs, so a commit re-run
+ * four times still reads as one workflow passing.
*/
export function CommitChecks({ repo, sha }) {
const { data } = useQuery({
@@ -15,7 +25,32 @@ export function CommitChecks({ repo, sha }) {
const runs = (data?.runs ?? []).filter((run) => run.sha === sha);
if (runs.length === 0) return null;
- return runs.map((run) => (
-
- ));
+ const latest = data?.bySha.get(sha) ?? [];
+ const summary = summarize(latest);
+ const look = checkLook(summary?.status);
+
+ return (
+
+ {summary && (
+
+
+
+ {summaryWords(summary, latest)}
+
+
+ )}
+ {runs.map((run) => (
+
+ ))}
+
+ );
}
diff --git a/packages/git-ui/src/components/molecules/dir-table.jsx b/packages/git-ui/src/components/molecules/dir-table.jsx
new file mode 100644
index 0000000..1d0beb9
--- /dev/null
+++ b/packages/git-ui/src/components/molecules/dir-table.jsx
@@ -0,0 +1,59 @@
+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,
+};
+
+/**
+ * A directory's entries with their sizes, for the reading pane of a directory
+ * that says nothing about itself. The pane beside it navigates; this says how
+ * big each of those entries is, which the narrow pane has no room for.
+ */
+export function DirTable({ repo, refName, path, entries }) {
+ return (
+
+ );
+}
diff --git a/packages/git-ui/src/components/molecules/language-bar.jsx b/packages/git-ui/src/components/molecules/language-bar.jsx
index 2eb29bd..69d6eb7 100644
--- a/packages/git-ui/src/components/molecules/language-bar.jsx
+++ b/packages/git-ui/src/components/molecules/language-bar.jsx
@@ -1,56 +1,96 @@
-import { useState } from 'react';
+import { useRef, useState } from 'react';
import { percent } from '#/lib/languages.js';
+/** Ticks across the whole scale. Each one is a share of the repository. */
+const TICKS = 72;
+
/**
- * What a repository is written in: one bar of the languages by share, with
- * the one under the pointer named in a chip below it.
+ * What a repository is written in, as a row of ticks read like a gauge: each
+ * language holds the run of ticks its share earns, and the one under the
+ * pointer names itself in a chip below.
+ *
+ * A language too small for a whole tick still gets one, so a listed language
+ * is always visible. The chip is drawn over what follows rather than in the
+ * flow, so the row keeps its height and nothing moves as the pointer crosses
+ * it.
*
- * The chip is drawn over what follows rather than in the flow, so the header
- * keeps its height and nothing moves as the pointer crosses the bar. Each
- * segment names itself, which is what a screen reader reads in place of the
- * chip a pointer brings up.
+ * The row answers the pointer, not the ticks: which language is under it
+ * comes from how far across the row it is. Ticks are 2px with air between
+ * them, so asking each tick would leave the gaps answering for nobody, and
+ * crossing one run would blink the chip off and on again.
*/
export function LanguageBar({ languages }) {
const [hovered, setHovered] = useState(null);
+ const row = useRef(null);
+
+ // Hand out the scale, one language at a time, and let the last one take
+ // whatever rounding left over so the row always ends full. Each tick
+ // carries its own place in the row, which is what names it to React.
+ /** @type {{id: number, language: {name: string, color: string}, index: number}[]} */
+ const ticks = [];
+ languages.forEach((language, index) => {
+ const last = index === languages.length - 1;
+ const count = last
+ ? TICKS - ticks.length
+ : Math.max(1, Math.round((language.share / 100) * TICKS));
+ for (let tick = 0; tick < count; tick++) {
+ ticks.push({ id: ticks.length, language, index });
+ }
+ });
+
+ const words = languages
+ .map((language) => `${language.name} ${percent(language.share)}`)
+ .join(', ');
- /**
- * The middle of a segment, as a percent across the bar, which is where its
- * chip points. Near an end the chip would hang off the bar, so it stops
- * short of both.
- * @param {number} index
- */
- const centerOf = (index) => {
- let start = 0;
- for (let i = 0; i < index; i++) start += languages[i].share;
- return Math.min(90, Math.max(10, start + languages[index].share / 2));
+ /** Which language the pointer stands over, by how far across the row it is. */
+ const track = (event) => {
+ const box = row.current?.getBoundingClientRect();
+ if (!box || box.width === 0) return;
+ const across = (event.clientX - box.left) / box.width;
+ const at = Math.floor(across * TICKS);
+ setHovered(
+ ticks[Math.min(ticks.length - 1, Math.max(0, at))]?.index ?? null,
+ );
};
return (
-
-
- {languages.map((language, index) => (
- setHovered(index)}
- onMouseLeave={() => setHovered(null)}
- />
- ))}
+
+ {/* The ticks stand a few pixels tall, which is a hard thing to keep a
+ pointer inside. The reach is taller than they are, and pulled back
+ out of the layout so nothing below it moves. */}
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: the row inside carries the reading; this only widens the reach for a pointer */}
+
setHovered(null)}
+ >
+ {/* The ticks keep their own width and spread across whatever room the
+ row has, which is what makes the scale read as an instrument
+ rather than as a filled bar. */}
+
{hovered !== null && (
-
+
diff --git a/packages/git-ui/src/components/molecules/latest-commit.jsx b/packages/git-ui/src/components/molecules/latest-commit.jsx
index d553a30..818a20f 100644
--- a/packages/git-ui/src/components/molecules/latest-commit.jsx
+++ b/packages/git-ui/src/components/molecules/latest-commit.jsx
@@ -1,16 +1,17 @@
import { HistoryIcon } from 'lucide-react';
import { Link } from '#/components/atoms/link.jsx';
import { AuthorLine } from '#/components/molecules/author-line.jsx';
-import { CheckStatus } from '#/components/molecules/check-status.jsx';
+import { RefCheckStatus } from '#/components/molecules/ref-check-status.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.
+ * it, what it said, how its checks ended, 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+".
+ * repository. A walk that hit its ceiling reads as "N+". It is the only route
+ * to the history from this page.
*/
export function LatestCommit({ repo, refName, commit, count, truncated }) {
const subject = commit.message.split('\n')[0];
@@ -26,7 +27,7 @@ export function LatestCommit({ repo, refName, commit, count, truncated }) {
>
{subject}
-
+
-
+ >
)}
-
+
);
}
diff --git a/packages/git-ui/src/components/molecules/ref-check-status.jsx b/packages/git-ui/src/components/molecules/ref-check-status.jsx
new file mode 100644
index 0000000..0e0d0a5
--- /dev/null
+++ b/packages/git-ui/src/components/molecules/ref-check-status.jsx
@@ -0,0 +1,36 @@
+import { useQuery } from '@tanstack/react-query';
+import { CheckStatus } from '#/components/molecules/check-status.jsx';
+import { loadChecks, loadRefChecks, summarize } from '#/lib/checks.js';
+
+/**
+ * The badge for the commit at the tip of a ref. The runner keeps a record
+ * naming the latest run of each workflow for one ref, so this costs one
+ * record read whatever the history's length. The run listing behind the other
+ * badges reads a bounded number of pages, and a repository with more runs
+ * than that loses its oldest badges.
+ *
+ * Both sources are filtered to the tip commit. A ref whose newest run is
+ * against an earlier commit shows no badge, rather than reporting another
+ * commit's result beside this one's sha.
+ */
+export function RefCheckStatus({ repo, refName, sha, className }) {
+ const { data } = useQuery({
+ queryKey: ['ref-checks', repo, refName],
+ queryFn: () => loadRefChecks(repo, refName),
+ });
+ const { data: listing } = useQuery({
+ queryKey: ['checks', repo],
+ queryFn: () => loadChecks(repo),
+ });
+
+ const named = (data?.runs ?? []).filter((run) => run.sha === sha);
+ const latest = named.length > 0 ? named : (listing?.bySha.get(sha) ?? []);
+
+ return (
+
+ );
+}
diff --git a/packages/git-ui/src/components/molecules/repo-gauges.jsx b/packages/git-ui/src/components/molecules/repo-gauges.jsx
new file mode 100644
index 0000000..123d8c1
--- /dev/null
+++ b/packages/git-ui/src/components/molecules/repo-gauges.jsx
@@ -0,0 +1,59 @@
+import { useQuery } from '@tanstack/react-query';
+import { ActivityTicks } from '#/components/molecules/activity-ticks.jsx';
+import { LanguageBar } from '#/components/molecules/language-bar.jsx';
+import { ACTIVITY_WEEKS } from '#/lib/activity.js';
+import { account, isEmpty, readerFor, repoRecord } from '#/lib/git.js';
+import { languageShares, percent } from '#/lib/languages.js';
+import { useHistory } from '#/lib/source.js';
+
+/**
+ * Two readings of the repository as a whole: what it is written in, and how
+ * lately it was worked on. Both stand under the file pane, so they keep the
+ * reader's company whatever file or directory is open.
+ *
+ * Each walk reads objects the page has already downloaded, and both share
+ * their keys with the pages that want the same answers, so a gauge costs no
+ * request of its own.
+ */
+export function RepoGauges({ repo, refName }) {
+ const record = repoRecord(repo);
+ const asked = Boolean(record) && !isEmpty(record);
+
+ const { data: languages } = useQuery({
+ queryKey: ['languages', repo, refName],
+ enabled: asked,
+ queryFn: async () => {
+ const listing = await readerFor(repo).listFiles(
+ account.did,
+ repo,
+ refName,
+ );
+ return languageShares(listing?.files ?? []);
+ },
+ });
+
+ const { data: history } = useHistory(repo, refName);
+
+ if (!languages?.length && !history?.dates?.length) return null;
+
+ return (
+
+ );
+}
diff --git a/packages/git-ui/src/components/molecules/repo-list-skeleton.jsx b/packages/git-ui/src/components/molecules/repo-list-skeleton.jsx
new file mode 100644
index 0000000..21bd039
--- /dev/null
+++ b/packages/git-ui/src/components/molecules/repo-list-skeleton.jsx
@@ -0,0 +1,27 @@
+import { Skeleton } from '#/components/atoms/skeleton.jsx';
+
+/** One cell's shape: a name line, a description, a line of readings. */
+function RepoCellSkeleton() {
+ return (
+
+
+
+
+
+ );
+}
+
+/**
+ * The repository grid, before any records arrive. Only a first visit sees it;
+ * a revisit paints the cached rows instead.
+ */
+export function RepoListSkeleton() {
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/packages/git-ui/src/components/molecules/page-skeleton.jsx b/packages/git-ui/src/components/molecules/repo-page-skeleton.jsx
similarity index 54%
rename from packages/git-ui/src/components/molecules/page-skeleton.jsx
rename to packages/git-ui/src/components/molecules/repo-page-skeleton.jsx
index 9505f92..0f68b9b 100644
--- a/packages/git-ui/src/components/molecules/page-skeleton.jsx
+++ b/packages/git-ui/src/components/molecules/repo-page-skeleton.jsx
@@ -1,46 +1,16 @@
-import { Card } from '#/components/atoms/card.jsx';
import { Skeleton } from '#/components/atoms/skeleton.jsx';
-/** One repository card's shape: a name line, a description, a fact line. */
-function RepoCardSkeleton() {
- return (
-
-
-
-
-
- );
-}
-
/**
- * The repository list, before any records arrive. Only a first visit sees
- * it; a revisit paints the cached rows instead.
- */
-export function RepoListSkeleton() {
- return (
-
-
-
-
-
- );
-}
-
-/**
- * A repository screen, shaped like the tree page it stands in for:
- * breadcrumb line, toolbar, a file table, a README block. Shown while
- * discovery still names the account, so the real page has nothing to say
- * yet.
+ * A repository screen, shaped like the tree page it stands in for: the ref
+ * and clone controls, a file table, a README block. Shown while discovery
+ * still names the account, so the real page has nothing to say yet.
*/
export function RepoPageSkeleton() {
return (
<>
-
-
-
-
+
-
+
diff --git a/packages/git-ui/src/components/molecules/repo-tabs.jsx b/packages/git-ui/src/components/molecules/repo-tabs.jsx
new file mode 100644
index 0000000..d0be777
--- /dev/null
+++ b/packages/git-ui/src/components/molecules/repo-tabs.jsx
@@ -0,0 +1,91 @@
+import { linkedPackagesOf, runnerOf } from '@pdsjs/git/rules';
+import { useQuery } from '@tanstack/react-query';
+import {
+ CircleCheckIcon,
+ CodeIcon,
+ GitCommitVerticalIcon,
+ PackageIcon,
+} from 'lucide-react';
+import { Link } from '#/components/atoms/link.jsx';
+import { repoConfig } from '#/lib/git.js';
+import { cn } from '#/lib/utils.js';
+
+/** What each section is called, for the tabs and for the heading above them. */
+export const SECTION_TITLES = {
+ code: 'Code',
+ commits: 'Commits',
+ checks: 'Checks',
+ packages: 'Packages',
+};
+
+const ICONS = {
+ code: CodeIcon,
+ commits: GitCommitVerticalIcon,
+ checks: CircleCheckIcon,
+ packages: PackageIcon,
+};
+
+/**
+ * The sections of one repository, on a band of their own between the heading
+ * and whatever the open section puts above its list. A page that reached a
+ * dead end before, a run or a package or a file, carries the way back to
+ * everything else.
+ *
+ * Which sections exist comes from the repository's config record, one cheap
+ * read that is already in hand from elsewhere in the app. Reading the runner's
+ * check listing would answer the same question and cost a listing walk, which
+ * a reader looking at a package should not pay for.
+ */
+export function RepoTabs({ repo, active }) {
+ const { data: config } = useQuery({
+ queryKey: ['config', repo],
+ queryFn: () => repoConfig(repo),
+ staleTime: Number.POSITIVE_INFINITY,
+ });
+ const packages = linkedPackagesOf(config);
+ const base = `/${encodeURIComponent(repo)}`;
+
+ const tabs = [
+ { key: 'code', href: base },
+ { key: 'commits', href: `${base}/commits` },
+ ...(runnerOf(config) ? [{ key: 'checks', href: `${base}/checks` }] : []),
+ ...(packages.length > 0
+ ? [{ key: 'packages', href: `${base}/packages`, count: packages.length }]
+ : []),
+ ];
+
+ return (
+
+ );
+}
diff --git a/packages/git-ui/src/components/molecules/source-panes.jsx b/packages/git-ui/src/components/molecules/source-panes.jsx
new file mode 100644
index 0000000..2e97bf8
--- /dev/null
+++ b/packages/git-ui/src/components/molecules/source-panes.jsx
@@ -0,0 +1,70 @@
+import { CornerLeftUpIcon } from 'lucide-react';
+import { Link } from '#/components/atoms/link.jsx';
+import { Skeleton } from '#/components/atoms/skeleton.jsx';
+import { RepoGauges } from '#/components/molecules/repo-gauges.jsx';
+import { TreeList } from '#/components/molecules/tree-list.jsx';
+import { parentOf, useDirectory } from '#/lib/source.js';
+
+/**
+ * The source view: one directory down the left, whatever the reader opened
+ * down the right. Opening a file keeps its neighbours in view, which is what
+ * makes reading a repository feel like reading a checkout.
+ *
+ * `dirPath` is the directory the pane lists. A directory's own page lists
+ * itself; a file's page lists the directory holding it, with that file marked.
+ * The pane keeps its own scroll, so a long listing and a long file each move
+ * on their own.
+ */
+export function SourcePanes({
+ repo,
+ refName,
+ dirPath,
+ selected = '',
+ children,
+}) {
+ const { data: entries } = useDirectory(repo, refName, dirPath);
+ const up = dirPath
+ ? `/${encodeURIComponent(repo)}/tree/${encodeURI(refName)}${
+ parentOf(dirPath) ? `/${encodeURI(parentOf(dirPath))}` : ''
+ }`
+ : '';
+
+ return (
+ // The panes fill what the window has left and each takes its own scroll,
+ // so a long listing and a long file move past each other.
+
+
+
{children}
+
+ );
+}
diff --git a/packages/git-ui/src/components/molecules/tree-list.jsx b/packages/git-ui/src/components/molecules/tree-list.jsx
index bcef649..97f9ae5 100644
--- a/packages/git-ui/src/components/molecules/tree-list.jsx
+++ b/packages/git-ui/src/components/molecules/tree-list.jsx
@@ -1,6 +1,5 @@
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 = {
@@ -10,15 +9,21 @@ const ICONS = {
submodule: PackageIcon,
};
-/** One directory's entries, directories first, then files, both by name. */
-export function TreeList({ repo, refName, path, entries }) {
+/**
+ * One directory's entries, directories first, then files, both by name.
+ *
+ * `selected` is the path of the file open beside this listing, which reads as
+ * the row a reader is standing on.
+ */
+export function TreeList({ repo, refName, path, entries, selected = '' }) {
return (
-
{entry.type === 'submodule' ? (
-
+
{row}
) : (
{row}
diff --git a/packages/git-ui/src/lib/activity.js b/packages/git-ui/src/lib/activity.js
new file mode 100644
index 0000000..2463d96
--- /dev/null
+++ b/packages/git-ui/src/lib/activity.js
@@ -0,0 +1,8 @@
+/** How far back the commit-activity gauge reads, and the bucket it counts in. */
+
+export const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
+
+/** Weeks the gauge covers, newest at the right. */
+export const ACTIVITY_WEEKS = 24;
+
+export const ACTIVITY_WINDOW_MS = ACTIVITY_WEEKS * WEEK_MS;
diff --git a/packages/git-ui/src/lib/checks.js b/packages/git-ui/src/lib/checks.js
index d909381..560da04 100644
--- a/packages/git-ui/src/lib/checks.js
+++ b/packages/git-ui/src/lib/checks.js
@@ -13,10 +13,18 @@
// badge, and none of them is an error worth showing.
import { runnerOf } from '@pdsjs/git/rules';
+import { latestCheckKey } from '@pdsjs/git-ci/spec';
import { CheckIcon, CircleDotIcon, XIcon } from 'lucide-react';
-import { account, didDocumentUrl, repoConfig } from './git.js';
+import {
+ account,
+ didDocumentUrl,
+ repoConfig,
+ repoRecord,
+ shortRef,
+} from './git.js';
const CHECK_COLLECTION = 'dev.pdsjs.git.check';
+const LATEST_CHECK_COLLECTION = 'dev.pdsjs.git.latestCheck';
const REPO_COLLECTION = 'dev.pdsjs.git.repo';
/**
@@ -38,6 +46,7 @@ const MAX_PAGES = 3;
* @property {string} sha
* @property {string} status
* @property {string} startedAt
+ * @property {string} [rkey] - the record key, which addresses the run page
* @property {string} [ref]
* @property {string} [workflow]
* @property {string} [finishedAt]
@@ -45,6 +54,14 @@ const MAX_PAGES = 3;
* @property {{ref?: {$link?: string}}} [logs]
*/
+/**
+ * How a whole commit reads: one status for the badge, and the count behind it.
+ * @typedef {Object} CheckSummary
+ * @property {string} status - the status the badge shows
+ * @property {number} passed - workflows that succeeded
+ * @property {number} total - workflows that reported
+ */
+
/** How each check status reads, and the colour it reads in. */
export const CHECK_LOOKS = {
success: { label: 'passed', className: 'text-success', Icon: CheckIcon },
@@ -65,6 +82,14 @@ export const CHECK_LOOKS = {
export const checkLook = (status) =>
CHECK_LOOKS[/** @type {keyof CHECK_LOOKS} */ (status)] ?? CHECK_LOOKS.running;
+/**
+ * What a run's workflow is called. The field is optional in the lexicon, and a
+ * runner that publishes one workflow may leave it out.
+ * @param {{workflow?: string}} check
+ * @returns {string}
+ */
+export const workflowOf = (check) => check.workflow || 'ci';
+
/**
* An elapsed time in the largest units that stay readable.
* @param {number} ms
@@ -97,7 +122,7 @@ export function runDuration(check) {
* @property {string} service - the runner's PDS origin
*/
-/** @typedef {{runner: CheckRunner|null, runs: CheckRun[], bySha: Map}} RepoChecks */
+/** @typedef {{runner: CheckRunner|null, runs: CheckRun[], bySha: Map}} RepoChecks */
/**
* This repository's runs, newest first, from the runner's raw listing. The
@@ -112,35 +137,119 @@ export function runsFor(records, subjectUri) {
/** @type {CheckRun[]} */
const runs = [];
for (const entry of records) {
- const value = /** @type {{value?: unknown}|null} */ (entry)?.value;
+ const row = /** @type {{value?: unknown, uri?: unknown}|null} */ (entry);
const check = /** @type {CheckRun & {subject?: {uri?: unknown}}} */ (
- value ?? {}
+ row?.value ?? {}
);
if (check.subject?.uri !== subjectUri) continue;
if (typeof check.sha !== 'string' || typeof check.startedAt !== 'string') {
continue;
}
- runs.push(check);
+ const rkey = String(row?.uri ?? '')
+ .split('/')
+ .pop();
+ runs.push(rkey ? { ...check, rkey } : check);
}
return runs.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
}
/**
- * The newest run per commit, which is what a one-icon badge shows. A commit
- * checked more than once, because someone asked for a re-run, keeps its most
- * recent result here.
+ * The newest run of each workflow. A workflow asked for again reports twice,
+ * and only the most recent of those says where that workflow stands.
* @param {CheckRun[]} runs - newest first
- * @returns {Map}
+ * @returns {CheckRun[]}
+ */
+export function latestPerWorkflow(runs) {
+ /** @type {CheckRun[]} */
+ const latest = [];
+ for (const run of runs) {
+ if (latest.some((seen) => workflowOf(seen) === workflowOf(run))) continue;
+ latest.push(run);
+ }
+ return latest;
+}
+
+/**
+ * The runs of each commit, newest commit first. A commit's group holds every
+ * run against it, re-runs included, in the order the listing gave them.
+ * @param {CheckRun[]} runs - newest first
+ * @returns {{sha: string, runs: CheckRun[]}[]}
+ */
+export function groupBySha(runs) {
+ /** @type {Map} */
+ const groups = new Map();
+ for (const run of runs) {
+ const group = groups.get(run.sha);
+ if (group) group.runs.push(run);
+ else groups.set(run.sha, { sha: run.sha, runs: [run] });
+ }
+ return [...groups.values()];
+}
+
+/**
+ * The newest run of each workflow, per commit. One commit may be checked by
+ * several workflows, and each of them reports separately.
+ * @param {CheckRun[]} runs - newest first
+ * @returns {Map}
*/
export function latestBySha(runs) {
- /** @type {Map} */
+ /** @type {Map} */
const bySha = new Map();
- for (const run of runs) {
- if (!bySha.has(run.sha)) bySha.set(run.sha, run);
+ for (const group of groupBySha(runs)) {
+ bySha.set(group.sha, latestPerWorkflow(group.runs));
}
return bySha;
}
+/**
+ * One status for a set of runs. A single failure decides the whole set: a
+ * commit whose test workflow failed is a failed commit, whatever its other
+ * workflows say. An unfinished run reads as running, and only a set where
+ * every workflow succeeded reads as passed.
+ * @param {CheckRun[]} runs - the latest run of each workflow
+ * @returns {CheckSummary|null} null for a commit no workflow reported on
+ */
+export function summarize(runs) {
+ if (runs.length === 0) return null;
+ const passed = runs.filter((run) => run.status === 'success').length;
+ const failed = runs.some(
+ (run) => run.status === 'failure' || run.status === 'error',
+ );
+ const status = failed
+ ? (runs.find((run) => run.status === 'error')?.status ?? 'failure')
+ : passed === runs.length
+ ? 'success'
+ : 'running';
+ return { status, passed, total: runs.length };
+}
+
+/**
+ * How a summary reads in words, for the badge's title and the commit page's
+ * heading. A single workflow names itself; several are counted.
+ * @param {CheckSummary} summary
+ * @param {CheckRun[]} runs - the runs the summary counts
+ * @returns {string}
+ */
+export function summaryWords(summary, runs) {
+ if (runs.length === 1) {
+ return `${workflowOf(runs[0])} ${checkLook(summary.status).label}`;
+ }
+ if (summary.status === 'running') {
+ return `${summary.passed} of ${summary.total} workflows passed, the rest running`;
+ }
+ return `${summary.passed} of ${summary.total} workflows passed`;
+}
+
+/**
+ * Every workflow that reported, in the order a reader meets them, for the
+ * filter above a run listing.
+ * @param {CheckRun[]} runs
+ * @returns {string[]}
+ */
+export function workflowNames(runs) {
+ return [...new Set(runs.map(workflowOf))];
+}
+
/**
* Where a finished check's log reads from: the blob on the runner's PDS.
* @param {CheckRunner|null|undefined} runner
@@ -195,6 +304,98 @@ function pdsEndpoint(doc) {
).replace(/\/$/, '');
}
+/**
+ * One runner lookup per repository, kept once it is read. Two screens want it,
+ * the run listing and the ref badge, and it changes only when the repository's
+ * owner names another runner.
+ * @type {Map>}
+ */
+const runners = new Map();
+
+/**
+ * The runner a repository names, and the PDS that answers for it. Null where
+ * the repository names no runner, or the runner's DID does not resolve.
+ * @param {string} repo - repository name, the record rkey
+ * @returns {Promise}
+ */
+export function repoRunner(repo) {
+ let pending = runners.get(repo);
+ if (!pending) {
+ pending = (async () => {
+ try {
+ const did = runnerOf(await repoConfig(repo));
+ if (!did) return null;
+ const doc = await (await fetch(didDocumentUrl(did))).json();
+ const service = pdsEndpoint(doc);
+ return service ? { did, service } : null;
+ } catch {
+ return null;
+ }
+ })();
+ runners.set(repo, pending);
+ }
+ return pending;
+}
+
+/**
+ * The full name of a ref the page names in short form. The repository record
+ * holds both, so this needs no round trip. A name that matches no ref reads as
+ * a branch, which is what a URL naming a deleted branch means.
+ * @param {string} repo
+ * @param {string} refName - short form, e.g. main
+ * @returns {string}
+ */
+export function fullRefName(repo, refName) {
+ const refs = /** @type {{refs?: {name: string}[]}|null} */ (repoRecord(repo))
+ ?.refs;
+ const found = (refs ?? []).find((ref) => shortRef(ref.name) === refName);
+ return found?.name ?? `refs/heads/${refName}`;
+}
+
+/**
+ * The latest run of each workflow for one ref, from the record the runner
+ * keeps for exactly this question. It costs one getRecord, where the listing
+ * below costs a page walk, and it stays right for a repository whose history
+ * runs past that walk's ceiling.
+ *
+ * The entries carry no steps and no log. They name the check record that
+ * holds both, which is what the rkey addresses.
+ * @param {string} repo - repository name, the record rkey
+ * @param {string} refName - short form, e.g. main
+ * @returns {Promise<{runner: CheckRunner|null, runs: CheckRun[]}>}
+ */
+export async function loadRefChecks(repo, refName) {
+ const runner = await repoRunner(repo);
+ if (!runner) return { runner: null, runs: [] };
+ try {
+ const params = new URLSearchParams({
+ repo: runner.did,
+ collection: LATEST_CHECK_COLLECTION,
+ rkey: latestCheckKey(account.did, repo, fullRefName(repo, refName)),
+ });
+ const res = await fetch(
+ `${runner.service}/xrpc/com.atproto.repo.getRecord?${params}`,
+ );
+ if (!res.ok) return { runner, runs: [] };
+ const value = /** @type {{value?: {checks?: unknown[]}}} */ (
+ await res.json()
+ ).value;
+ /** @type {CheckRun[]} */
+ const runs = [];
+ for (const entry of value?.checks ?? []) {
+ const check = /** @type {CheckRun & {check?: {uri?: unknown}}} */ (entry);
+ if (typeof check.sha !== 'string') continue;
+ const rkey = String(check.check?.uri ?? '')
+ .split('/')
+ .pop();
+ runs.push(rkey ? { ...check, rkey } : check);
+ }
+ return { runner, runs };
+ } catch {
+ return { runner, runs: [] };
+ }
+}
+
/**
* @param {string} repo - repository name, the record rkey
* @returns {Promise}
@@ -203,12 +404,9 @@ export async function loadChecks(repo) {
/** @type {RepoChecks} */
const none = { runner: null, runs: [], bySha: new Map() };
try {
- const did = runnerOf(await repoConfig(repo));
- if (!did) return none;
-
- const doc = await (await fetch(didDocumentUrl(did))).json();
- const service = pdsEndpoint(doc);
- if (!service) return none;
+ const runner = await repoRunner(repo);
+ if (!runner) return none;
+ const { did, service } = runner;
/** @type {unknown[]} */
const records = [];
@@ -234,7 +432,7 @@ export async function loadChecks(repo) {
const subjectUri = `at://${account.did}/${REPO_COLLECTION}/${repo}`;
const runs = runsFor(records, subjectUri);
- return { runner: { did, service }, runs, bySha: latestBySha(runs) };
+ return { runner, runs, bySha: latestBySha(runs) };
} catch {
return none;
}
diff --git a/packages/git-ui/src/lib/git.js b/packages/git-ui/src/lib/git.js
index 6e23391..2af6943 100644
--- a/packages/git-ui/src/lib/git.js
+++ b/packages/git-ui/src/lib/git.js
@@ -395,6 +395,45 @@ export function repoConfig(name) {
return pending;
}
+/**
+ * @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 - repository name, the record rkey
+ * @param {string[]} refs - full ref names, e.g. refs/heads/main
+ * @param {number} [limit] - commits to walk per ref
+ * @returns {Promise