diff --git a/package.json b/package.json index 34b1df9..f0a3aa0 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@solidjs/signals": "2.0.0-beta.31", "@solidjs/web": "2.0.0-beta.31", "@tauri-apps/api": "^2", + "@tauri-apps/plugin-dialog": "^2.7.2", "@tauri-apps/plugin-opener": "^2", "solid-js": "2.0.0-beta.31" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a4a73d..4dd5673 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@tauri-apps/api': specifier: ^2 version: 2.11.1 + '@tauri-apps/plugin-dialog': + specifier: ^2.7.2 + version: 2.7.2 '@tauri-apps/plugin-opener': specifier: ^2 version: 2.5.4 @@ -973,6 +976,9 @@ packages: engines: {node: '>= 10'} hasBin: true + '@tauri-apps/plugin-dialog@2.7.2': + resolution: {integrity: sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==} + '@tauri-apps/plugin-opener@2.5.4': resolution: {integrity: sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==} @@ -2309,6 +2315,10 @@ snapshots: '@tauri-apps/cli-win32-ia32-msvc': 2.11.4 '@tauri-apps/cli-win32-x64-msvc': 2.11.4 + '@tauri-apps/plugin-dialog@2.7.2': + dependencies: + '@tauri-apps/api': 2.11.1 + '@tauri-apps/plugin-opener@2.5.4': dependencies: '@tauri-apps/api': 2.11.1 diff --git a/src/App.tsx b/src/App.tsx index 8f3bf97..c8df58c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,12 +1,14 @@ -import { Show, createSignal } from "solid-js"; +import { Show, createSignal, onSettled } from "solid-js"; import History from "./components/History"; import OpenRepository from "./components/OpenRepository"; import RepositoryNav from "./components/RepositoryNav"; import StatusBar from "./components/StatusBar"; +import Welcome from "./components/Welcome"; import { openRepository } from "./lib/api"; import { isGigitError, type OpenedRepository } from "./lib/bindings"; import { createGraphView } from "./lib/graph"; +import { createRecents } from "./lib/recents"; /** What to call a repository: its working tree's folder, or the git directory. */ function nameOf(repository: OpenedRepository): string { @@ -34,6 +36,9 @@ export default function App() { const [opening, setOpening] = createSignal(false); const [error, setError] = createSignal(); const graph = createGraphView(); + const recents = createRecents(); + + onSettled(() => void recents.refresh()); async function open(path: string) { setOpening(true); @@ -42,6 +47,9 @@ export default function App() { try { const opened = await openRepository(path); setRepository(opened); + // Opening promotes the repository to the front of the list, so the + // welcome pane is right when it is next seen. + void recents.refresh(); await graph.show(opened.id); } catch (failure) { setRepository(undefined); @@ -80,28 +88,26 @@ export default function App() { )} - + - {/* tabindex="-1" so the skip link actually moves focus here, not just scroll */} + {/* tabindex="-1" so the skip button actually moves focus here */}
-

History

- - Open a repository to see its history. -

- } + fallback={} > +

History

