//! 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() ); } }