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 && ( -