From f24bc1084fdab7735009fdaa5ff5858fad2f12b9 Mon Sep 17 00:00:00 2001
From: Claas
Date: Thu, 6 Aug 2026 15:39:56 +0200
Subject: [PATCH] Pick repositories with a folder dialog and a recents list
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replaces the placeholder path field with the native folder picker and a
welcome pane that offers what was opened before.
The picker is wrapped in one module so nothing else imports the dialog
plugin, which is also what lets the tests stand in for it in one place.
Dismissing the dialog resolves to undefined and is treated as an
ordinary outcome — cancelling is not a failure to report.
A recent entry opens by its stored path rather than its name, and stays
openable when the folder is missing: a drive may simply not be mounted,
and refusing to try would be presumptuous. It is marked so the reason a
click might fail is visible before it does. Forgetting removes the entry
straight away and puts it back if the call fails — waiting on a round
trip to see your own click take effect feels broken.
A row is two controls side by side rather than one containing another,
which HTML does not allow. Every forget button reads "Forget" on screen,
so each carries the repository name as hidden text; otherwise they would
be indistinguishable by voice.
Two things the tests pushed on:
- The recents list is named after its heading, so it can be addressed as
the recent list rather than as "a list" — the ref sidebar is a list
too, and an unscoped query counts four extra rows. Naming a
keeps
it a list, where naming a would invent a region landmark.
- The list-semantics workaround moved from the history list to a base
rule for every ol and ul. Two lists needed it, and a third would have
been a bug waiting to happen.
Verified in WebKit and Chromium, light and dark: 15 unit tests, 10
end-to-end, and the whole suite still green at 56 across both engines.
Co-Authored-By: Claude Opus 5
---
package.json | 1 +
pnpm-lock.yaml | 10 ++
src/App.tsx | 28 +++--
src/app.css | 18 ++--
src/components/OpenRepository.tsx | 65 +++++-------
src/components/RecentRepositories.tsx | 79 ++++++++++++++
src/components/Welcome.tsx | 27 +++++
src/lib/api.ts | 11 ++
src/lib/bindings.ts | 17 +++
src/lib/picker.ts | 27 +++++
src/lib/recents.ts | 51 +++++++++
tests/e2e/accessibility.spec.ts | 14 +--
tests/e2e/history.spec.ts | 12 ++-
tests/e2e/picker.spec.ts | 133 ++++++++++++++++++++++++
tests/e2e/tauri.ts | 43 ++++++--
tests/fixtures/rows.ts | 17 +++
tests/unit/recent-repositories.test.tsx | 123 ++++++++++++++++++++++
tests/unit/recents.test.ts | 82 +++++++++++++++
18 files changed, 683 insertions(+), 75 deletions(-)
create mode 100644 src/components/RecentRepositories.tsx
create mode 100644 src/components/Welcome.tsx
create mode 100644 src/lib/picker.ts
create mode 100644 src/lib/recents.ts
create mode 100644 tests/e2e/picker.spec.ts
create mode 100644 tests/unit/recent-repositories.test.tsx
create mode 100644 tests/unit/recents.test.ts
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 (
-
+
);
}
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. */}
+