- + ); } diff --git a/src/app.css b/src/app.css index 8a3f636..837b1be 100644 --- a/src/app.css +++ b/src/app.css @@ -26,6 +26,16 @@ body { overflow: hidden; } + + /* 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 at + every list in the app. */ + ol, + ul { + list-style-type: ""; + } } /* The lane a graph line or dot belongs to. Registered so it is typed as a @@ -73,14 +83,6 @@ 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. diff --git a/src/components/OpenRepository.tsx b/src/components/OpenRepository.tsx index f839a1a..04b8f74 100644 --- a/src/components/OpenRepository.tsx +++ b/src/components/OpenRepository.tsx @@ -1,49 +1,36 @@ -import { createSignal } from "solid-js"; +import { chooseRepositoryFolder } from "../lib/picker"; /** - * Opening a repository by path. + * Opening a repository by choosing a folder. * - * 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. + * Any folder inside a repository will do — the Rust side searches upwards the + * way git does, so picking a subdirectory works. */ -export default function OpenRepository(props: { busy: boolean; onOpen: (path: string) => void }) { - const [path, setPath] = createSignal(""); +export default function OpenRepository(props: { + busy: boolean; + onOpen: (path: string) => void; + /** Styling differs between the title bar and the welcome pane. */ + class?: string; + children?: string; +}) { + async function choose() { + const path = await chooseRepositoryFolder(); - function onSubmit(event: SubmitEvent) { - event.preventDefault(); - - const trimmed = path().trim(); - if (trimmed) props.onOpen(trimmed); + // Dismissing the dialog is an ordinary outcome, not something to report. + if (path) props.onOpen(path); } 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/RecentRepositories.tsx b/src/components/RecentRepositories.tsx new file mode 100644 index 0000000..cf57b42 --- /dev/null +++ b/src/components/RecentRepositories.tsx @@ -0,0 +1,79 @@ +import { For, Show } from "solid-js"; + +import type { RecentRepository } from "../lib/bindings"; +import { absoluteTime, isoTime, relativeTime } from "../lib/format"; +import type { Recents } from "../lib/recents"; + +/** + * Repositories opened before. + * + * Each row is two controls rather than one with a nested button, which HTML + * does not allow: opening is the row itself, forgetting sits beside it. + */ +export default function RecentRepositories(props: { + recents: Recents; + onOpen: (path: string) => void; +}) { + return ( + 0}> +
+

+ Recent +

+ + {/* Named after its heading so it is addressable as "the recent list" + rather than just "a list". Naming a
    keeps it a list; naming a +
    would turn it into a region landmark, which this is not. */} +
      + + {(entry) => } + +
    +
+
+ ); +} + +function Row(props: { entry: RecentRepository; recents: Recents; onOpen: (path: string) => void }) { + return ( +
  • + + + +
  • + ); +} diff --git a/src/components/Welcome.tsx b/src/components/Welcome.tsx new file mode 100644 index 0000000..e748f46 --- /dev/null +++ b/src/components/Welcome.tsx @@ -0,0 +1,27 @@ +import type { Recents } from "../lib/recents"; +import OpenRepository from "./OpenRepository"; +import RecentRepositories from "./RecentRepositories"; + +/** What fills the main pane before a repository is open. */ +export default function Welcome(props: { + recents: Recents; + busy: boolean; + onOpen: (path: string) => void; +}) { + return ( +
    +

    Open a repository

    + +

    + Choose a folder. Anywhere inside a repository will do — gigit looks upwards for it, the same + way git does. +

    + + + Choose folder… + + + +
    + ); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index f063738..5bd32ff 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -10,6 +10,7 @@ import { Channel, invoke } from "@tauri-apps/api/core"; import type { GraphEvent, OpenedRepository, + RecentRepository, RefEntry, RepositoryId, RepositorySummary, @@ -31,6 +32,16 @@ export function listReferences(id: RepositoryId): Promise { return invoke("list_references", { id }); } +/** Repositories opened before, most recent first. */ +export function listRecentRepositories(): Promise { + return invoke("list_recent_repositories"); +} + +/** Drop a repository from the recents list. Resolves to whether it was there. */ +export function forgetRecentRepository(id: RepositoryId): Promise { + return invoke("forget_recent_repository", { id }); +} + /** A running graph stream. */ export type GraphStream = { /** diff --git a/src/lib/bindings.ts b/src/lib/bindings.ts index 110d6c0..23da4a6 100644 --- a/src/lib/bindings.ts +++ b/src/lib/bindings.ts @@ -96,6 +96,23 @@ export type GraphRow = { refs: RefBadge[]; }; +/** A repository that was open at some point. */ +export type RecentRepository = { + id: RepositoryId; + /** The working tree, or the git directory for a bare repository. */ + path: string; + /** The last path segment — what people call a repository. */ + name: string; + /** Seconds since the epoch. */ + lastOpened: number; + /** + * Whether the path is still there. Recomputed on every read, so a repository + * that has been moved or deleted can be shown as missing rather than + * silently dropped. + */ + exists: boolean; +}; + /** What arrives on a graph stream's channel. */ export type GraphEvent = | { event: "rows"; data: { start: number; rows: GraphRow[] } } diff --git a/src/lib/picker.ts b/src/lib/picker.ts new file mode 100644 index 0000000..a943b3e --- /dev/null +++ b/src/lib/picker.ts @@ -0,0 +1,27 @@ +/** + * Choosing a repository folder. + * + * Wrapped so the rest of the app never imports the dialog plugin directly — + * which also means the tests can stand in for it in one place. + */ + +import { open } from "@tauri-apps/plugin-dialog"; + +/** + * Ask for a repository folder. + * + * Resolves to `undefined` when the dialog is dismissed, which is an ordinary + * outcome rather than an error. Any folder inside a repository will do: the + * Rust side searches upwards the way git does. + */ +export async function chooseRepositoryFolder(): Promise { + const chosen = await open({ + directory: true, + multiple: false, + title: "Open repository", + }); + + // The plugin returns null on dismissal, and an array only when `multiple` + // is set — which it is not. + return typeof chosen === "string" ? chosen : undefined; +} diff --git a/src/lib/recents.ts b/src/lib/recents.ts new file mode 100644 index 0000000..ac88043 --- /dev/null +++ b/src/lib/recents.ts @@ -0,0 +1,51 @@ +/** + * The recents list as the UI sees it. + * + * Kept beside the graph store rather than inside it: what a repository's + * history looks like and which repositories are worth offering are unrelated + * concerns that happen to appear on the same screen. + */ + +import { createSignal } from "solid-js"; + +import { forgetRecentRepository, listRecentRepositories } from "./api"; +import { isGigitError, type RecentRepository, type RepositoryId } from "./bindings"; + +function describe(error: unknown): string { + return isGigitError(error) ? error.message : String(error); +} + +export function createRecents() { + const [entries, setEntries] = createSignal([]); + const [message, setMessage] = createSignal(); + + /** Fetch the list. Safe to call whenever the list may have changed. */ + async function refresh() { + try { + setEntries(await listRecentRepositories()); + } catch (error) { + // A recents list that cannot be read is a nuisance, not a failure worth + // blocking the app over — the picker still works. + setMessage(describe(error)); + } + } + + /** Drop one, removing it from the list straight away. */ + async function forget(id: RepositoryId) { + // Removed locally first: the list is the user's own and waiting on a round + // trip to see their own click take effect would feel broken. + const before = entries(); + setEntries(before.filter((entry) => entry.id !== id)); + + try { + await forgetRecentRepository(id); + } catch (error) { + setEntries(before); + setMessage(describe(error)); + } + } + + return { entries, message, refresh, forget }; +} + +export type Recents = ReturnType; diff --git a/tests/e2e/accessibility.spec.ts b/tests/e2e/accessibility.spec.ts index 4aa23e1..f382734 100644 --- a/tests/e2e/accessibility.spec.ts +++ b/tests/e2e/accessibility.spec.ts @@ -5,7 +5,7 @@ import { commitRows, mockTauri, openRepository } from "./tauri"; test.describe("structure", () => { test("puts every region behind a landmark", async ({ page }) => { - await mockTauri(page, { rows: linearHistory(20) }); + await mockTauri(page, { rows: linearHistory(20), chooseFolder: "/demo" }); await page.goto("/"); await expect(page.getByRole("banner")).toBeVisible(); @@ -18,7 +18,7 @@ test.describe("structure", () => { // WebKit drops list semantics when a list is styled with `list-style: none`, // which Tailwind's preflight applies — and WebKit is what the app actually // runs in on macOS. This is the test that would have caught it. - await mockTauri(page, { rows: linearHistory(20) }); + await mockTauri(page, { rows: linearHistory(20), chooseFolder: "/demo" }); await page.goto("/"); await openRepository(page); @@ -27,7 +27,7 @@ test.describe("structure", () => { }); test("hides the graph drawing from assistive technology", async ({ page }) => { - await mockTauri(page, { rows: branchedHistory() }); + await mockTauri(page, { rows: branchedHistory(), chooseFolder: "/demo" }); await page.goto("/"); await openRepository(page); @@ -43,7 +43,7 @@ test.describe("structure", () => { }); test("names each ref badge, since its colour says nothing out loud", async ({ page }) => { - await mockTauri(page, { rows: branchedHistory() }); + await mockTauri(page, { rows: branchedHistory(), chooseFolder: "/demo" }); await page.goto("/"); await openRepository(page); @@ -56,7 +56,7 @@ test.describe("structure", () => { test.describe("keyboard", () => { test.beforeEach(async ({ page }) => { - await mockTauri(page, { rows: linearHistory(60) }); + await mockTauri(page, { rows: linearHistory(60), chooseFolder: "/demo" }); await page.goto("/"); await openRepository(page); await expect(commitRows(page).first()).toBeVisible(); @@ -95,7 +95,7 @@ test.describe("skip link", () => { }) => { // Deliberately no prior interaction: the point of a skip link is that it is // the very first tab stop on a fresh page. - await mockTauri(page, { rows: linearHistory(20) }); + await mockTauri(page, { rows: linearHistory(20), chooseFolder: "/demo" }); await page.goto("/"); await page.keyboard.press("Tab"); @@ -115,7 +115,7 @@ test.describe("skip link", () => { /** The colours the theme actually resolved to, under one scheme. */ async function palette(page: Page, scheme: "light" | "dark") { await page.emulateMedia({ colorScheme: scheme }); - await mockTauri(page, { rows: branchedHistory() }); + await mockTauri(page, { rows: branchedHistory(), chooseFolder: "/demo" }); await page.goto("/"); await openRepository(page); await expect(commitRows(page).first()).toBeVisible(); diff --git a/tests/e2e/history.spec.ts b/tests/e2e/history.spec.ts index 8dde38b..3da6846 100644 --- a/tests/e2e/history.spec.ts +++ b/tests/e2e/history.spec.ts @@ -7,7 +7,7 @@ const ROWS = 900; test.describe("history", () => { test.beforeEach(async ({ page }) => { - await mockTauri(page, { rows: linearHistory(ROWS) }); + await mockTauri(page, { rows: linearHistory(ROWS), chooseFolder: "/demo" }); await page.goto("/"); }); @@ -90,10 +90,14 @@ test.describe("history", () => { test.describe("failures", () => { test("says what went wrong instead of showing an empty history", async ({ page }) => { - await mockTauri(page, { rows: [], failToOpen: "no git repository found at or above /nope" }); + await mockTauri(page, { + rows: [], + chooseFolder: "/nope", + failToOpen: "no git repository found at or above /nope", + }); await page.goto("/"); - await openRepository(page, "/nope"); + await openRepository(page); await expect(page.getByRole("contentinfo")).toContainText("no git repository found"); }); @@ -101,7 +105,7 @@ test.describe("failures", () => { test.describe("empty repository", () => { test("says there is nothing rather than looking broken", async ({ page }) => { - await mockTauri(page, { rows: [] }); + await mockTauri(page, { rows: [], chooseFolder: "/demo" }); await page.goto("/"); await openRepository(page); diff --git a/tests/e2e/picker.spec.ts b/tests/e2e/picker.spec.ts new file mode 100644 index 0000000..3ac2c2c --- /dev/null +++ b/tests/e2e/picker.spec.ts @@ -0,0 +1,133 @@ +import { expect, test } from "@playwright/test"; + +import { linearHistory, recent } from "../fixtures/rows"; +import { commitRows, invocations, mockTauri, openRepository, recentRows } from "./tauri"; + +test.describe("welcome pane", () => { + test("offers to open a repository before one is chosen", async ({ page }) => { + await mockTauri(page, { rows: [], chooseFolder: "/demo" }); + await page.goto("/"); + + await expect(page.getByRole("heading", { name: "Open a repository" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Choose folder…" })).toBeVisible(); + }); + + test("opens what the folder dialog returned", async ({ page }) => { + await mockTauri(page, { rows: linearHistory(10), chooseFolder: "/somewhere/gigit" }); + await page.goto("/"); + + await openRepository(page); + + await expect(commitRows(page).first()).toBeVisible(); + const opened = (await invocations(page)).find((call) => call.command === "open_repository"); + expect(opened).toBeDefined(); + }); + + test("does nothing when the dialog is dismissed", async ({ page }) => { + // Cancelling is an ordinary outcome, not a failure to report. + await mockTauri(page, { rows: linearHistory(10), chooseFolder: null }); + await page.goto("/"); + + await openRepository(page); + + await expect(page.getByRole("heading", { name: "Open a repository" })).toBeVisible(); + await expect(page.getByRole("contentinfo")).toHaveText("Ready"); + expect((await invocations(page)).some((call) => call.command === "open_repository")).toBe( + false, + ); + }); +}); + +test.describe("recent repositories", () => { + test("shows nothing at all on a first run", async ({ page }) => { + await mockTauri(page, { rows: [], recents: [], chooseFolder: "/demo" }); + await page.goto("/"); + + await expect(page.getByRole("button", { name: "Choose folder…" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Recent" })).toBeHidden(); + }); + + test("lists what was opened before", async ({ page }) => { + await mockTauri(page, { + rows: [], + recents: [recent("gigit"), recent("tauri")], + chooseFolder: "/demo", + }); + await page.goto("/"); + + await expect(page.getByRole("heading", { name: "Recent" })).toBeVisible(); + await expect(recentRows(page)).toHaveCount(2); + }); + + test("opens a remembered repository by its path", async ({ page }) => { + await mockTauri(page, { + rows: linearHistory(10), + recents: [recent("gigit")], + // Deliberately different, so a test passing by way of the dialog would + // be obvious. + chooseFolder: "/not/this/one", + }); + await page.goto("/"); + + await page.getByRole("button", { name: /^gigit/u }).click(); + + await expect(commitRows(page).first()).toBeVisible(); + const opened = (await invocations(page)).find((call) => call.command === "open_repository"); + expect(opened?.args.path).toBe("/repositories/gigit"); + }); + + test("forgets a repository and stops offering it", async ({ page }) => { + await mockTauri(page, { + rows: [], + recents: [recent("gigit"), recent("tauri")], + chooseFolder: "/demo", + }); + await page.goto("/"); + await expect(recentRows(page)).toHaveCount(2); + + await page.getByRole("button", { name: "Forget gigit" }).click(); + + await expect(recentRows(page)).toHaveCount(1); + await expect(page.getByRole("button", { name: /^tauri/u })).toBeVisible(); + }); + + test("marks a repository that is no longer there", async ({ page }) => { + await mockTauri(page, { + rows: [], + recents: [recent("moved", { exists: false })], + chooseFolder: "/demo", + }); + await page.goto("/"); + + await expect(page.getByText("Missing")).toBeVisible(); + // Still offered: the drive may simply not be mounted. + await expect(page.getByRole("button", { name: /^moved/u })).toBeEnabled(); + }); + + test("names each forget button after what it forgets", async ({ page }) => { + // Buttons that all read "Forget" would be indistinguishable by voice. + await mockTauri(page, { + rows: [], + recents: [recent("gigit"), recent("tauri")], + chooseFolder: "/demo", + }); + await page.goto("/"); + + await expect(page.getByRole("button", { name: "Forget gigit" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Forget tauri" })).toBeVisible(); + }); + + test("is reachable and operable by keyboard alone", async ({ page }) => { + await mockTauri(page, { rows: [], recents: [recent("gigit")], chooseFolder: "/demo" }); + await page.goto("/"); + await expect(page.getByRole("button", { name: /^gigit/u })).toBeVisible(); + + // Skip button, title bar open, welcome open, then the entry itself. + await page.keyboard.press("Tab"); + await page.keyboard.press("Tab"); + await page.keyboard.press("Tab"); + await page.keyboard.press("Tab"); + + await expect(page.getByRole("button", { name: /^gigit/u })).toBeFocused(); + }); +}); diff --git a/tests/e2e/tauri.ts b/tests/e2e/tauri.ts index 4c30e0a..6cec81c 100644 --- a/tests/e2e/tauri.ts +++ b/tests/e2e/tauri.ts @@ -14,13 +14,20 @@ import type { Page } from "@playwright/test"; -import type { GraphRow } from "../../src/lib/bindings"; +import type { GraphRow, RecentRepository } from "../../src/lib/bindings"; export type MockOptions = { /** Every row the fake backend is willing to hand over. */ rows: GraphRow[]; /** Make `open_repository` reject, to exercise the failure path. */ failToOpen?: string; + /** What the recents list starts out holding. */ + recents?: RecentRepository[]; + /** + * What the folder dialog returns. `null` stands for the user dismissing it, + * which the app must treat as an ordinary outcome rather than a failure. + */ + chooseFolder?: string | null; }; /** @@ -53,6 +60,8 @@ export async function mockTauri(page: Page, options: MockOptions): Promise const calls: { command: string; args: Record }[] = []; (window as unknown as { __calls: typeof calls }).__calls = calls; + let recents = serialised.recents ?? []; + (window as unknown as { __TAURI_INTERNALS__: unknown }).__TAURI_INTERNALS__ = { transformCallback: () => Math.random(), invoke: async (command: string, args: Record) => { @@ -89,6 +98,19 @@ export async function mockTauri(page: Page, options: MockOptions): Promise deliver(args.rows as number); return null; } + // The dialog plugin goes over the same bridge, so standing in for it + // needs no separate mechanism. + case "plugin:dialog|open": { + return serialised.chooseFolder ?? null; + } + case "list_recent_repositories": { + return recents; + } + case "forget_recent_repository": { + const before = recents.length; + recents = recents.filter((entry) => entry.id !== args.id); + return recents.length !== before; + } default: return null; } @@ -100,12 +122,11 @@ export async function mockTauri(page: Page, options: MockOptions): Promise /** * Open a repository through the UI, the way a person would. * - * Addressed by role rather than by label: the sidebar landmark is also named - * "Repository", so a label lookup alone matches two different things. + * The folder dialog is native, so what the mock returns for it is set up front + * by `mockTauri`; this just presses the button that summons it. */ -export async function openRepository(page: Page, path = "/demo"): Promise { - await page.getByRole("textbox", { name: "Repository" }).fill(path); - await page.getByRole("button", { name: "Open" }).click(); +export async function openRepository(page: Page): Promise { + await page.getByRole("button", { name: "Choose folder…" }).click(); } /** Every command the frontend has invoked, in order. */ @@ -122,3 +143,13 @@ export function invocations(page: Page): Promise<{ command: string }[]> { export function commitRows(page: Page) { return page.getByRole("main").getByRole("listitem"); } + +/** + * The remembered repositories. + * + * Scoped by the list's own name for the same reason as {@link commitRows}: the + * ref sidebar is a list too. + */ +export function recentRows(page: Page) { + return page.getByRole("list", { name: "Recent" }).getByRole("listitem"); +} diff --git a/tests/fixtures/rows.ts b/tests/fixtures/rows.ts index 52cfd59..38ac3b2 100644 --- a/tests/fixtures/rows.ts +++ b/tests/fixtures/rows.ts @@ -93,3 +93,20 @@ export function branchedHistory(): GraphRow[] { }), ]; } + +/** A remembered repository. */ +export function recent( + name: string, + partial: Partial = {}, +): import("../../src/lib/bindings").RecentRepository { + return { + id: `/repositories/${name}/.git`, + path: `/repositories/${name}`, + name, + // Fixed rather than relative to now, so an assertion on a rendered date + // does not change its answer overnight. + lastOpened: 1_700_000_000, + exists: true, + ...partial, + }; +} diff --git a/tests/unit/recent-repositories.test.tsx b/tests/unit/recent-repositories.test.tsx new file mode 100644 index 0000000..535b4db --- /dev/null +++ b/tests/unit/recent-repositories.test.tsx @@ -0,0 +1,123 @@ +import { render } from "@solidjs/testing-library"; +import { createSignal, flush } from "solid-js"; +import { describe, expect, test, vi } from "vitest"; + +import RecentRepositories from "../../src/components/RecentRepositories"; +import type { RecentRepository } from "../../src/lib/bindings"; +import type { Recents } from "../../src/lib/recents"; +import { recent } from "../fixtures/rows"; + +/** A stand-in for the store, so these tests never touch IPC. */ +function store(entries: RecentRepository[]) { + const [current] = createSignal(entries); + + return { + entries: current, + message: () => undefined, + refresh: vi.fn(), + forget: vi.fn(), + } as unknown as Recents & { forget: ReturnType }; +} + +describe("RecentRepositories", () => { + test("shows nothing at all when there is nothing remembered", () => { + // An empty "Recent" heading would be worse than no heading. + const { container } = render(() => ); + + expect(container.textContent).toBe(""); + }); + + test("lists each repository by name and path", async () => { + const { container } = render(() => ( + + )); + await flush(); + + // Per row rather than per document: a name also appears in its own forget + // button's hidden label, so looking for it document-wide finds two. + const rows = [...container.querySelectorAll("li")].map((row) => row.textContent ?? ""); + + expect(rows).toHaveLength(2); + expect(rows[0]).toContain("gigit"); + expect(rows[0]).toContain("/repositories/gigit"); + expect(rows[1]).toContain("tauri"); + }); + + test("opens the path that was remembered, not the name", async () => { + const onOpen = vi.fn(); + const { getByRole } = render(() => ( + + )); + await flush(); + + getByRole("button", { name: /^gigit/u }).click(); + + expect(onOpen).toHaveBeenCalledWith("/repositories/gigit"); + }); + + test("gives each forget button a name saying what it forgets", async () => { + // Several buttons reading "Forget" would be indistinguishable by voice. + const { getByRole } = render(() => ( + + )); + await flush(); + + expect(getByRole("button", { name: "Forget gigit" })).toBeTruthy(); + expect(getByRole("button", { name: "Forget tauri" })).toBeTruthy(); + }); + + test("forgets by id", async () => { + const recents = store([recent("gigit")]); + const { getByRole } = render(() => ); + await flush(); + + getByRole("button", { name: "Forget gigit" }).click(); + + expect(recents.forget).toHaveBeenCalledWith("/repositories/gigit/.git"); + }); + + test("marks a repository that is no longer where it was", async () => { + const { container } = render(() => ( + + )); + await flush(); + + expect(container.textContent).toContain("Missing"); + }); + + test("still offers to open a missing repository", async () => { + // The folder may be on a drive that is simply not mounted right now. + const onOpen = vi.fn(); + const { getByRole } = render(() => ( + + )); + await flush(); + + getByRole("button", { name: /^moved/u }).click(); + + expect(onOpen).toHaveBeenCalledWith("/repositories/moved"); + }); + + test("gives every entry a machine readable date", async () => { + const { container } = render(() => ( + + )); + await flush(); + + expect(container.querySelector("time")?.getAttribute("datetime")).toBe( + "2023-11-14T22:13:20.000Z", + ); + }); + + test("is a list, so how many there are is announced up front", async () => { + const { container } = render(() => ( + + )); + await flush(); + + expect(container.querySelectorAll("ul > li")).toHaveLength(3); + }); +}); diff --git a/tests/unit/recents.test.ts b/tests/unit/recents.test.ts new file mode 100644 index 0000000..256552a --- /dev/null +++ b/tests/unit/recents.test.ts @@ -0,0 +1,82 @@ +import { flush } from "solid-js"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { createRecents } from "../../src/lib/recents"; +import { recent } from "../fixtures/rows"; + +const listRecentRepositories = vi.fn(async () => [recent("one"), recent("two")]); +const forgetRecentRepository = vi.fn(async () => true); + +vi.mock("../../src/lib/api", () => ({ + listRecentRepositories: (...args: unknown[]) => listRecentRepositories(...(args as [])), + forgetRecentRepository: (...args: unknown[]) => forgetRecentRepository(...(args as [])), +})); + +beforeEach(() => { + listRecentRepositories.mockClear(); + listRecentRepositories.mockImplementation(async () => [recent("one"), recent("two")]); + forgetRecentRepository.mockClear(); + forgetRecentRepository.mockImplementation(async () => true); +}); + +describe("createRecents", () => { + test("starts empty and asks for nothing", () => { + const recents = createRecents(); + + expect(recents.entries()).toEqual([]); + expect(listRecentRepositories).not.toHaveBeenCalled(); + }); + + test("collects the list when refreshed", async () => { + const recents = createRecents(); + + await recents.refresh(); + await flush(); + + expect(recents.entries().map((entry) => entry.name)).toEqual(["one", "two"]); + }); + + test("removes an entry as soon as it is forgotten", async () => { + // Waiting on a round trip before the user sees their own click take effect + // would feel broken. + const recents = createRecents(); + await recents.refresh(); + await flush(); + + const forgetting = recents.forget("/repositories/one/.git"); + await flush(); + + expect(recents.entries().map((entry) => entry.name)).toEqual(["two"]); + await forgetting; + }); + + test("puts an entry back if forgetting failed", async () => { + const recents = createRecents(); + await recents.refresh(); + await flush(); + + forgetRecentRepository.mockRejectedValueOnce({ + kind: "workerStopped", + message: "worker stopped", + }); + + await recents.forget("/repositories/one/.git"); + await flush(); + + expect(recents.entries().map((entry) => entry.name)).toEqual(["one", "two"]); + expect(recents.message()).toBe("worker stopped"); + }); + + test("reports a list that could not be read without losing what it had", async () => { + const recents = createRecents(); + await recents.refresh(); + await flush(); + + listRecentRepositories.mockRejectedValueOnce({ kind: "git", message: "cannot read" }); + await recents.refresh(); + await flush(); + + expect(recents.message()).toBe("cannot read"); + expect(recents.entries()).toHaveLength(2); + }); +});