From 2d7efbb7e541f76a34e991cafb9800fdbf4e6e13 Mon Sep 17 00:00:00 2001 From: Chad Miller Date: Sun, 23 Aug 2026 15:29:17 -0700 Subject: [PATCH] feat(git-ui): a repository reads as one instrument The browser now holds the window and scrolls inside itself. One bar names where the reader is, a second names the repository's sections, and a third carries whatever the open section is looking at, so no single row carries three jobs. Source and a commit each read as two panes: what to open down the left, what is open down the right. Opening a file keeps its neighbours in view. Checks group by the commit they ran against, headed by that commit's subject line rather than its sha, with a filter naming each workflow and how many runs it holds. A run has a page of its own, addressed by the record key the runner wrote it under, so one can be linked to. The badge beside a commit keeps the newest run of each workflow and folds them: a single failure decides the commit, whatever its other workflows say. Before this the newest run of any workflow spoke for all of them, so a publish that finished later hid a failed test. Sections come from the repository's config record, one cheap read, so every page carries the way back to every other. Packages, checks, files and an empty repository were each a dead end. Surfaces are panels divided by rules rather than tiles: one radius, no filled controls, identifiers in mono. Two gauges under the file pane read the repository as a whole, what it is written in and how lately it was worked on, both from walks the page already made. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Gp5VgVe393AeJL5jKARqR3 --- packages/git-ui/package.json | 1 + packages/git-ui/src/app.jsx | 223 ++++++++----- .../components/molecules/account-panel.jsx | 86 +++++ .../components/molecules/activity-ticks.jsx | 55 ++++ .../components/molecules/check-disclosure.jsx | 52 +++ .../src/components/molecules/check-filter.jsx | 43 +++ .../src/components/molecules/check-group.jsx | 77 +++++ .../src/components/molecules/check-log.jsx | 2 +- .../src/components/molecules/check-row.jsx | 36 ++ .../src/components/molecules/check-run.jsx | 133 +++----- .../src/components/molecules/check-status.jsx | 29 +- .../src/components/molecules/check-steps.jsx | 44 +++ .../src/components/molecules/code-menu.jsx | 13 +- .../molecules/commit-check-status.jsx | 24 ++ .../components/molecules/commit-checks.jsx | 51 ++- .../src/components/molecules/dir-table.jsx | 59 ++++ .../src/components/molecules/language-bar.jsx | 118 ++++--- .../components/molecules/latest-commit.jsx | 9 +- .../src/components/molecules/package-card.jsx | 15 +- .../components/molecules/ref-check-status.jsx | 36 ++ .../src/components/molecules/repo-gauges.jsx | 59 ++++ .../molecules/repo-list-skeleton.jsx | 27 ++ ...ge-skeleton.jsx => repo-page-skeleton.jsx} | 40 +-- .../src/components/molecules/repo-tabs.jsx | 91 ++++++ .../src/components/molecules/source-panes.jsx | 70 ++++ .../src/components/molecules/tree-list.jsx | 33 +- packages/git-ui/src/lib/activity.js | 8 + packages/git-ui/src/lib/checks.js | 236 ++++++++++++-- packages/git-ui/src/lib/git.js | 39 +++ packages/git-ui/src/lib/source.js | 88 +++++ packages/git-ui/src/pages/check.jsx | 48 +++ packages/git-ui/src/pages/checks.jsx | 109 +++++-- packages/git-ui/src/pages/commit.jsx | 138 ++++---- packages/git-ui/src/pages/commits.jsx | 96 +++--- packages/git-ui/src/pages/file.jsx | 93 ++++-- packages/git-ui/src/pages/packages.jsx | 21 +- packages/git-ui/src/pages/repos.jsx | 124 ++++--- packages/git-ui/src/pages/tree.jsx | 307 ++++++------------ packages/git-ui/src/style.css | 11 +- packages/git-ui/test/checks.test.js | 249 +++++++++++++- pnpm-lock.yaml | 3 + 41 files changed, 2237 insertions(+), 759 deletions(-) create mode 100644 packages/git-ui/src/components/molecules/account-panel.jsx create mode 100644 packages/git-ui/src/components/molecules/activity-ticks.jsx create mode 100644 packages/git-ui/src/components/molecules/check-disclosure.jsx create mode 100644 packages/git-ui/src/components/molecules/check-filter.jsx create mode 100644 packages/git-ui/src/components/molecules/check-group.jsx create mode 100644 packages/git-ui/src/components/molecules/check-row.jsx create mode 100644 packages/git-ui/src/components/molecules/check-steps.jsx create mode 100644 packages/git-ui/src/components/molecules/commit-check-status.jsx create mode 100644 packages/git-ui/src/components/molecules/dir-table.jsx create mode 100644 packages/git-ui/src/components/molecules/ref-check-status.jsx create mode 100644 packages/git-ui/src/components/molecules/repo-gauges.jsx create mode 100644 packages/git-ui/src/components/molecules/repo-list-skeleton.jsx rename packages/git-ui/src/components/molecules/{page-skeleton.jsx => repo-page-skeleton.jsx} (54%) create mode 100644 packages/git-ui/src/components/molecules/repo-tabs.jsx create mode 100644 packages/git-ui/src/components/molecules/source-panes.jsx create mode 100644 packages/git-ui/src/lib/activity.js create mode 100644 packages/git-ui/src/lib/source.js create mode 100644 packages/git-ui/src/pages/check.jsx 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 && ( +
+ + + {initial(owner()?.displayName || accountInfo.handle)} + + } + className="size-7" + /> + +
+ )} +
- {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. - 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 ( +
+
+
+ + {initial(profile?.displayName || account.handle)} + + } + className="size-10 shrink-0" + /> +
+
+ {profile?.displayName || account.handle} +
+
+ {account.handle} +
+
+
+ + {profile?.description && ( +

+ {profile.description} +

+ )} + +
+ + {account.did} + + + + {account.pds.replace(/^https?:\/\//, '')} + + + + {(repos ?? []).length} · {bytes(total)} + +
+
+ + +
+ ); +} 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 ( +
+ {weeks.map((week) => ( + 0 ? 'bg-key' : 'h-[2px] bg-border', + )} + style={ + week.count > 0 + ? { height: `${Math.max(20, (week.count / busiest) * 100)}%` } + : undefined + } + /> + ))} +
+ ); +} 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 ( +
  • +
    +
    +
      + {group.runs.map((run) => ( +
    • + {/* A record read without its key has no page to open. */} + {run.rkey ? ( + + + + ) : ( + + )} +
    • + ))} +
    +
  • + ); +} diff --git a/packages/git-ui/src/components/molecules/check-log.jsx b/packages/git-ui/src/components/molecules/check-log.jsx index f23353f..caa29f0 100644 --- a/packages/git-ui/src/components/molecules/check-log.jsx +++ b/packages/git-ui/src/components/molecules/check-log.jsx @@ -37,7 +37,7 @@ export function CheckLog({ runner, check }) { return ( <> -
    +      
             {data || 'The log is empty.'}
           
    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 ( -
    - - {step.name} - - {typeof step.durationMs === 'number' && ( - - {duration(step.durationMs)} - - )} - - {failed ? `exit ${step.exitCode}` : 'ok'} - +
    +
    + {label} +
    +
    + {children} +
    ); } /** - * 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 ( - -
    - - - {check.workflow ?? 'ci'} {look.label} - - {check.ref && ( - - {shortRef(check.ref)} +
    +
    +
    + +

    + {workflowOf(check)} +

    + + {look.label} - )} - {commitHref && ( - - {check.sha.slice(0, 8)} - - )} - - {took && {took}} - {new Date(check.startedAt).toLocaleString()} - +
    + +
    + + {commitHref ? ( + + {check.sha.slice(0, 8)} + + ) : ( + {check.sha.slice(0, 8)} + )} + + {check.ref && ( + + {shortRef(check.ref)} + + )} + + {took ?? 'running'} + + + {new Date(check.startedAt).toLocaleString()} + +
    {steps.length > 0 && ( -
    - {steps.map((step) => ( - - ))} +
    +
    )} -
    - - {open && ( -
    - -
    - )} + {/* 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 ( - ); 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 ( +
    + {steps.map((step) => { + const failed = step.exitCode !== 0; + return ( +
    + + {step.name} + + {typeof step.durationMs === 'number' && ( + + {duration(step.durationMs)} + + )} + + {failed ? `exit ${step.exitCode}` : 'ok'} + +
    + ); + })} +
    + ); +} 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 ( +
      + {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/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. */} +
    + {ticks.map(({ id, language, index }) => ( +
    {hovered !== null && ( -