diff --git a/.oxlintrc.json b/.oxlintrc.json index 007ae08..6f824ea 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -8,11 +8,20 @@ }, "rules": { // Vite's CSS entry point is a side-effect import; there is nothing to assign. - "import/no-unassigned-import": "off" + "import/no-unassigned-import": "off", + // A component's function is mostly markup, so a line limit meant for logic + // just asks for components to be split where they do not want to be. + "eslint/max-lines-per-function": "off" }, "settings": { "jsx-a11y": { - "polymorphicPropName": "component" + "polymorphicPropName": "component", + // jsx-a11y was written for React, which renames the `for` attribute to + // `htmlFor`. Solid uses the real HTML name, so the rules have to be told + // to look for it. + "attributes": { + "for": ["for", "htmlFor"] + } } }, "ignorePatterns": ["dist", "src-tauri/target"] diff --git a/src/App.tsx b/src/App.tsx index 832fd33..cfc75b9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,12 +1,56 @@ +import { Show, createSignal } from "solid-js"; + +import History from "./components/History"; +import OpenRepository from "./components/OpenRepository"; import RepositoryNav from "./components/RepositoryNav"; import StatusBar from "./components/StatusBar"; +import { openRepository } from "./lib/api"; +import { isGigitError, type OpenedRepository } from "./lib/bindings"; +import { createGraphView } from "./lib/graph"; + +/** What to call a repository: its working tree's folder, or the git directory. */ +function nameOf(repository: OpenedRepository): string { + const path = repository.summary.workdir ?? repository.summary.gitDir; + + return path.replace(/\/+$/u, "").split("/").pop() ?? path; +} + +/** How the current HEAD reads in the title bar. */ +function headOf(repository: OpenedRepository): string { + const head = repository.summary.head; + + switch (head.state) { + case "branch": + return head.shorthand; + case "detached": + return `detached at ${head.id.slice(0, 7)}`; + case "unborn": + return `${head.shorthand} (no commits yet)`; + } +} -/** - * The application shell: a title bar, a repository navigation sidebar, the - * history pane, and a status bar. Everything below is still a placeholder — - * the history pane is filled in once the graph streams from Rust. - */ export default function App() { + const [repository, setRepository] = createSignal(); + const [opening, setOpening] = createSignal(false); + const [error, setError] = createSignal(); + const graph = createGraphView(); + + async function open(path: string) { + setOpening(true); + setError(undefined); + + try { + const opened = await openRepository(path); + setRepository(opened); + await graph.show(opened.id); + } catch (failure) { + setRepository(undefined); + setError(isGigitError(failure) ? failure.message : String(failure)); + } finally { + setOpening(false); + } + } + return ( ); } diff --git a/src/app.css b/src/app.css index be2aa91..8a3f636 100644 --- a/src/app.css +++ b/src/app.css @@ -27,3 +27,69 @@ overflow: hidden; } } + +/* The lane a graph line or dot belongs to. Registered so it is typed as a + number, which is what lets it drive the hue calculation below. */ +@property --lane { + syntax: ""; + inherits: true; + initial-value: 0; +} + +@theme { + /* Must match the constants in GraphLanes.tsx, which builds the SVG paths. */ + --row-height: 28px; +} + +@layer components { + /* Ten lane colours from one formula rather than ten hard-coded values: the + lane index rotates the hue, starting near the seed colour so the graph + belongs to the same palette as everything else. Lightness and chroma + differ between schemes because a colour readable on white is not readable + on near-black. */ + .graph-line, + .graph-dot { + --hue: calc(25 + var(--lane) * 36); + + color: light-dark(oklch(52% 0.17 var(--hue)), oklch(78% 0.15 var(--hue))); + } + + .graph-line { + fill: none; + stroke: currentcolor; + stroke-width: 2; + stroke-linecap: round; + } + + .graph-dot { + fill: currentcolor; + } + + /* A merge reads as a ring, so it is distinguishable from an ordinary commit + without relying on size alone. */ + .graph-dot-merge { + fill: var(--color-surface); + stroke: currentcolor; + stroke-width: 3; + } + + /* Safari drops list semantics when a list is given `list-style: none`, which + Tailwind's preflight does — and Tauri is WKWebView on macOS, so that is the + default here, not a corner case. An empty string marker renders nothing but + leaves the list a list, which beats patching the role back on with ARIA. */ + .history-list { + list-style-type: ""; + } + + /* Rows past the first screenful are skipped until they are scrolled near. + `none` for the width keeps an off-screen row from collapsing horizontally, + which would make the scroll width jump about on a wide graph. + + Using nth-child means no JavaScript decides what is on screen — and the + first rows, the ones that must paint immediately, are deliberately left + out of it. */ + .history-list > li:nth-child(n + 40) { + content-visibility: auto; + contain-intrinsic-size: auto none auto var(--row-height); + } +} diff --git a/src/components/CommitRow.tsx b/src/components/CommitRow.tsx new file mode 100644 index 0000000..b67db2b --- /dev/null +++ b/src/components/CommitRow.tsx @@ -0,0 +1,93 @@ +import { For, Show } from "solid-js"; + +import type { GraphRow, RefBadge } from "../lib/bindings"; +import { absoluteTime, isoTime, relativeTime, shortId } from "../lib/format"; +import GraphLanes from "./GraphLanes"; + +/** What a ref badge is, spoken rather than shown. */ +function describe(badge: RefBadge): string { + switch (badge.kind) { + case "localBranch": + return badge.isHead ? "current branch" : "branch"; + case "remoteBranch": + return "remote branch"; + case "tag": + return "tag"; + case "other": + return "reference"; + } +} + +/** + * One commit. + * + * The row is a button because selecting a commit is an action, and a button + * brings its own role, focus ring and Enter/Space handling. That constrains the + * contents to phrasing content — which is why the ref badges are spans rather + * than a list. + */ +export default function CommitRow(props: { + row: GraphRow; + isSelected: boolean; + onSelect: () => void; + onKeyDown: (event: KeyboardEvent) => void; +}) { + return ( +
  • + +
  • + ); +} diff --git a/src/components/GraphLanes.tsx b/src/components/GraphLanes.tsx new file mode 100644 index 0000000..dae1232 --- /dev/null +++ b/src/components/GraphLanes.tsx @@ -0,0 +1,92 @@ +import { For } from "solid-js"; + +import type { Edge, GraphRow } from "../lib/bindings"; + +/** + * Geometry, in pixels. These match the custom properties in `app.css`; the SVG + * needs real numbers to build paths from, and CSS needs them to size the row. + */ +const LANE_WIDTH = 14; +const ROW_HEIGHT = 28; +const DOT_RADIUS = 4; + +/** The centre of a column. */ +function centreOf(column: number): number { + return column * LANE_WIDTH + LANE_WIDTH / 2; +} + +/** + * One line segment as a path. + * + * Control points sit at the vertical midpoint of the segment, which gives a + * smooth S-curve when the line changes column and collapses to a straight + * vertical line when it does not — so one formula covers every case. + */ +function segment(edge: Edge, commitColumn: number): string { + const middle = ROW_HEIGHT / 2; + + let from: [number, number]; + let to: [number, number]; + + switch (edge.kind) { + case "through": { + from = [centreOf(edge.fromColumn), 0]; + to = [centreOf(edge.toColumn), ROW_HEIGHT]; + break; + } + case "toCommit": { + from = [centreOf(edge.fromColumn), 0]; + to = [centreOf(commitColumn), middle]; + break; + } + case "fromCommit": { + from = [centreOf(commitColumn), middle]; + to = [centreOf(edge.toColumn), ROW_HEIGHT]; + break; + } + } + + const bend = (from[1] + to[1]) / 2; + + return `M ${from[0]} ${from[1]} C ${from[0]} ${bend} ${to[0]} ${bend} ${to[0]} ${to[1]}`; +} + +/** + * The lanes and dot for one row. + * + * Decoration over content that already reads correctly without it, so it is + * hidden from assistive technology entirely. Colours come from the `--lane` + * custom property, which means the whole graph re-themes in CSS with no + * re-render. + */ +export default function GraphLanes(props: { row: GraphRow }) { + const width = () => Math.max(props.row.width, props.row.column + 1) * LANE_WIDTH; + + return ( + + ); +} diff --git a/src/components/History.tsx b/src/components/History.tsx new file mode 100644 index 0000000..98cc2c4 --- /dev/null +++ b/src/components/History.tsx @@ -0,0 +1,127 @@ +import { For, Show, createSignal, onCleanup } from "solid-js"; + +import type { GraphView } from "../lib/graph"; +import CommitRow from "./CommitRow"; + +/** How many rows a Page Up or Page Down jumps. */ +const PAGE = 20; + +/** + * The commit history. + * + * Two things keep this affordable on a repository with tens of thousands of + * commits. Rows arrive in batches as the end of the list comes into view, so + * the DOM only ever holds what has been scrolled to; and rows past the first + * screenful get `content-visibility`, so the browser skips laying out and + * painting the ones that are off screen. Between them there is no need for a + * virtual list library. + */ +export default function History(props: { graph: GraphView }) { + const [selected, setSelected] = createSignal(0); + + let list: HTMLOListElement | undefined; + + /** + * Move the selection and take focus with it, so the keyboard follows. + * + * `next` is a function of the current row rather than an absolute index, and + * the update goes through the functional setter, because Solid batches signal + * writes: reading `selected()` to compute the next value gives a stale answer + * when two key presses land in the same batch, and the second press is lost. + */ + function moveSelection(next: (current: number) => number) { + const last = props.graph.rows().length - 1; + let landed = 0; + + setSelected((current) => { + landed = Math.max(0, Math.min(next(current), last)); + return landed; + }); + + // Found by row number rather than by index into a list of refs, so a jump + // past rows that have not rendered yet still lands somewhere sensible. + const row = list?.querySelector(`[data-row="${landed}"]`); + row?.focus(); + row?.scrollIntoView({ block: "nearest" }); + } + + /** + * Arrow-key navigation. Handled on the row itself rather than on the + * container: the row is the focused, interactive element, so it is the one + * that should be listening. + */ + function onKeyDown(event: KeyboardEvent) { + const moves: Record number> = { + ArrowDown: (current) => current + 1, + ArrowUp: (current) => current - 1, + PageDown: (current) => current + PAGE, + PageUp: (current) => current - PAGE, + Home: () => 0, + // Clamped to the last row, so this does not need to know how many there + // are — which is just as well, since more may still be streaming in. + End: () => Number.MAX_SAFE_INTEGER, + }; + + const move = moves[event.key]; + if (!move) return; + + event.preventDefault(); + moveSelection(move); + } + + /** + * Watch for the end of the list coming into view. The margin asks for more + * before the user actually gets there, so scrolling does not stall. + */ + function watchForEnd(element: HTMLDivElement) { + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + props.graph.loadMore(); + } + }, + { rootMargin: "800px" }, + ); + + observer.observe(element); + onCleanup(() => observer.disconnect()); + } + + return ( +
    + 0} + fallback={ +

    + {props.graph.status() === "loading" ? "Reading history…" : "No commits to show."} +

    + } + > +
      (list = element)} + // Rows are as wide as their content; the container scrolls to reach + // the rest. A graph's width comes from the history, not the window, + // so there is no width at which capping would help. + class="history-list w-max min-w-full" + > + + {(row, index) => ( + setSelected(index())} + onKeyDown={onKeyDown} + /> + )} + +
    +
    + + + ); +} diff --git a/src/components/OpenRepository.tsx b/src/components/OpenRepository.tsx new file mode 100644 index 0000000..f839a1a --- /dev/null +++ b/src/components/OpenRepository.tsx @@ -0,0 +1,49 @@ +import { createSignal } from "solid-js"; + +/** + * Opening a repository by path. + * + * A plain form, because a native folder picker needs the Tauri dialog plugin + * and that is a dependency this change does not need. The picker arrives with + * the repository-picker work, along with recent repositories; until then a path + * still gets you in, and anything below a repository's root works because the + * Rust side searches upwards the way git does. + */ +export default function OpenRepository(props: { busy: boolean; onOpen: (path: string) => void }) { + const [path, setPath] = createSignal(""); + + function onSubmit(event: SubmitEvent) { + event.preventDefault(); + + const trimmed = path().trim(); + if (trimmed) props.onOpen(trimmed); + } + + return ( +
    + + setPath(event.currentTarget.value)} + class="w-72 rounded-small border border-outline bg-surface px-3 py-1.5 text-body-md text-on-surface focus-visible:outline-2 focus-visible:outline-offset-[-1px] focus-visible:outline-primary" + /> + +
    + ); +} diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx index ec86281..8304dd2 100644 --- a/src/components/StatusBar.tsx +++ b/src/components/StatusBar.tsx @@ -1,12 +1,18 @@ +import { Show } from "solid-js"; + /** * Status bar along the bottom of the window. The message lives in an * ``, which is implicitly a polite live region, so progress reports - * (fetching, walking history) are announced without stealing focus. + * and problems are announced without stealing focus. */ -export default function StatusBar() { +export default function StatusBar(props: { message?: string; isError?: boolean }) { return ( -