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 (
-
+
+ {props.busy ? "Opening…" : (props.children ?? "Open repository…")}
+
);
}
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. */}
+
+
+
+ );
+}
+
+function Row(props: { entry: RecentRepository; recents: Recents; onOpen: (path: string) => void }) {
+ return (
+
+ props.onOpen(props.entry.path)}
+ class="flex min-w-0 flex-1 items-baseline gap-3 rounded-medium px-3 py-2 text-left hover:bg-surface-container focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-primary"
+ >
+ {props.entry.name}
+
+
+ {props.entry.path}
+
+
+
+ {/* Still openable — a network mount may simply be away — but the
+ reason a click might fail should be visible before it does. */}
+
+
+
+
+ {relativeTime(props.entry.lastOpened)}
+
+
+
+ void props.recents.forget(props.entry.id)}
+ class="shrink-0 rounded-medium px-3 py-2 text-label-lg text-on-surface-variant hover:bg-surface-container focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-primary"
+ >
+ Forget
+ {/* Every one of these reads "Forget" on screen; the name says which. */}
+ {props.entry.name}
+
+
+ );
+}
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);
+ });
+});