define file change listener

This commit is contained in:
Nikita Galaiko 2023-04-13 13:37:46 +02:00
parent 614fcbee49
commit 4fefeb26c0
22 changed files with 890 additions and 94 deletions

1
src-tauri/Cargo.lock generated
View File

@ -1319,6 +1319,7 @@ dependencies = [
"timed", "timed",
"tokio", "tokio",
"tokio-tungstenite", "tokio-tungstenite",
"tokio-util",
"urlencoding", "urlencoding",
"uuid 1.3.0", "uuid 1.3.0",
"walkdir", "walkdir",

View File

@ -46,6 +46,7 @@ futures = "0.3"
futures-util = "0.3.8" futures-util = "0.3.8"
timed = "0.2.1" timed = "0.2.1"
serde-jsonlines = "0.4.0" serde-jsonlines = "0.4.0"
tokio-util = "0.7.7"
[features] [features]
# by default Tauri runs in production mode # by default Tauri runs in production mode

View File

@ -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<Mutex<Option<RecommendedWatcher>>>,
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<PathBuf>) -> 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: &notify::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<notify::Result<notify::Event>>,
)> {
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))
}

View File

@ -0,0 +1,2 @@
pub mod file_change;
pub mod tick;

View File

@ -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<AtomicBool>,
}
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<std::time::Instant>,
) -> 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(())
}
}

View File

