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