From d89a7ce69514a7f70266bf892cc415e163e19c98 Mon Sep 17 00:00:00 2001 From: Claas Date: Thu, 6 Aug 2026 15:30:52 +0200 Subject: [PATCH] Remember recently opened repositories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the dialog plugin and an actor that owns the list of repositories opened before, persisted under the platform's config directory. A separate actor rather than part of the workspace: the workspace owns repositories that are open, this owns a memory of ones that were, and the two have different lifetimes. It owns both the list and the file, so neither needs a lock. Reads and writes go through tokio::fs, since the actor is async and blocking it would stall every other request. The workspace records on a successful open rather than the command layer doing it, because remembering is part of opening and commands are meant to be free of decisions like that. A failed open records nothing, and a failed write never fails the open — not remembering a repository is a far smaller problem than refusing to open one over it. Writes that do fail say so on stderr rather than vanishing. Entries are keyed by repository id, so opening the same repository again moves it to the front instead of appearing twice. The path stored is the working tree rather than the git directory, because that is what a person recognises. A repository that has since been moved or deleted is flagged rather than dropped: the user may want to see it was there and forget it deliberately. That flag is recomputed on every read and never trusted from disk. An unreadable file is treated as an empty list. Refusing to start because a convenience cache got mangled would be the wrong trade. The capability grants dialog:allow-open only — opening a folder, not saving files or showing message dialogs. Twelve tests cover ordering, deduplication, the twenty-entry cap, forgetting something unknown, surviving a restart, a repository that disappears, a corrupt file, and the wire shape. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 144 ++++++++++++++- src-tauri/Cargo.toml | 6 +- src-tauri/capabilities/default.json | 7 +- src-tauri/src/commands.rs | 18 ++ src-tauri/src/lib.rs | 34 +++- src-tauri/src/recents.rs | 223 +++++++++++++++++++++++ src-tauri/src/workspace.rs | 22 ++- src-tauri/tests/recents.rs | 271 ++++++++++++++++++++++++++++ src-tauri/tests/streaming.rs | 11 +- src-tauri/tests/workspace.rs | 11 +- 10 files changed, 728 insertions(+), 19 deletions(-) create mode 100644 src-tauri/src/recents.rs create mode 100644 src-tauri/tests/recents.rs diff --git a/Cargo.lock b/Cargo.lock index 65551cc..4d906a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1369,6 +1369,7 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-dialog", "tauri-plugin-opener", "tempfile", "thiserror 2.0.19", @@ -3131,6 +3132,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.1", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -3671,6 +3673,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -4392,6 +4418,48 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.19", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.19", + "toml 1.1.4+spec-1.1.0", + "url", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.4" @@ -5476,6 +5544,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -5509,13 +5586,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows-threading" version = "0.1.0" @@ -5546,6 +5640,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -5558,6 +5658,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -5570,12 +5676,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -5588,6 +5706,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -5600,6 +5724,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -5612,6 +5742,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -5624,6 +5760,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.5.40" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d57a080..5da2d48 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -25,8 +25,10 @@ tauri-plugin-opener = "2" serde.workspace = true serde_json = "1" thiserror.workspace = true -# Only the channels and the runtime handle; Tauri brings the runtime itself. -tokio = { version = "1", features = ["sync", "rt"] } +# Channels, the runtime handle, and non-blocking file access for the recents +# list. Tauri brings the runtime itself. +tokio = { version = "1", features = ["sync", "rt", "fs"] } +tauri-plugin-dialog = "2" [dev-dependencies] gigit-git = { workspace = true, features = ["testing"] } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 4cdbf49..d1881b6 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,9 +2,12 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Capability for the main window", - "windows": ["main"], + "windows": [ + "main" + ], "permissions": [ "core:default", - "opener:default" + "opener:default", + "dialog:allow-open" ] } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 1df78a9..fbce3b0 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -11,6 +11,7 @@ use tauri::State; use crate::error::Result; use crate::graph_stream::GraphEvent; +use crate::recents::{RecentRepository, RecentsHandle}; use crate::workspace::{OpenedRepository, RepositoryId, WorkspaceHandle}; #[tauri::command] @@ -68,3 +69,20 @@ pub async fn request_more_rows( ) -> Result<()> { workspace.request_more_rows(id, rows).await } + +/// Repositories opened before, most recent first. +#[tauri::command] +pub async fn list_recent_repositories( + recents: State<'_, RecentsHandle>, +) -> Result> { + recents.list().await +} + +/// Drop a repository from the recents list. Returns whether it was there. +#[tauri::command] +pub async fn forget_recent_repository( + id: RepositoryId, + recents: State<'_, RecentsHandle>, +) -> Result { + recents.forget(id).await +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index aef1193..f2270bc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,23 +1,43 @@ mod commands; mod error; mod graph_stream; +mod recents; mod repository_actor; mod workspace; pub use error::Error; pub use graph_stream::GraphEvent; +pub use recents::{RecentRepository, RecentsHandle}; pub use workspace::{OpenedRepository, RepositoryId, WorkspaceHandle}; +use tauri::Manager; + +/// Where the recents list lives, under the platform's config directory. +const RECENTS_FILE: &str = "recent-repositories.json"; + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - let (workspace, actor) = WorkspaceHandle::new(); - tauri::async_runtime::spawn(actor); - tauri::Builder::default() .plugin(tauri_plugin_opener::init()) - // The only shared state is the workspace handle, which is a channel - // sender. Nothing here is behind a lock. - .manage(workspace) + .plugin(tauri_plugin_dialog::init()) + .setup(|app| { + // The config directory is only knowable from the app handle, which + // is why the actors start here rather than before the builder. + let file = app.path().app_config_dir()?.join(RECENTS_FILE); + + let (recents, remembering) = RecentsHandle::new(file); + let (workspace, serving) = WorkspaceHandle::new(recents.clone()); + + tauri::async_runtime::spawn(remembering); + tauri::async_runtime::spawn(serving); + + // The only shared state is two channel senders. Nothing is behind a + // lock. + app.manage(recents); + app.manage(workspace); + + Ok(()) + }) .invoke_handler(tauri::generate_handler![ commands::open_repository, commands::close_repository, @@ -25,6 +45,8 @@ pub fn run() { commands::list_references, commands::stream_graph, commands::request_more_rows, + commands::list_recent_repositories, + commands::forget_recent_repository, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/recents.rs b/src-tauri/src/recents.rs new file mode 100644 index 0000000..bd45811 --- /dev/null +++ b/src-tauri/src/recents.rs @@ -0,0 +1,223 @@ +//! The list of repositories opened recently. +//! +//! An actor of its own, so the list and the file it lives in have exactly one +//! owner and need no lock. It is deliberately not part of the workspace actor: +//! the workspace owns *open* repositories, this owns a memory of ones that were +//! open once, and the two have different lifetimes. + +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use gigit_git::RepositorySummary; +use serde::{Deserialize, Serialize}; +use tokio::sync::{mpsc, oneshot}; + +use crate::error::{Error, Result}; +use crate::workspace::RepositoryId; + +const INBOX_CAPACITY: usize = 32; + +/// How many repositories to remember. Long enough to cover what anyone is +/// actually moving between, short enough that the list stays scannable. +const CAPACITY: usize = 20; + +/// A repository that was open at some point. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RecentRepository { + pub id: RepositoryId, + /// The working tree, or the git directory for a bare repository — whichever + /// a person would recognise as "where the repository is". + pub path: PathBuf, + /// The last path segment, which is what people call a repository. + pub name: String, + /// Seconds since the epoch. + pub last_opened: i64, + /// Whether the path is still there. + /// + /// Recomputed on every read and never trusted from disk: a repository can + /// be moved or deleted while the app is not looking. It is written out with + /// the rest only because keeping it out would mean a second type for no + /// real gain. + #[serde(default)] + pub exists: bool, +} + +impl RecentRepository { + pub(crate) fn new(id: RepositoryId, summary: &RepositorySummary) -> Self { + let path = summary + .workdir + .clone() + .unwrap_or_else(|| summary.git_dir.clone()); + + Self { + name: name_of(&path), + id, + path, + last_opened: now(), + exists: true, + } + } +} + +fn name_of(path: &Path) -> String { + path.file_name() + .map(|name| name.to_string_lossy().into_owned()) + // A path ending in a separator, or the filesystem root. Rare, but + // showing an empty name would be worse than showing the whole path. + .unwrap_or_else(|| path.to_string_lossy().into_owned()) +} + +fn now() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|elapsed| i64::try_from(elapsed.as_secs()).unwrap_or(i64::MAX)) + .unwrap_or_default() +} + +enum Message { + List { + respond: oneshot::Sender>, + }, + Record { + entry: Box, + respond: oneshot::Sender<()>, + }, + Forget { + id: RepositoryId, + respond: oneshot::Sender, + }, +} + +/// A cheap, clonable way to talk to the recents actor. +#[derive(Clone, Debug)] +pub struct RecentsHandle { + outbox: mpsc::Sender, +} + +impl RecentsHandle { + /// Build the handle and the actor's future. + /// + /// As with the workspace, the future is returned rather than spawned so the + /// caller picks the runtime — Tauri's in the app, the test runtime in tests. + pub fn new(file: PathBuf) -> (Self, impl Future + Send + 'static) { + let (outbox, inbox) = mpsc::channel(INBOX_CAPACITY); + + (Self { outbox }, run(file, inbox)) + } + + /// Most recently opened first. + pub async fn list(&self) -> Result> { + self.request(|respond| Message::List { respond }).await + } + + /// Remember a repository, moving it to the front if it is already known. + pub async fn record(&self, entry: RecentRepository) -> Result<()> { + self.request(|respond| Message::Record { + entry: Box::new(entry), + respond, + }) + .await + } + + /// Drop a repository from the list. Returns whether it was there. + pub async fn forget(&self, id: RepositoryId) -> Result { + self.request(|respond| Message::Forget { id, respond }) + .await + } + + async fn request(&self, build: impl FnOnce(oneshot::Sender) -> Message) -> Result { + let (respond, answer) = oneshot::channel(); + + self.outbox + .send(build(respond)) + .await + .map_err(|_| Error::WorkerStopped)?; + + answer.await.map_err(|_| Error::WorkerStopped) + } +} + +async fn run(file: PathBuf, mut inbox: mpsc::Receiver) { + let mut entries = load(&file).await; + + while let Some(message) = inbox.recv().await { + match message { + Message::List { respond } => { + for entry in &mut entries { + entry.exists = entry.path.exists(); + } + + let _ = respond.send(entries.clone()); + } + Message::Record { entry, respond } => { + // Same repository opened again: it moves to the front rather + // than appearing twice. + entries.retain(|known| known.id != entry.id); + entries.insert(0, *entry); + entries.truncate(CAPACITY); + + save(&file, &entries).await; + let _ = respond.send(()); + } + Message::Forget { id, respond } => { + let before = entries.len(); + entries.retain(|known| known.id != id); + let forgotten = entries.len() != before; + + if forgotten { + save(&file, &entries).await; + } + + let _ = respond.send(forgotten); + } + } + } +} + +/// Read the list, treating anything unreadable as an empty list. +/// +/// A missing file is the normal first run. A corrupt one is not worth refusing +/// to start over — the list is a convenience, and the worst case is that a user +/// has to open a repository the long way once. +async fn load(file: &Path) -> Vec { + let Ok(contents) = tokio::fs::read_to_string(file).await else { + return Vec::new(); + }; + + match serde_json::from_str::>(&contents) { + Ok(entries) => entries, + Err(error) => { + eprintln!( + "ignoring unreadable recent repositories at {}: {error}", + file.display() + ); + Vec::new() + } + } +} + +/// Write the list back. +/// +/// Failures are reported but not propagated: not being able to remember a +/// repository is a far smaller problem than refusing to open one because of it. +async fn save(file: &Path, entries: &[RecentRepository]) { + if let Some(directory) = file.parent() { + if let Err(error) = tokio::fs::create_dir_all(directory).await { + eprintln!("could not create {}: {error}", directory.display()); + return; + } + } + + let Ok(contents) = serde_json::to_string_pretty(entries) else { + return; + }; + + if let Err(error) = tokio::fs::write(file, contents).await { + eprintln!( + "could not save recent repositories to {}: {error}", + file.display() + ); + } +} diff --git a/src-tauri/src/workspace.rs b/src-tauri/src/workspace.rs index e15b5d1..8b4c607 100644 --- a/src-tauri/src/workspace.rs +++ b/src-tauri/src/workspace.rs @@ -14,6 +14,7 @@ use tokio::sync::{mpsc, oneshot}; use crate::error::{Error, Result}; use crate::graph_stream::GraphEvent; +use crate::recents::{RecentRepository, RecentsHandle}; use crate::repository_actor::RepositoryHandle; const INBOX_CAPACITY: usize = 32; @@ -77,10 +78,14 @@ impl WorkspaceHandle { /// /// The future is returned rather than spawned so the caller decides which /// runtime runs it — Tauri's in the app, the test runtime in tests. - pub fn new() -> (Self, impl Future + Send + 'static) { + /// + /// The recents handle is taken here rather than reached for in the command + /// layer, because remembering a repository is part of opening one and + /// commands are meant to be free of decisions like that. + pub fn new(recents: RecentsHandle) -> (Self, impl Future + Send + 'static) { let (outbox, inbox) = mpsc::channel(INBOX_CAPACITY); - (Self { outbox }, run(inbox)) + (Self { outbox }, run(inbox, recents)) } /// Open a repository, or return the one already open for that path. @@ -142,7 +147,7 @@ impl WorkspaceHandle { } } -async fn run(mut inbox: mpsc::Receiver) { +async fn run(mut inbox: mpsc::Receiver, recents: RecentsHandle) { let mut open: HashMap = HashMap::new(); while let Some(message) = inbox.recv().await { @@ -151,7 +156,16 @@ async fn run(mut inbox: mpsc::Receiver) { // served one after the other. Opening is fast enough that the // simplicity is worth more than the concurrency. Message::Open { path, respond } => { - let _ = respond.send(open_repository(&mut open, path).await); + let opened = open_repository(&mut open, path).await; + + // Only a repository that actually opened is worth remembering, + // and failing to remember one is never worth failing the open. + if let Ok(opened) = &opened { + let entry = RecentRepository::new(opened.id.clone(), &opened.summary); + let _ = recents.record(entry).await; + } + + let _ = respond.send(opened); } Message::Close { id, respond } => { // Dropping the last handle closes the actor's channel, which diff --git a/src-tauri/tests/recents.rs b/src-tauri/tests/recents.rs new file mode 100644 index 0000000..8664355 --- /dev/null +++ b/src-tauri/tests/recents.rs @@ -0,0 +1,271 @@ +//! The remembered list of repositories. + +use std::path::PathBuf; + +use gigit_git::testing::Fixture; +use gigit_lib::{RecentsHandle, RepositoryId, WorkspaceHandle}; +use tempfile::TempDir; + +/// A recents actor writing into a directory that cleans itself up. +fn recents() -> (RecentsHandle, TempDir) { + let directory = tempfile::tempdir().expect("could not create a temporary directory"); + let (handle, actor) = RecentsHandle::new(directory.path().join("recent-repositories.json")); + tokio::spawn(actor); + + (handle, directory) +} + +/// A workspace wired to a given recents actor, so opening records. +fn workspace(recents: RecentsHandle) -> WorkspaceHandle { + let (handle, actor) = WorkspaceHandle::new(recents); + tokio::spawn(actor); + + handle +} + +fn id(path: &str) -> RepositoryId { + serde_json::from_str(&format!("\"{path}\"")).expect("should deserialise") +} + +#[tokio::test] +async fn starts_empty_when_there_is_nothing_remembered() { + let (recents, _directory) = recents(); + + assert_eq!(recents.list().await.expect("should list"), Vec::new()); +} + +#[tokio::test] +async fn remembers_a_repository_that_was_opened() { + let fixture = Fixture::with_one_commit(); + let (recents, _directory) = recents(); + let workspace = workspace(recents.clone()); + + workspace + .open(fixture.path().to_path_buf()) + .await + .expect("should open"); + + let remembered = recents.list().await.expect("should list"); + + assert_eq!(remembered.len(), 1); + assert!(remembered[0].exists); + assert_eq!( + remembered[0].path.canonicalize().ok(), + fixture.path().canonicalize().ok(), + "the working tree is what a person recognises, not the git directory" + ); +} + +#[tokio::test] +async fn names_a_repository_after_its_folder() { + let fixture = Fixture::with_one_commit(); + let (recents, _directory) = recents(); + let workspace = workspace(recents.clone()); + + workspace + .open(fixture.path().to_path_buf()) + .await + .expect("should open"); + + let remembered = recents.list().await.expect("should list"); + let expected = fixture + .path() + .file_name() + .expect("the fixture has a folder name") + .to_string_lossy() + .into_owned(); + + assert_eq!(remembered[0].name, expected); +} + +#[tokio::test] +async fn does_not_remember_a_repository_that_failed_to_open() { + let directory = tempfile::tempdir().expect("could not create a temporary directory"); + let (recents, _home) = recents(); + let workspace = workspace(recents.clone()); + + workspace + .open(directory.path().to_path_buf()) + .await + .expect_err("there is no repository there"); + + assert!(recents.list().await.expect("should list").is_empty()); +} + +#[tokio::test] +async fn opening_again_moves_a_repository_to_the_front_rather_than_duplicating_it() { + let first = Fixture::with_one_commit(); + let second = Fixture::with_one_commit(); + let (recents, _directory) = recents(); + let workspace = workspace(recents.clone()); + + workspace + .open(first.path().to_path_buf()) + .await + .expect("should open"); + workspace + .open(second.path().to_path_buf()) + .await + .expect("should open"); + workspace + .open(first.path().to_path_buf()) + .await + .expect("should reopen"); + + let remembered = recents.list().await.expect("should list"); + + assert_eq!(remembered.len(), 2, "reopening must not add a second entry"); + assert_eq!( + remembered[0].path.canonicalize().ok(), + first.path().canonicalize().ok(), + "the most recently opened comes first" + ); +} + +#[tokio::test] +async fn keeps_only_the_most_recent_twenty() { + let (recents, _directory) = recents(); + + for index in 0..25 { + recents + .record(entry(&format!("/repositories/number-{index}"))) + .await + .expect("should record"); + } + + let remembered = recents.list().await.expect("should list"); + + assert_eq!(remembered.len(), 20); + assert_eq!(remembered[0].name, "number-24", "newest first"); + assert_eq!(remembered[19].name, "number-5", "the oldest five fell off"); +} + +#[tokio::test] +async fn forgetting_removes_a_repository() { + let (recents, _directory) = recents(); + recents + .record(entry("/repositories/one")) + .await + .expect("should record"); + recents + .record(entry("/repositories/two")) + .await + .expect("should record"); + + let forgotten = recents + .forget(id("/repositories/one/.git")) + .await + .expect("should forget"); + + assert!(forgotten); + let remembered = recents.list().await.expect("should list"); + assert_eq!(remembered.len(), 1); + assert_eq!(remembered[0].name, "two"); +} + +#[tokio::test] +async fn forgetting_something_unknown_is_not_an_error() { + // The UI may well ask twice, or ask about something already gone. + let (recents, _directory) = recents(); + + assert!(!recents + .forget(id("/never/seen/.git")) + .await + .expect("should not fail")); +} + +#[tokio::test] +async fn the_list_survives_a_restart() { + let directory = tempfile::tempdir().expect("could not create a temporary directory"); + let file = directory.path().join("recent-repositories.json"); + + let (first, actor) = RecentsHandle::new(file.clone()); + tokio::spawn(actor); + first + .record(entry("/repositories/kept")) + .await + .expect("should record"); + // Wait for the write by asking for something the actor answers after it. + first.list().await.expect("should list"); + drop(first); + + let (second, actor) = RecentsHandle::new(file); + tokio::spawn(actor); + + let remembered = second.list().await.expect("should list"); + + assert_eq!(remembered.len(), 1); + assert_eq!(remembered[0].name, "kept"); +} + +#[tokio::test] +async fn a_repository_that_has_gone_away_is_marked_rather_than_dropped() { + // Deleting a folder should not silently erase it from the list — the user + // may want to see it was there, and to forget it deliberately. + let (recents, _directory) = recents(); + let fixture = Fixture::with_one_commit(); + let workspace = workspace(recents.clone()); + + workspace + .open(fixture.path().to_path_buf()) + .await + .expect("should open"); + assert!(recents.list().await.expect("should list")[0].exists); + + drop(fixture); + + let remembered = recents.list().await.expect("should list"); + assert_eq!(remembered.len(), 1); + assert!( + !remembered[0].exists, + "a missing repository should be flagged" + ); +} + +#[tokio::test] +async fn an_unreadable_file_is_treated_as_an_empty_list() { + // Better to start over than to refuse to run because a convenience cache + // got mangled. + let directory = tempfile::tempdir().expect("could not create a temporary directory"); + let file = directory.path().join("recent-repositories.json"); + std::fs::write(&file, "this is not json").expect("should write"); + + let (recents, actor) = RecentsHandle::new(file); + tokio::spawn(actor); + + assert!(recents.list().await.expect("should list").is_empty()); +} + +#[tokio::test] +async fn entries_cross_the_boundary_in_the_shape_the_frontend_expects() { + let (recents, _directory) = recents(); + recents + .record(entry("/repositories/one")) + .await + .expect("should record"); + + let remembered = recents.list().await.expect("should list"); + let json = serde_json::to_value(&remembered[0]).expect("should serialise"); + + assert!(json["id"].is_string()); + assert!(json["path"].is_string()); + assert_eq!(json["name"], "one"); + assert!(json["lastOpened"].is_number()); + assert!(json["exists"].is_boolean()); +} + +/// A recent entry for a path that need not exist. +fn entry(path: &str) -> gigit_lib::RecentRepository { + let json = serde_json::json!({ + "id": format!("{path}/.git"), + "path": path, + "name": PathBuf::from(path) + .file_name() + .expect("a path with a last segment") + .to_string_lossy(), + "lastOpened": 1_700_000_000, + "exists": true, + }); + + serde_json::from_value(json).expect("should deserialise") +} diff --git a/src-tauri/tests/streaming.rs b/src-tauri/tests/streaming.rs index 8040b31..a1f243f 100644 --- a/src-tauri/tests/streaming.rs +++ b/src-tauri/tests/streaming.rs @@ -7,7 +7,7 @@ use std::sync::{Arc, Mutex}; use gigit_git::testing::Fixture; -use gigit_lib::{RepositoryId, WorkspaceHandle}; +use gigit_lib::{RecentsHandle, RepositoryId, WorkspaceHandle}; use tauri::ipc::Channel; /// Collects everything sent down a channel, as parsed JSON. @@ -60,7 +60,14 @@ impl Received { } fn workspace() -> WorkspaceHandle { - let (handle, actor) = WorkspaceHandle::new(); + // These tests say nothing about recents, but the workspace needs somewhere + // to record them. A throwaway path under the temp directory keeps them off + // the developer's real config directory. + let file = std::env::temp_dir().join(format!("gigit-test-recents-{}.json", std::process::id())); + let (recents, remembering) = RecentsHandle::new(file); + tokio::spawn(remembering); + + let (handle, actor) = WorkspaceHandle::new(recents); tokio::spawn(actor); handle diff --git a/src-tauri/tests/workspace.rs b/src-tauri/tests/workspace.rs index 8965d67..1b1802e 100644 --- a/src-tauri/tests/workspace.rs +++ b/src-tauri/tests/workspace.rs @@ -6,11 +6,18 @@ use gigit_git::testing::Fixture; use gigit_git::{Head, RefKind}; -use gigit_lib::{RepositoryId, WorkspaceHandle}; +use gigit_lib::{RecentsHandle, RepositoryId, WorkspaceHandle}; /// Start a workspace actor on the test runtime and hand back its handle. fn workspace() -> WorkspaceHandle { - let (handle, actor) = WorkspaceHandle::new(); + // These tests say nothing about recents, but the workspace needs somewhere + // to record them. A throwaway path under the temp directory keeps them off + // the developer's real config directory. + let file = std::env::temp_dir().join(format!("gigit-test-recents-{}.json", std::process::id())); + let (recents, remembering) = RecentsHandle::new(file); + tokio::spawn(remembering); + + let (handle, actor) = WorkspaceHandle::new(recents); tokio::spawn(actor); handle -- 2.51.2