@ -1,3 +1,4 @@
use super::{reader, writer};
use crate::{projects, sessions}; use crate::{projects, sessions};
use anyhow::{anyhow, Context, Ok, Result}; use anyhow::{anyhow, Context, Ok, Result};
@ -7,9 +8,37 @@ pub struct Repository {
} }
impl Repository { impl Repository {
pub fn open(project: &projects::Project) -> Result<Self> { pub fn open<P: AsRef<std::path::Path>>(root: P, project: &projects::Project) -> Result<Self> {
let git_repository = git2::Repository::open(&project.path) let root = root.as_ref();
.with_context(|| format!("{}: failed to open git repository", project.path))?; 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 { Ok(Self {
project_id: project.id.clone(), project_id: project.id.clone(),
git_repository, git_repository,
@ -20,6 +49,34 @@ impl Repository {
Err(anyhow!("TODO")) 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<reader::CommitReader> {
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<Option<sessions::Session>> {
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 { pub(crate) fn session_path(&self) -> std::path::PathBuf {
self.git_repository.path().parent().unwrap().join("session") self.git_repository.path().parent().unwrap().join("session")
} }

View File

@ -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<Option<String>> {
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<Option<String>> {
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<Option<Vec<deltas::Delta>>> {
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<P: AsRef<std::path::Path>>(&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(&current_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(),
&current_file_content,
)
.with_context(|| "failed to write file content")?;
Ok(())
}
}

View File

@ -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<git2::Oid> {
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<git2::Repository> {
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<projects::Project> {
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<deltas::Delta> = 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<deltas::Delta> = 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<deltas::Delta> = 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(())
}

View File

@ -0,0 +1,4 @@
mod file_change;
#[cfg(test)]
mod file_change_tests;

View File

@ -1,7 +1,13 @@
mod gb_repository; mod dispatchers;
pub mod gb_repository;
mod listeners;
mod project_repository;
pub mod reader; pub mod reader;
pub mod session; mod session;
mod watcher;
mod writer; mod writer;
#[cfg(test)] #[cfg(test)]
mod reader_tests; mod reader_tests;
pub struct App {}

View File

@ -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<Self> {
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<P: AsRef<std::path::Path>>(&self, path: P) -> Result<bool> {
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<reader::CommitReader> {
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()
}
}

View File

@ -1,43 +1,81 @@
use crate::fs;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use crate::fs; #[derive(Debug, PartialEq)]
pub enum Content {
UTF8(String),
Binary(Vec<u8>),
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("file not found")]
NotFound,
#[error("io error: {0}")]
Other(std::io::Error),
}
pub trait Reader { pub trait Reader {
fn read_to_string(&self, file_path: &str) -> Result<String>; fn read(&self, file_path: &str) -> Result<Content, Error>;
fn list_files(&self, dir_path: &str) -> Result<Vec<String>>; fn list_files(&self, dir_path: &str) -> Result<Vec<String>>;
} fn exists(&self, file_path: &str) -> bool;
fn size(&self, file_path: &str) -> Result<usize>;
pub struct WdReader<'reader> { fn read_to_string(&self, file_path: &str) -> Result<String, Error> {
git_repository: &'reader git2::Repository, match self.read(file_path)? {
} Content::UTF8(s) => Ok(s),
Content::Binary(_) => Err(Error::Other(std::io::Error::new(
impl WdReader<'_> { std::io::ErrorKind::InvalidData,
pub fn read_to_string(&self, path: &str) -> Result<String> { "file is not utf8",
let contents = ))),
std::fs::read_to_string(self.git_repository.path().parent().unwrap().join(path)) }
.with_context(|| format!("{}: not found", path))?;
Ok(contents)
} }
} }
impl Reader for WdReader<'_> { pub struct DirReader<'reader> {
fn read_to_string(&self, path: &str) -> Result<String> { root: &'reader std::path::Path,
self.read_to_string(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<usize> {
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<Content, Error> {
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<Vec<String>> { fn list_files(&self, dir_path: &str) -> Result<Vec<String>> {
let files: Vec<String> = let files: Vec<String> = fs::list_files(self.root.join(dir_path))?
fs::list_files(self.git_repository.path().parent().unwrap().join(dir_path))?
.iter() .iter()
.map(|f| f.to_str().unwrap().to_string()) .map(|f| f.to_str().unwrap().to_string())
.filter(|f| !f.starts_with(".git")) .filter(|f| !f.starts_with(".git"))
.collect(); .collect();
Ok(files) Ok(files)
} }
}
pub fn get_working_directory_reader(git_repository: &git2::Repository) -> WdReader { fn exists(&self, file_path: &str) -> bool {
WdReader { git_repository } std::path::Path::new(self.root.join(file_path).as_path()).exists()
}
} }
pub struct CommitReader<'reader> { pub struct CommitReader<'reader> {
@ -46,24 +84,71 @@ pub struct CommitReader<'reader> {
tree: git2::Tree<'reader>, tree: git2::Tree<'reader>,
} }
impl CommitReader<'_> { impl<'reader> CommitReader<'reader> {
pub fn from_commit(
repository: &'reader git2::Repository,
commit: git2::Commit<'reader>,
) -> Result<CommitReader<'reader>> {
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<CommitReader<'reader>> {
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 { pub fn get_commit_oid(&self) -> git2::Oid {
self.commit_oid self.commit_oid
} }
} }
impl Reader for CommitReader<'_> { impl Reader for CommitReader<'_> {
fn read_to_string(&self, path: &str) -> Result<String> { fn size(&self, file_path: &str) -> Result<usize> {
let entry = self 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<Content, Error> {
let entry = match self
.tree .tree
.get_path(std::path::Path::new(path)) .get_path(std::path::Path::new(path))
.with_context(|| format!("{}: tree entry not found", path))?; .with_context(|| format!("{}: tree entry not found", path))
let blob = self {
.repository Ok(entry) => entry,
.find_blob(entry.id()) Err(_) => return Err(Error::NotFound),
.with_context(|| format!("{}: blob not found", entry.id()))?; };
let contents = String::from_utf8_lossy(blob.content()).to_string(); let blob = match self.repository.find_blob(entry.id()) {
Ok(contents) 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<Vec<String>> { fn list_files(&self, dir_path: &str) -> Result<Vec<String>> {
@ -97,21 +182,8 @@ impl Reader for CommitReader<'_> {
Ok(files) Ok(files)
} }
}
pub fn get_commit_reader<'reader>( fn exists(&self, file_path: &str) -> bool {
repository: &'reader git2::Repository, self.tree.get_path(std::path::Path::new(file_path)).is_ok()
commit_oid: git2::Oid, }
) -> Result<CommitReader<'reader>> {
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,
})
} }

View File

@ -1,4 +1,4 @@
use super::reader::Reader; use super::reader::{self, Reader};
use anyhow::Result; use anyhow::Result;
use tempfile::tempdir; use tempfile::tempdir;
@ -43,8 +43,11 @@ fn test_working_directory_reader_read_file() -> Result<()> {
let file_path = "test.txt"; let file_path = "test.txt";
std::fs::write(&repository.path().parent().unwrap().join(file_path), "test")?; std::fs::write(&repository.path().parent().unwrap().join(file_path), "test")?;
let reader = super::reader::get_working_directory_reader(&repository); let reader = reader::DirReader::open(&repository.path().parent().unwrap());
assert_eq!(reader.read_to_string(&file_path)?, "test"); assert_eq!(
reader.read(&file_path)?,
reader::Content::UTF8("test".to_string())
);
Ok(()) Ok(())
} }
@ -63,8 +66,11 @@ fn test_commit_reader_read_file() -> Result<()> {
"test2", "test2",
)?; )?;
let reader = super::reader::get_commit_reader(&repository, oid)?; let reader = reader::CommitReader::open(&repository, oid)?;
assert_eq!(reader.read_to_string(&file_path)?, "test"); assert_eq!(
reader.read(&file_path)?,
reader::Content::UTF8("test".to_string())
);
Ok(()) Ok(())
} }
@ -88,7 +94,7 @@ fn test_working_directory_reader_list_files() -> Result<()> {
"test", "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(".")?; let files = reader.list_files(".")?;
assert_eq!(files.len(), 2); assert_eq!(files.len(), 2);
assert!(files.contains(&"test.txt".to_string())); 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"))?; 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(".")?; let files = reader.list_files(".")?;
assert_eq!(files.len(), 2); assert_eq!(files.len(), 2);
assert!(files.contains(&"test.txt".to_string())); assert!(files.contains(&"test.txt".to_string()));
@ -128,3 +134,39 @@ fn test_commit_reader_list_files() -> Result<()> {
Ok(()) 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(())
}

View File

@ -1,11 +1,11 @@
use std::collections::HashMap;
use super::{ use super::{
gb_repository as repository, reader, gb_repository as repository,
reader::{self, Reader},
writer::{self, Writer}, writer::{self, Writer},
}; };
use crate::{deltas, pty, sessions}; use crate::{deltas, pty, sessions};
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use std::collections::HashMap;
pub struct SessionWriter<'writer> { pub struct SessionWriter<'writer> {
repository: &'writer repository::Repository, repository: &'writer repository::Repository,
@ -17,7 +17,7 @@ impl<'writer> SessionWriter<'writer> {
repository: &'writer repository::Repository, repository: &'writer repository::Repository,
session: &'writer sessions::Session, session: &'writer sessions::Session,
) -> Result<Self> { ) -> Result<Self> {
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( let current_session_id = reader.read_to_string(
repository 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 writer
.write_string( .write_string(
@ -200,7 +200,7 @@ impl<'reader> SessionReader<'reader> {
repository: &'reader repository::Repository, repository: &'reader repository::Repository,
session: sessions::Session, session: sessions::Session,
) -> Result<Self> { ) -> Result<Self> {
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( let current_session_id = wd_reader.read_to_string(
&repository &repository
@ -229,7 +229,7 @@ impl<'reader> SessionReader<'reader> {
let oid = git2::Oid::from_str(&session_hash) let oid = git2::Oid::from_str(&session_hash)
.with_context(|| format!("failed to parse commit hash {}", 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 { Ok(SessionReader {
reader: Box::new(commit_reader), reader: Box::new(commit_reader),
repository, repository,

View File

@ -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(())
}
}

View File

@ -6,19 +6,19 @@ pub trait Writer {
fn append_string(&self, path: &str, contents: &str) -> Result<()>; fn append_string(&self, path: &str, contents: &str) -> Result<()>;
} }
pub struct WdWriter<'writer> { pub struct DirWriter<'writer> {
git_repository: &'writer git2::Repository, root: &'writer std::path::Path,
} }
pub fn get_working_directory_writer<'writer>( impl<'writer> DirWriter<'writer> {
git_repository: &'writer git2::Repository, pub fn open(root: &'writer std::path::Path) -> Self {
) -> WdWriter { Self { root }
WdWriter { git_repository } }
} }
impl Writer for WdWriter<'_> { impl Writer for DirWriter<'_> {
fn write_string(&self, path: &str, contents: &str) -> Result<()> { 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(); let dir_path = file_path.parent().unwrap();
std::fs::create_dir_all(dir_path)?; std::fs::create_dir_all(dir_path)?;
std::fs::write(path, contents)?; std::fs::write(path, contents)?;
@ -26,7 +26,7 @@ impl Writer for WdWriter<'_> {
} }
fn append_string(&self, path: &str, contents: &str) -> Result<()> { 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() let mut file = std::fs::OpenOptions::new()
.create(true) .create(true)
.append(true) .append(true)

View File

@ -718,6 +718,13 @@ fn init(app_handle: tauri::AppHandle) -> Result<()> {
.watch(tx.clone(), &repo) .watch(tx.clone(), &repo)
.with_context(|| format!("{}: failed to watch project", project.id))?; .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 if let Err(err) = app_state
.deltas_searcher .deltas_searcher
.lock() .lock()

View File

@ -1,7 +1,7 @@
mod sessions; mod sessions;
mod storage; mod storage;
pub use sessions::{id_from_commit, Meta, Session}; pub use sessions::{id_from_commit, Meta, Session, SessionError};
pub use storage::Store; pub use storage::Store;
#[cfg(test)] #[cfg(test)]

View File

@ -2,6 +2,7 @@ use crate::{app::reader, git::activity};
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use serde::Serialize; use serde::Serialize;
use std::path::Path; use std::path::Path;
use thiserror::Error;
#[derive(Debug, Clone, Serialize, PartialEq)] #[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@ -27,23 +28,40 @@ pub struct Session {
pub activity: Vec<activity::Activity>, pub activity: Vec<activity::Activity>,
} }
#[derive(Error, Debug)]
pub enum SessionError {
#[error("session does not exist")]
NoSession,
#[error("{0}")]
Err(anyhow::Error),
}
impl<'reader> TryFrom<Box<dyn reader::Reader + 'reader>> for Session { impl<'reader> TryFrom<Box<dyn reader::Reader + 'reader>> for Session {
type Error = anyhow::Error; type Error = SessionError;
fn try_from(reader: Box<dyn reader::Reader + 'reader>) -> Result<Self, Self::Error> { fn try_from(reader: Box<dyn reader::Reader + 'reader>) -> Result<Self, Self::Error> {
if !reader.exists("session") {
return Err(SessionError::NoSession);
}
let id = reader let id = reader
.read_to_string("session/meta/id") .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 let start_timestamp_ms = reader
.read_to_string("session/meta/start") .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::<u128>() .parse::<u128>()
.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 let last_timestamp_ms = reader
.read_to_string("session/meta/last") .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::<u128>() .parse::<u128>()
.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 branch = reader.read_to_string("session/meta/branch");
let commit = reader.read_to_string("session/meta/commit"); let commit = reader.read_to_string("session/meta/commit");
@ -69,8 +87,17 @@ impl<'reader> TryFrom<Box<dyn reader::Reader + 'reader>> for Session {
} }
} }
impl<'reader> TryFrom<reader::DirReader<'reader>> for Session {
type Error = SessionError;
fn try_from(reader: reader::DirReader<'reader>) -> Result<Self, Self::Error> {
let session = Session::try_from(Box::new(reader) as Box<dyn reader::Reader + 'reader>)?;
Ok(session)
}
}
impl<'reader> TryFrom<reader::CommitReader<'reader>> for Session { impl<'reader> TryFrom<reader::CommitReader<'reader>> for Session {
type Error = anyhow::Error; type Error = SessionError;
fn try_from(reader: reader::CommitReader<'reader>) -> Result<Self, Self::Error> { fn try_from(reader: reader::CommitReader<'reader>) -> Result<Self, Self::Error> {
let commit_oid = reader.get_commit_oid().to_string(); let commit_oid = reader.get_commit_oid().to_string();

View File

@ -227,10 +227,8 @@ fn test_get_persistent() -> Result<()> {
let commit_oid = git2::Oid::from_str(&created_session.hash.as_ref().unwrap())?; let commit_oid = git2::Oid::from_str(&created_session.hash.as_ref().unwrap())?;
let reader = reader::get_commit_reader(&repo, commit_oid)?; let reader = reader::CommitReader::open(&repo, commit_oid)?;
let reconstructed = super::sessions::Session::try_from(reader); let reconstructed = super::sessions::Session::try_from(reader)?;
assert!(reconstructed.is_ok());
let reconstructed = reconstructed.unwrap();
assert_eq!(reconstructed, created_session); assert_eq!(reconstructed, created_session);

View File

@ -57,7 +57,7 @@ impl Store {
walker.set_sorting(git2::Sort::TIME)?; walker.set_sorting(git2::Sort::TIME)?;
for commit_id in walker { 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 { if reader.read_to_string("session/meta/id")? == session_id {
return Ok(Some(sessions::Session::try_from(reader)?)); return Ok(Some(sessions::Session::try_from(reader)?));
} }
@ -206,7 +206,7 @@ impl Store {
let mut sessions: Vec<sessions::Session> = vec![]; let mut sessions: Vec<sessions::Session> = vec![];
for id in walker { for id in walker {
let id = id?; 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)?; let session = sessions::Session::try_from(reader)?;
sessions.push(session); sessions.push(session);
} }