From 4fefeb26c0cf6ed22e17bb7e3315e8873598d5eb Mon Sep 17 00:00:00 2001 From: Nikita Galaiko Date: Thu, 13 Apr 2023 13:37:46 +0200 Subject: [PATCH] define file change listener --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/app/dispatchers/file_change.rs | 99 ++++++++++ src-tauri/src/app/dispatchers/mod.rs | 2 + src-tauri/src/app/dispatchers/tick.rs | 47 +++++ src-tauri/src/app/gb_repository.rs | 63 ++++++- src-tauri/src/app/listeners/file_change.rs | 157 ++++++++++++++++ .../src/app/listeners/file_change_tests.rs | 155 ++++++++++++++++ src-tauri/src/app/listeners/mod.rs | 4 + src-tauri/src/app/mod.rs | 10 +- src-tauri/src/app/project_repository.rs | 36 ++++ src-tauri/src/app/reader.rs | 174 +++++++++++++----- src-tauri/src/app/reader_tests.rs | 56 +++++- src-tauri/src/app/session.rs | 14 +- src-tauri/src/app/watcher.rs | 85 +++++++++ src-tauri/src/app/writer.rs | 18 +- src-tauri/src/main.rs | 7 + src-tauri/src/sessions/mod.rs | 2 +- src-tauri/src/sessions/sessions.rs | 41 ++++- src-tauri/src/sessions/sessions_tests.rs | 6 +- src-tauri/src/sessions/storage/persistent.rs | 4 +- src-tauri/src/watchers/delta.rs | 2 +- 22 files changed, 890 insertions(+), 94 deletions(-) create mode 100644 src-tauri/src/app/dispatchers/file_change.rs create mode 100644 src-tauri/src/app/dispatchers/mod.rs create mode 100644 src-tauri/src/app/dispatchers/tick.rs create mode 100644 src-tauri/src/app/listeners/file_change.rs create mode 100644 src-tauri/src/app/listeners/file_change_tests.rs create mode 100644 src-tauri/src/app/listeners/mod.rs create mode 100644 src-tauri/src/app/project_repository.rs create mode 100644 src-tauri/src/app/watcher.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c1787d340..dac9ccbc9 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1319,6 +1319,7 @@ dependencies = [ "timed", "tokio", "tokio-tungstenite", + "tokio-util", "urlencoding", "uuid 1.3.0", "walkdir", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c402c8bf6..997dcf251 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -46,6 +46,7 @@ futures = "0.3" futures-util = "0.3.8" timed = "0.2.1" serde-jsonlines = "0.4.0" +tokio-util = "0.7.7" [features] # by default Tauri runs in production mode diff --git a/src-tauri/src/app/dispatchers/file_change.rs b/src-tauri/src/app/dispatchers/file_change.rs new file mode 100644 index 000000000..6f03ba78a --- /dev/null +++ b/src-tauri/src/app/dispatchers/file_change.rs @@ -0,0 +1,99 @@ +use std::{ + path::PathBuf, + sync::{Arc, Mutex}, +}; + +use crate::projects; +use anyhow::Result; +use notify::{Config, RecommendedWatcher, Watcher}; +use tokio::sync; + +#[derive(Debug, Clone)] +pub struct Dispatcher { + watcher: Arc>>, + project_path: String, + project_id: String, +} + +impl Dispatcher { + pub fn new(project: &projects::Project) -> Self { + Self { + watcher: Arc::new(Mutex::new(None)), + project_path: project.path.clone(), + project_id: project.id.clone(), + } + } + + pub fn stop(&self) -> Result<()> { + if let Some(mut watcher) = self.watcher.lock().unwrap().take() { + watcher.unwatch(&std::path::Path::new(&self.project_path))?; + } + Ok(()) + } + + pub async fn start(&self, rtx: sync::mpsc::Sender) -> Result<()> { + let (mut watcher, mut rx) = async_watcher()?; + + watcher.watch( + &std::path::Path::new(&self.project_path), + notify::RecursiveMode::Recursive, + )?; + self.watcher.lock().unwrap().replace(watcher); + + log::info!("{}: file watcher started", self.project_id); + + while let Some(res) = rx.recv().await { + match res { + Ok(event) => { + if !is_interesting_event(&event.kind) { + continue; + } + for file_path in event.paths { + let relative_file_path = + file_path.strip_prefix(&self.project_path).unwrap(); + if let Err(e) = rtx.send(relative_file_path.to_path_buf()).await { + log::error!( + "{}: failed to send file change event: {:#}", + self.project_id, + e + ); + } + } + } + Err(e) => log::error!("{}: file watcher error: {:#}", self.project_id, e), + } + } + + log::info!("{}: file watcher stopped", self.project_id); + + Ok(()) + } +} + +fn is_interesting_event(kind: ¬ify::EventKind) -> bool { + match kind { + notify::EventKind::Create(notify::event::CreateKind::File) => true, + notify::EventKind::Modify(notify::event::ModifyKind::Data(_)) => true, + notify::EventKind::Modify(notify::event::ModifyKind::Name(_)) => true, + notify::EventKind::Remove(notify::event::RemoveKind::File) => true, + _ => false, + } +} + +fn async_watcher() -> notify::Result<( + RecommendedWatcher, + sync::mpsc::Receiver>, +)> { + let (tx, rx) = sync::mpsc::channel(1); + + let watcher = RecommendedWatcher::new( + move |res| { + futures::executor::block_on(async { + tx.send(res).await.unwrap(); + }) + }, + Config::default(), + )?; + + Ok((watcher, rx)) +} diff --git a/src-tauri/src/app/dispatchers/mod.rs b/src-tauri/src/app/dispatchers/mod.rs new file mode 100644 index 000000000..9ca418a06 --- /dev/null +++ b/src-tauri/src/app/dispatchers/mod.rs @@ -0,0 +1,2 @@ +pub mod file_change; +pub mod tick; diff --git a/src-tauri/src/app/dispatchers/tick.rs b/src-tauri/src/app/dispatchers/tick.rs new file mode 100644 index 000000000..01d1bba2a --- /dev/null +++ b/src-tauri/src/app/dispatchers/tick.rs @@ -0,0 +1,47 @@ +use crate::projects; +use anyhow::Result; +use std::sync::{atomic::AtomicBool, Arc}; +use tokio::{sync, time}; + +#[derive(Debug, Clone)] +pub struct Dispatcher { + project_id: String, + stop: Arc, +} + +impl Dispatcher { + pub fn new(project: &projects::Project) -> Self { + Self { + project_id: project.id.clone(), + stop: AtomicBool::new(false).into(), + } + } + + pub fn stop(&self) -> Result<()> { + self.stop.store(true, std::sync::atomic::Ordering::Relaxed); + Ok(()) + } + + pub async fn start( + &self, + interval: time::Duration, + rtx: sync::mpsc::Sender, + ) -> Result<()> { + let mut interval = time::interval(interval); + log::info!("{}: ticker started", self.project_id); + loop { + if self.stop.load(std::sync::atomic::Ordering::Relaxed) { + break; + } + + let tick = interval.tick().await; + + if let Err(e) = rtx.send(tick.into_std()).await { + log::error!("{}: failed to send tick event: {:#}", self.project_id, e); + } + } + log::info!("{}: ticker stopped", self.project_id); + + Ok(()) + } +} diff --git a/src-tauri/src/app/gb_repository.rs b/src-tauri/src/app/gb_repository.rs index f0c3b357f..e21e08371 100644 --- a/src-tauri/src/app/gb_repository.rs +++ b/src-tauri/src/app/gb_repository.rs @@ -1,3 +1,4 @@ +use super::{reader, writer}; use crate::{projects, sessions}; use anyhow::{anyhow, Context, Ok, Result}; @@ -7,9 +8,37 @@ pub struct Repository { } impl Repository { - pub fn open(project: &projects::Project) -> Result { - let git_repository = git2::Repository::open(&project.path) - .with_context(|| format!("{}: failed to open git repository", project.path))?; + pub fn open>(root: P, project: &projects::Project) -> Result { + let root = root.as_ref(); + let path = root.join(&project.id); + let git_repository = if path.exists() { + git2::Repository::open(path) + .with_context(|| format!("{}: failed to open git repository", project.path))? + } else { + // TODO: flush first session instead + + let git_repository = git2::Repository::init_opts( + &path, + &git2::RepositoryInitOptions::new() + .initial_head("refs/heads/current") + .external_template(false), + ) + .with_context(|| format!("{}: failed to initialize git repository", project.path))?; + + let mut index = git_repository.index()?; + let oid = index.write_tree()?; + let signature = git2::Signature::now("gitbutler", "gitbutler@localhost").unwrap(); + git_repository.commit( + Some("HEAD"), + &signature, + &signature, + "Initial commit", + &git_repository.find_tree(oid)?, + &[], + )?; + + git_repository + }; Ok(Self { project_id: project.id.clone(), git_repository, @@ -20,6 +49,34 @@ impl Repository { Err(anyhow!("TODO")) } + pub fn get_wd_writer(&self) -> writer::DirWriter { + writer::DirWriter::open(self.root()) + } + + pub fn get_wd_reader(&self) -> reader::DirReader { + reader::DirReader::open(self.root()) + } + + pub fn get_head_reader(&self) -> Result { + let head = self.git_repository.head().context("failed to get HEAD")?; + let commit = head.peel_to_commit().context("failed to get HEAD commit")?; + let reader = reader::CommitReader::from_commit(&self.git_repository, commit)?; + Ok(reader) + } + + pub fn get_current_session(&self) -> Result> { + let reader = reader::DirReader::open(self.root()); + match sessions::Session::try_from(reader) { + Result::Ok(session) => Ok(Some(session)), + Err(sessions::SessionError::NoSession) => Ok(None), + Err(sessions::SessionError::Err(err)) => Err(err), + } + } + + pub(crate) fn root(&self) -> &std::path::Path { + self.git_repository.path().parent().unwrap() + } + pub(crate) fn session_path(&self) -> std::path::PathBuf { self.git_repository.path().parent().unwrap().join("session") } diff --git a/src-tauri/src/app/listeners/file_change.rs b/src-tauri/src/app/listeners/file_change.rs new file mode 100644 index 000000000..37774d47c --- /dev/null +++ b/src-tauri/src/app/listeners/file_change.rs @@ -0,0 +1,157 @@ +use crate::{ + app::{ + gb_repository, project_repository, + reader::{self, Reader}, + writer::Writer, + }, + deltas::{self, TextDocument}, + projects, +}; +use anyhow::{anyhow, Context, Result}; + +pub struct Listener<'listener> { + project_id: String, + project_repository: &'listener project_repository::Repository, + gb_repository: &'listener gb_repository::Repository, +} + +impl<'listener> Listener<'listener> { + pub fn new( + project: &projects::Project, + project_repository: &'listener project_repository::Repository, + gb_repository: &'listener gb_repository::Repository, + ) -> Self { + Self { + project_id: project.id.clone(), + project_repository, + gb_repository, + } + } + + fn get_current_file_content(&self, path: &std::path::Path) -> Result> { + if self.project_repository.is_path_ignored(path)? { + return Ok(None); + } + + let reader = self.project_repository.get_wd_reader(); + + let path = path.to_str().unwrap(); + if reader.size(path)? > 100_000 { + log::warn!("{}: ignoring large file: {}", self.project_id, path); + return Ok(None); + } + + match reader.read(path)? { + reader::Content::UTF8(content) => Ok(Some(content)), + reader::Content::Binary(_) => { + log::warn!("{}: ignoring non-utf8 file: {}", self.project_id, path); + return Ok(None); + } + } + } + + fn get_latest_file_contents(&self, path: &std::path::Path) -> Result> { + let path = path.to_str().unwrap(); + let gb_head_reader = self + .gb_repository + .get_head_reader() + .with_context(|| "failed to get gb head reader")?; + let project_head_reader = self + .project_repository + .get_head_reader() + .with_context(|| "failed to get project head reader")?; + let reader = if gb_head_reader.exists(path) { + gb_head_reader + } else if project_head_reader.exists(path) { + project_head_reader + } else { + return Ok(None); + }; + if reader.size(path)? > 100_000 { + log::warn!("{}: ignoring large file: {}", self.project_id, path); + return Ok(None); + } + match reader.read(path)? { + reader::Content::UTF8(content) => Ok(Some(content)), + reader::Content::Binary(_) => { + log::warn!("{}: ignoring non-utf8 file: {}", self.project_id, path); + return Ok(None); + } + } + } + + fn get_current_deltas(&self, path: &std::path::Path) -> Result>> { + let reader = self.gb_repository.get_wd_reader(); + let deltas_path = self.gb_repository.deltas_path().join(path); + match reader.read_to_string(deltas_path.to_str().unwrap()) { + Ok(content) => Ok(Some(serde_json::from_str(&content)?)), + Err(reader::Error::NotFound) => Ok(None), + Err(err) => Err(err.into()), + } + } + + pub fn register>(&self, path: P) -> Result<()> { + let path = path.as_ref(); + let current_file_content = match self + .get_current_file_content(&path) + .with_context(|| "failed to get current file content")? + { + Some(content) => content, + None => return Ok(()), + }; + + let latest_file_content = self + .get_latest_file_contents(&path) + .with_context(|| "failed to get latest file content")?; + + let current_deltas = self + .get_current_deltas(&path) + .with_context(|| "failed to get current deltas")?; + + let mut text_doc = match (latest_file_content, current_deltas) { + (Some(latest_contents), Some(deltas)) => { + deltas::TextDocument::new(Some(&latest_contents), deltas)? + } + (Some(latest_contents), None) => { + deltas::TextDocument::new(Some(&latest_contents), vec![])? + } + (None, Some(deltas)) => deltas::TextDocument::new(None, deltas)?, + (None, None) => deltas::TextDocument::new(None, vec![])?, + }; + + if !text_doc.update(¤t_file_content)? { + log::debug!( + "{}: {} no new deltas, ignoring", + self.project_id, + path.display() + ); + return Ok(()); + } + + log::info!("{}: {} changed", self.project_id, path.display()); + + let writer = self.gb_repository.get_wd_writer(); + + // save current deltas + writer + .write_string( + self.gb_repository + .deltas_path() + .join(path) + .to_str() + .unwrap(), + &serde_json::to_string(&text_doc.get_deltas())?, + ) + .with_context(|| "failed to write deltas")?; + + // save file contents corresponding to the deltas + writer + .write_string( + self.gb_repository.wd_path().join(path).to_str().unwrap(), + ¤t_file_content, + ) + .with_context(|| "failed to write file content")?; + + Ok(()) + } +} diff --git a/src-tauri/src/app/listeners/file_change_tests.rs b/src-tauri/src/app/listeners/file_change_tests.rs new file mode 100644 index 000000000..9ae84ee53 --- /dev/null +++ b/src-tauri/src/app/listeners/file_change_tests.rs @@ -0,0 +1,155 @@ +use crate::{ + app::{gb_repository, project_repository}, + deltas, projects, +}; +use anyhow::Result; +use tempfile::tempdir; + +use super::file_change::Listener; + +fn commit_all(repository: &git2::Repository) -> Result { + let mut index = repository.index()?; + index.add_all(&["."], git2::IndexAddOption::DEFAULT, None)?; + index.write()?; + let oid = index.write_tree()?; + let signature = git2::Signature::now("test", "test@email.com").unwrap(); + let commit_oid = repository.commit( + Some("HEAD"), + &signature, + &signature, + "some commit", + &repository.find_tree(oid)?, + &[&repository.find_commit(repository.refname_to_id("HEAD")?)?], + )?; + Ok(commit_oid) +} + +fn test_repository() -> Result { + let path = tempdir()?.path().to_str().unwrap().to_string(); + let repository = git2::Repository::init(&path)?; + let mut index = repository.index()?; + let oid = index.write_tree()?; + let signature = git2::Signature::now("test", "test@email.com").unwrap(); + repository.commit( + Some("HEAD"), + &signature, + &signature, + "Initial commit", + &repository.find_tree(oid)?, + &[], + )?; + Ok(repository) +} + +fn test_project(repository: &git2::Repository) -> Result { + let project = projects::Project::from_path( + repository + .path() + .parent() + .unwrap() + .to_str() + .unwrap() + .to_string(), + )?; + Ok(project) +} + +#[test] +fn test_register_existing_file() -> Result<()> { + let repository = test_repository()?; + let project = test_project(&repository)?; + let project_repo = project_repository::Repository::open(&project)?; + let gb_repo_path = tempdir()?.path().to_str().unwrap().to_string(); + let gb_repo = gb_repository::Repository::open(gb_repo_path, &project)?; + let listener = Listener::new(&project, &project_repo, &gb_repo); + + let file_path = std::path::Path::new("test.txt"); + std::fs::write(project_repo.root().join(file_path), "test")?; + commit_all(&repository)?; + + std::fs::write(project_repo.root().join(file_path), "test2")?; + listener.register(file_path)?; + + let raw_deltas = std::fs::read_to_string(gb_repo.deltas_path().join(file_path))?; + let deltas: Vec = serde_json::from_str(&raw_deltas)?; + assert_eq!(deltas.len(), 1); + assert_eq!(deltas[0].operations.len(), 1); + assert_eq!( + deltas[0].operations[0], + deltas::Operation::Insert((4, "2".to_string())), + ); + assert_eq!( + std::fs::read_to_string(gb_repo.wd_path().join(file_path))?, + "test2" + ); + + Ok(()) +} + +#[test] +fn test_register_new_file() -> Result<()> { + let repository = test_repository()?; + let project = test_project(&repository)?; + let project_repo = project_repository::Repository::open(&project)?; + let gb_repo_path = tempdir()?.path().to_str().unwrap().to_string(); + let gb_repo = gb_repository::Repository::open(gb_repo_path, &project)?; + let listener = Listener::new(&project, &project_repo, &gb_repo); + + let file_path = std::path::Path::new("test.txt"); + std::fs::write(project_repo.root().join(file_path), "test")?; + + listener.register(file_path)?; + + let raw_deltas = std::fs::read_to_string(gb_repo.deltas_path().join(file_path))?; + let deltas: Vec = serde_json::from_str(&raw_deltas)?; + assert_eq!(deltas.len(), 1); + assert_eq!(deltas[0].operations.len(), 1); + assert_eq!( + deltas[0].operations[0], + deltas::Operation::Insert((0, "test".to_string())), + ); + assert_eq!( + std::fs::read_to_string(gb_repo.wd_path().join(file_path))?, + "test" + ); + + Ok(()) +} + +#[test] +fn test_register_new_file_twice() -> Result<()> { + let repository = test_repository()?; + let project = test_project(&repository)?; + let project_repo = project_repository::Repository::open(&project)?; + let gb_repo_path = tempdir()?.path().to_str().unwrap().to_string(); + let gb_repo = gb_repository::Repository::open(gb_repo_path, &project)?; + let listener = Listener::new(&project, &project_repo, &gb_repo); + + let file_path = std::path::Path::new("test.txt"); + std::fs::write(project_repo.root().join(file_path), "test")?; + + listener.register(file_path)?; + + std::fs::write(project_repo.root().join(file_path), "test2")?; + listener.register(file_path)?; + + let raw_deltas = std::fs::read_to_string(gb_repo.deltas_path().join(file_path))?; + let deltas: Vec = serde_json::from_str(&raw_deltas)?; + assert_eq!(deltas.len(), 2); + assert_eq!(deltas[0].operations.len(), 1); + assert_eq!( + deltas[0].operations[0], + deltas::Operation::Insert((0, "test".to_string())), + ); + assert_eq!(deltas[1].operations.len(), 1); + assert_eq!( + deltas[1].operations[0], + deltas::Operation::Insert((4, "2".to_string())), + ); + assert_eq!( + std::fs::read_to_string(gb_repo.wd_path().join(file_path))?, + "test2" + ); + + Ok(()) +} diff --git a/src-tauri/src/app/listeners/mod.rs b/src-tauri/src/app/listeners/mod.rs new file mode 100644 index 000000000..db97b8b63 --- /dev/null +++ b/src-tauri/src/app/listeners/mod.rs @@ -0,0 +1,4 @@ +mod file_change; + +#[cfg(test)] +mod file_change_tests; diff --git a/src-tauri/src/app/mod.rs b/src-tauri/src/app/mod.rs index 22941bd3c..edfc694d3 100644 --- a/src-tauri/src/app/mod.rs +++ b/src-tauri/src/app/mod.rs @@ -1,7 +1,13 @@ -mod gb_repository; +mod dispatchers; +pub mod gb_repository; +mod listeners; +mod project_repository; pub mod reader; -pub mod session; +mod session; +mod watcher; mod writer; #[cfg(test)] mod reader_tests; + +pub struct App {} diff --git a/src-tauri/src/app/project_repository.rs b/src-tauri/src/app/project_repository.rs new file mode 100644 index 000000000..79def6904 --- /dev/null +++ b/src-tauri/src/app/project_repository.rs @@ -0,0 +1,36 @@ +use super::reader; +use crate::projects; +use anyhow::{Context, Result}; + +pub struct Repository { + git_repository: git2::Repository, +} + +impl Repository { + pub fn open(project: &projects::Project) -> Result { + let git_repository = git2::Repository::open(&project.path) + .with_context(|| format!("{}: failed to open git repository", project.path))?; + Ok(Self { git_repository }) + } + + pub fn is_path_ignored>(&self, path: P) -> Result { + let path = path.as_ref(); + let ignored = self.git_repository.is_path_ignored(path)?; + Ok(ignored) + } + + pub fn get_wd_reader(&self) -> reader::DirReader { + reader::DirReader::open(self.root()) + } + + pub fn get_head_reader(&self) -> Result { + let head = self.git_repository.head()?; + let commit = head.peel_to_commit()?; + let reader = reader::CommitReader::from_commit(&self.git_repository, commit)?; + Ok(reader) + } + + pub fn root(&self) -> &std::path::Path { + self.git_repository.path().parent().unwrap() + } +} diff --git a/src-tauri/src/app/reader.rs b/src-tauri/src/app/reader.rs index 3b80da13e..9a727f7de 100644 --- a/src-tauri/src/app/reader.rs +++ b/src-tauri/src/app/reader.rs @@ -1,43 +1,81 @@ +use crate::fs; use anyhow::{Context, Result}; -use crate::fs; +#[derive(Debug, PartialEq)] +pub enum Content { + UTF8(String), + Binary(Vec), +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("file not found")] + NotFound, + #[error("io error: {0}")] + Other(std::io::Error), +} pub trait Reader { - fn read_to_string(&self, file_path: &str) -> Result; + fn read(&self, file_path: &str) -> Result; fn list_files(&self, dir_path: &str) -> Result>; -} + fn exists(&self, file_path: &str) -> bool; + fn size(&self, file_path: &str) -> Result; -pub struct WdReader<'reader> { - git_repository: &'reader git2::Repository, -} - -impl WdReader<'_> { - pub fn read_to_string(&self, path: &str) -> Result { - let contents = - std::fs::read_to_string(self.git_repository.path().parent().unwrap().join(path)) - .with_context(|| format!("{}: not found", path))?; - Ok(contents) + fn read_to_string(&self, file_path: &str) -> Result { + match self.read(file_path)? { + Content::UTF8(s) => Ok(s), + Content::Binary(_) => Err(Error::Other(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "file is not utf8", + ))), + } } } -impl Reader for WdReader<'_> { - fn read_to_string(&self, path: &str) -> Result { - self.read_to_string(path) +pub struct DirReader<'reader> { + root: &'reader std::path::Path, +} + +impl<'reader> DirReader<'reader> { + pub fn open(root: &'reader std::path::Path) -> Self { + Self { root } + } +} + +impl Reader for DirReader<'_> { + fn size(&self, file_path: &str) -> Result { + let path = self.root.join(file_path); + if !path.exists() { + return Ok(0); + } + let metadata = std::fs::metadata(path)?; + Ok(metadata.len().try_into()?) + } + + fn read(&self, path: &str) -> Result { + let path = self.root.join(path); + if !path.exists() { + return Err(Error::NotFound); + } + let content = std::fs::read(path).map_err(Error::Other)?; + match String::from_utf8_lossy(&content).into_owned() { + s if s.as_bytes().eq(&content) => Ok(Content::UTF8(s)), + _ => Ok(Content::Binary(content)), + } } fn list_files(&self, dir_path: &str) -> Result> { - let files: Vec = - fs::list_files(self.git_repository.path().parent().unwrap().join(dir_path))? - .iter() - .map(|f| f.to_str().unwrap().to_string()) - .filter(|f| !f.starts_with(".git")) - .collect(); + let files: Vec = fs::list_files(self.root.join(dir_path))? + .iter() + .map(|f| f.to_str().unwrap().to_string()) + .filter(|f| !f.starts_with(".git")) + .collect(); Ok(files) } -} -pub fn get_working_directory_reader(git_repository: &git2::Repository) -> WdReader { - WdReader { git_repository } + fn exists(&self, file_path: &str) -> bool { + std::path::Path::new(self.root.join(file_path).as_path()).exists() + } } pub struct CommitReader<'reader> { @@ -46,24 +84,71 @@ pub struct CommitReader<'reader> { tree: git2::Tree<'reader>, } -impl CommitReader<'_> { +impl<'reader> CommitReader<'reader> { + pub fn from_commit( + repository: &'reader git2::Repository, + commit: git2::Commit<'reader>, + ) -> Result> { + let tree = commit + .tree() + .with_context(|| format!("{}: tree not found", commit.id()))?; + Ok(CommitReader { + repository, + tree, + commit_oid: commit.id(), + }) + } + + pub fn open( + repository: &'reader git2::Repository, + commit_oid: git2::Oid, + ) -> Result> { + let commit = repository + .find_commit(commit_oid) + .with_context(|| format!("{}: commit not found", commit_oid))?; + return CommitReader::from_commit(repository, commit); + } + pub fn get_commit_oid(&self) -> git2::Oid { self.commit_oid } } impl Reader for CommitReader<'_> { - fn read_to_string(&self, path: &str) -> Result { - let entry = self + fn size(&self, file_path: &str) -> Result { + let entry = match self + .tree + .get_path(std::path::Path::new(file_path)) + .with_context(|| format!("{}: tree entry not found", file_path)) + { + Ok(entry) => entry, + Err(_) => return Ok(0), + }; + let blob = match self.repository.find_blob(entry.id()) { + Ok(blob) => blob, + Err(_) => return Ok(0), + }; + Ok(blob.size()) + } + + fn read(&self, path: &str) -> Result { + let entry = match self .tree .get_path(std::path::Path::new(path)) - .with_context(|| format!("{}: tree entry not found", path))?; - let blob = self - .repository - .find_blob(entry.id()) - .with_context(|| format!("{}: blob not found", entry.id()))?; - let contents = String::from_utf8_lossy(blob.content()).to_string(); - Ok(contents) + .with_context(|| format!("{}: tree entry not found", path)) + { + Ok(entry) => entry, + Err(_) => return Err(Error::NotFound), + }; + let blob = match self.repository.find_blob(entry.id()) { + Ok(blob) => blob, + Err(_) => return Err(Error::NotFound), + }; + let content = blob.content(); + match String::from_utf8_lossy(&content).into_owned() { + s if s.as_bytes().eq(content) => Ok(Content::UTF8(s)), + _ => Ok(Content::Binary(content.to_vec())), + } } fn list_files(&self, dir_path: &str) -> Result> { @@ -97,21 +182,8 @@ impl Reader for CommitReader<'_> { Ok(files) } -} -pub fn get_commit_reader<'reader>( - repository: &'reader git2::Repository, - commit_oid: git2::Oid, -) -> Result> { - let commit = repository - .find_commit(commit_oid) - .with_context(|| format!("{}: commit not found", commit_oid))?; - let tree = commit - .tree() - .with_context(|| format!("{}: tree not found", commit_oid))?; - Ok(CommitReader { - repository, - tree, - commit_oid, - }) + fn exists(&self, file_path: &str) -> bool { + self.tree.get_path(std::path::Path::new(file_path)).is_ok() + } } diff --git a/src-tauri/src/app/reader_tests.rs b/src-tauri/src/app/reader_tests.rs index 35a2adcf8..27df03d65 100644 --- a/src-tauri/src/app/reader_tests.rs +++ b/src-tauri/src/app/reader_tests.rs @@ -1,4 +1,4 @@ -use super::reader::Reader; +use super::reader::{self, Reader}; use anyhow::Result; use tempfile::tempdir; @@ -43,8 +43,11 @@ fn test_working_directory_reader_read_file() -> Result<()> { let file_path = "test.txt"; std::fs::write(&repository.path().parent().unwrap().join(file_path), "test")?; - let reader = super::reader::get_working_directory_reader(&repository); - assert_eq!(reader.read_to_string(&file_path)?, "test"); + let reader = reader::DirReader::open(&repository.path().parent().unwrap()); + assert_eq!( + reader.read(&file_path)?, + reader::Content::UTF8("test".to_string()) + ); Ok(()) } @@ -63,8 +66,11 @@ fn test_commit_reader_read_file() -> Result<()> { "test2", )?; - let reader = super::reader::get_commit_reader(&repository, oid)?; - assert_eq!(reader.read_to_string(&file_path)?, "test"); + let reader = reader::CommitReader::open(&repository, oid)?; + assert_eq!( + reader.read(&file_path)?, + reader::Content::UTF8("test".to_string()) + ); Ok(()) } @@ -88,7 +94,7 @@ fn test_working_directory_reader_list_files() -> Result<()> { "test", )?; - let reader = super::reader::get_working_directory_reader(&repository); + let reader = super::reader::DirReader::open(&repository.path().parent().unwrap()); let files = reader.list_files(".")?; assert_eq!(files.len(), 2); assert!(files.contains(&"test.txt".to_string())); @@ -120,7 +126,7 @@ fn test_commit_reader_list_files() -> Result<()> { std::fs::remove_dir_all(&repository.path().parent().unwrap().join("dir"))?; - let reader = super::reader::get_commit_reader(&repository, oid)?; + let reader = super::reader::CommitReader::open(&repository, oid)?; let files = reader.list_files(".")?; assert_eq!(files.len(), 2); assert!(files.contains(&"test.txt".to_string())); @@ -128,3 +134,39 @@ fn test_commit_reader_list_files() -> Result<()> { Ok(()) } + +#[test] +fn test_working_directory_reader_exists() -> Result<()> { + let repository = test_repository()?; + + std::fs::write( + &repository.path().parent().unwrap().join("test.txt"), + "test", + )?; + + let reader = super::reader::DirReader::open(&repository.path().parent().unwrap()); + assert!(reader.exists("test.txt")); + assert!(!reader.exists("test2.txt")); + + Ok(()) +} + +#[test] +fn test_commit_reader_exists() -> Result<()> { + let repository = test_repository()?; + + std::fs::write( + &repository.path().parent().unwrap().join("test.txt"), + "test", + )?; + + let oid = commit(&repository)?; + + std::fs::remove_file(&repository.path().parent().unwrap().join("test.txt"))?; + + let reader = super::reader::CommitReader::open(&repository, oid)?; + assert!(reader.exists("test.txt")); + assert!(!reader.exists("test2.txt")); + + Ok(()) +} diff --git a/src-tauri/src/app/session.rs b/src-tauri/src/app/session.rs index 7710a999d..cddff2325 100644 --- a/src-tauri/src/app/session.rs +++ b/src-tauri/src/app/session.rs @@ -1,11 +1,11 @@ -use std::collections::HashMap; - use super::{ - gb_repository as repository, reader, + gb_repository as repository, + reader::{self, Reader}, writer::{self, Writer}, }; use crate::{deltas, pty, sessions}; use anyhow::{anyhow, Context, Result}; +use std::collections::HashMap; pub struct SessionWriter<'writer> { repository: &'writer repository::Repository, @@ -17,7 +17,7 @@ impl<'writer> SessionWriter<'writer> { repository: &'writer repository::Repository, session: &'writer sessions::Session, ) -> Result { - let reader = reader::get_working_directory_reader(&repository.git_repository); + let reader = reader::DirReader::open(repository.root()); let current_session_id = reader.read_to_string( repository @@ -37,7 +37,7 @@ impl<'writer> SessionWriter<'writer> { )); } - let writer = writer::get_working_directory_writer(&repository.git_repository); + let writer = writer::DirWriter::open(repository.root()); writer .write_string( @@ -200,7 +200,7 @@ impl<'reader> SessionReader<'reader> { repository: &'reader repository::Repository, session: sessions::Session, ) -> Result { - let wd_reader = reader::get_working_directory_reader(&repository.git_repository); + let wd_reader = reader::DirReader::open(repository.root()); let current_session_id = wd_reader.read_to_string( &repository @@ -229,7 +229,7 @@ impl<'reader> SessionReader<'reader> { let oid = git2::Oid::from_str(&session_hash) .with_context(|| format!("failed to parse commit hash {}", session_hash))?; - let commit_reader = reader::get_commit_reader(&repository.git_repository, oid)?; + let commit_reader = reader::CommitReader::open(&repository.git_repository, oid)?; Ok(SessionReader { reader: Box::new(commit_reader), repository, diff --git a/src-tauri/src/app/watcher.rs b/src-tauri/src/app/watcher.rs new file mode 100644 index 000000000..1b0f0d00c --- /dev/null +++ b/src-tauri/src/app/watcher.rs @@ -0,0 +1,85 @@ +use super::gb_repository; +use crate::{ + app::{ + dispatchers::{file_change, tick}, + reader, + }, + git, projects, +}; +use anyhow::Result; +use core::time; +use std::path::PathBuf; +use tokio::sync; + +pub struct Watcher<'watcher> { + project: &'watcher projects::Project, + gb_repository: &'watcher gb_repository::Repository, + + ticker: tick::Dispatcher, + file_watcher: file_change::Dispatcher, + + stop: tokio_util::sync::CancellationToken, +} + +impl<'watcher> Watcher<'watcher> { + pub fn new( + gb_repository: &'watcher gb_repository::Repository, + project: &'watcher projects::Project + ) -> Self { + Self { + gb_repository, + project, + ticker: tick::Dispatcher::new(project), + file_watcher: file_change::Dispatcher::new(project), + stop: tokio_util::sync::CancellationToken::new(), + } + } + + pub fn stop(&self) -> anyhow::Result<()> { + self.stop.cancel(); + Ok(()) + } + + pub async fn start(&self) -> Result<()> { + let (t_tx, mut t_rx) = sync::mpsc::channel(128); + let ticker = self.ticker.clone(); + let project_id = self.project.id.clone(); + tauri::async_runtime::spawn(async move { + if let Err(e) = ticker.start(time::Duration::from_secs(10), t_tx).await { + log::error!("{}: failed to start ticker: {:#}", project_id, e); + } + }); + + let (fw_tx, mut fw_rx) = sync::mpsc::channel(128); + let file_watcher = self.file_watcher.clone(); + let project_id = self.project.id.clone(); + tauri::async_runtime::spawn(async move { + if let Err(e) = file_watcher.start(fw_tx).await { + log::error!("{}: failed to start file watcher: {:#}", project_id, e); + } + }); + + let reader = reader::DirReader::open(&std::path::Path::new(&self.project.path)); + loop { + tokio::select! { + Some(ts) = t_rx.recv() => { + log::info!("{}: ticker ticked: {}", self.project.id, ts.elapsed().as_secs()); + } + Some(path) = fw_rx.recv() => { + log::info!("{}: file changed: {}", self.project.id, path.display()); + }, + _ = self.stop.cancelled() => { + if let Err(e) = self.ticker.stop() { + log::error!("{}: failed to stop ticker: {:#}", self.project.id, e); + } + if let Err(e) = self.file_watcher.stop() { + log::error!("{}: failed to stop file watcher: {:#}", self.project.id, e); + } + break; + } + } + } + + Ok(()) + } +} diff --git a/src-tauri/src/app/writer.rs b/src-tauri/src/app/writer.rs index edb40a1db..27eba0854 100644 --- a/src-tauri/src/app/writer.rs +++ b/src-tauri/src/app/writer.rs @@ -6,19 +6,19 @@ pub trait Writer { fn append_string(&self, path: &str, contents: &str) -> Result<()>; } -pub struct WdWriter<'writer> { - git_repository: &'writer git2::Repository, +pub struct DirWriter<'writer> { + root: &'writer std::path::Path, } -pub fn get_working_directory_writer<'writer>( - git_repository: &'writer git2::Repository, -) -> WdWriter { - WdWriter { git_repository } +impl<'writer> DirWriter<'writer> { + pub fn open(root: &'writer std::path::Path) -> Self { + Self { root } + } } -impl Writer for WdWriter<'_> { +impl Writer for DirWriter<'_> { fn write_string(&self, path: &str, contents: &str) -> Result<()> { - let file_path = self.git_repository.path().parent().unwrap().join(path); + let file_path = self.root.join(path); let dir_path = file_path.parent().unwrap(); std::fs::create_dir_all(dir_path)?; std::fs::write(path, contents)?; @@ -26,7 +26,7 @@ impl Writer for WdWriter<'_> { } fn append_string(&self, path: &str, contents: &str) -> Result<()> { - let file_path = self.git_repository.path().parent().unwrap().join(path); + let file_path = self.root.join(path); let mut file = std::fs::OpenOptions::new() .create(true) .append(true) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 0e9672642..380490a8f 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -718,6 +718,13 @@ fn init(app_handle: tauri::AppHandle) -> Result<()> { .watch(tx.clone(), &repo) .with_context(|| format!("{}: failed to watch project", project.id))?; + // let p = project.clone(); + // tauri::async_runtime::spawn(async move { + // let p = p; + // let w = app::watchers::Watcher::new(&p); + // w.start().await.expect("failed to start watcher"); + // }); + if let Err(err) = app_state .deltas_searcher .lock() diff --git a/src-tauri/src/sessions/mod.rs b/src-tauri/src/sessions/mod.rs index a29e66627..76143f516 100644 --- a/src-tauri/src/sessions/mod.rs +++ b/src-tauri/src/sessions/mod.rs @@ -1,7 +1,7 @@ mod sessions; mod storage; -pub use sessions::{id_from_commit, Meta, Session}; +pub use sessions::{id_from_commit, Meta, Session, SessionError}; pub use storage::Store; #[cfg(test)] diff --git a/src-tauri/src/sessions/sessions.rs b/src-tauri/src/sessions/sessions.rs index 3b43e6e1a..34c6c8bdc 100644 --- a/src-tauri/src/sessions/sessions.rs +++ b/src-tauri/src/sessions/sessions.rs @@ -2,6 +2,7 @@ use crate::{app::reader, git::activity}; use anyhow::{anyhow, Context, Result}; use serde::Serialize; use std::path::Path; +use thiserror::Error; #[derive(Debug, Clone, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] @@ -27,23 +28,40 @@ pub struct Session { pub activity: Vec, } +#[derive(Error, Debug)] +pub enum SessionError { + #[error("session does not exist")] + NoSession, + #[error("{0}")] + Err(anyhow::Error), +} + impl<'reader> TryFrom> for Session { - type Error = anyhow::Error; + type Error = SessionError; fn try_from(reader: Box) -> Result { + if !reader.exists("session") { + return Err(SessionError::NoSession); + } + let id = reader .read_to_string("session/meta/id") - .with_context(|| "failed to read session id")?; + .with_context(|| "failed to read session id") + .map_err(|err| SessionError::Err(err))?; let start_timestamp_ms = reader .read_to_string("session/meta/start") - .with_context(|| "failed to read session start timestamp")? + .with_context(|| "failed to read session start timestamp") + .map_err(|err| SessionError::Err(err))? .parse::() - .with_context(|| "failed to parse session start timestamp")?; + .with_context(|| "failed to parse session start timestamp") + .map_err(|err| SessionError::Err(err))?; let last_timestamp_ms = reader .read_to_string("session/meta/last") - .with_context(|| "failed to read session last timestamp")? + .with_context(|| "failed to read session last timestamp") + .map_err(|err| SessionError::Err(err))? .parse::() - .with_context(|| "failed to parse session last timestamp")?; + .with_context(|| "failed to parse session last timestamp") + .map_err(|err| SessionError::Err(err))?; let branch = reader.read_to_string("session/meta/branch"); let commit = reader.read_to_string("session/meta/commit"); @@ -69,8 +87,17 @@ impl<'reader> TryFrom> for Session { } } +impl<'reader> TryFrom> for Session { + type Error = SessionError; + + fn try_from(reader: reader::DirReader<'reader>) -> Result { + let session = Session::try_from(Box::new(reader) as Box)?; + Ok(session) + } +} + impl<'reader> TryFrom> for Session { - type Error = anyhow::Error; + type Error = SessionError; fn try_from(reader: reader::CommitReader<'reader>) -> Result { let commit_oid = reader.get_commit_oid().to_string(); diff --git a/src-tauri/src/sessions/sessions_tests.rs b/src-tauri/src/sessions/sessions_tests.rs index 5976b0423..b55e776dc 100644 --- a/src-tauri/src/sessions/sessions_tests.rs +++ b/src-tauri/src/sessions/sessions_tests.rs @@ -227,10 +227,8 @@ fn test_get_persistent() -> Result<()> { let commit_oid = git2::Oid::from_str(&created_session.hash.as_ref().unwrap())?; - let reader = reader::get_commit_reader(&repo, commit_oid)?; - let reconstructed = super::sessions::Session::try_from(reader); - assert!(reconstructed.is_ok()); - let reconstructed = reconstructed.unwrap(); + let reader = reader::CommitReader::open(&repo, commit_oid)?; + let reconstructed = super::sessions::Session::try_from(reader)?; assert_eq!(reconstructed, created_session); diff --git a/src-tauri/src/sessions/storage/persistent.rs b/src-tauri/src/sessions/storage/persistent.rs index c24cc8f1a..17e1b705a 100644 --- a/src-tauri/src/sessions/storage/persistent.rs +++ b/src-tauri/src/sessions/storage/persistent.rs @@ -57,7 +57,7 @@ impl Store { walker.set_sorting(git2::Sort::TIME)?; for commit_id in walker { - let reader = reader::get_commit_reader(&git_repository, commit_id?)?; + let reader = reader::CommitReader::open(&git_repository, commit_id?)?; if reader.read_to_string("session/meta/id")? == session_id { return Ok(Some(sessions::Session::try_from(reader)?)); } @@ -206,7 +206,7 @@ impl Store { let mut sessions: Vec = vec![]; for id in walker { let id = id?; - let reader = reader::get_commit_reader(&git_repository, id)?; + let reader = reader::CommitReader::open(&git_repository, id)?; let session = sessions::Session::try_from(reader)?; sessions.push(session); } diff --git a/src-tauri/src/watchers/delta.rs b/src-tauri/src/watchers/delta.rs index 5eafeba08..c7ce3015c 100644 --- a/src-tauri/src/watchers/delta.rs +++ b/src-tauri/src/watchers/delta.rs @@ -85,7 +85,7 @@ pub(crate) fn register_file_change( relative_file_path: &Path, ) -> Result)>> { let file_path = repo.workdir().unwrap().join(relative_file_path); - + if file_path.metadata()?.len() > MAX_FILE_SIZE { log::info!( "{}: \"{}\" is larger than 100K, ignoring",