diff --git a/src-tauri/src/ipc/commands/typst.rs b/src-tauri/src/ipc/commands/typst.rs index 9435120..dc9897a 100644 --- a/src-tauri/src/ipc/commands/typst.rs +++ b/src-tauri/src/ipc/commands/typst.rs @@ -9,6 +9,7 @@ use serde::Serialize; use serde_repr::Serialize_repr; use siphasher::sip128::{Hasher128, SipHasher}; use std::hash::Hash; +use std::panic::catch_unwind; use std::path::PathBuf; use std::sync::Arc; use std::time::Instant; @@ -73,17 +74,22 @@ pub async fn typst_compile( .slot_update(path.as_path(), Some(content)) .map_err(Into::::into)?; - // TODO: Configurable main - world.set_main(source_id); + if !world.is_main_set() { + let config = project.config.read().unwrap(); + if config.apply_main(&*project, &mut *world).is_err() { + debug!("skipped compilation for {:?} (main not set)", project); + return Ok(()); + } + } - debug!("compiling: {:?}", path); + debug!("compiling: {:?}", project); let now = Instant::now(); match typst::compile(&*world) { Ok(doc) => { let elapsed = now.elapsed(); debug!( "compilation succeeded for {:?} in {:?} ms", - path, + project, elapsed.as_millis() ); diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 73d5967..927b23d 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -1,6 +1,6 @@ -use crate::project::{Project, ProjectManager, ProjectWorld}; +use crate::project::{Project, ProjectManager}; use std::fs; -use std::sync::{Arc, RwLock}; +use std::sync::Arc; use tauri::api::dialog::FileDialogBuilder; use tauri::{Manager, Runtime, State, WindowMenuEvent}; @@ -14,11 +14,7 @@ pub fn handle_menu_event(e: WindowMenuEvent) { let window = e.window(); let project_manager: State<'_, Arc>> = window.state(); - let project = Arc::new(Project { - world: ProjectWorld::new(path.clone()).into(), - cache: RwLock::new(Default::default()), - root: path, - }); + let project = Arc::new(Project::load_from_path(path)); project_manager.set_project(window, Some(project)); } }), diff --git a/src-tauri/src/project/manager.rs b/src-tauri/src/project/manager.rs new file mode 100644 index 0000000..10082a4 --- /dev/null +++ b/src-tauri/src/project/manager.rs @@ -0,0 +1,165 @@ +use crate::ipc::{FSRefreshEvent, ProjectChangeEvent, ProjectModel}; +use crate::project::{is_project_config_file, Project, ProjectConfig}; +use log::{debug, error, info, trace}; +use notify::event::ModifyKind; +use notify::{Config, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Arc, Mutex, RwLock}; +use tauri::{Runtime, Window}; +use tokio::sync::mpsc::channel; + +#[derive(Clone, Copy, Debug)] +enum FSHandleKind { + Refresh, + Reload, +} + +pub struct ProjectManager { + projects: RwLock, Arc>>, + watcher: Mutex>>, +} + +impl ProjectManager { + pub fn init_watcher( + project_manager: Arc>, + ) -> anyhow::Result> { + let (tx, mut rx) = channel(1); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + + let watcher = RecommendedWatcher::new( + move |res| { + let _ = rt.block_on(tx.send(res)); + }, + Config::default(), + )?; + + tokio::spawn(async move { + while let Some(res) = rx.recv().await { + match res { + Ok(event) => project_manager.handle_fs_event(event), + Err(e) => error!("watch error {:?}", e), + } + } + }); + + Ok(Box::new(watcher)) + } + + pub fn set_watcher(&self, watcher: Box) { + let mut inner = self.watcher.lock().unwrap(); + *inner = Some(watcher); + } + + pub fn get_project(&self, window: &Window) -> Option> { + self.projects.read().unwrap().get(window).cloned() + } + + pub fn set_project(&self, window: &Window, project: Option>) { + let mut projects = self.projects.write().unwrap(); + let model = project.as_ref().map(|p| ProjectModel { + root: p.root.clone(), + }); + match project { + None => { + if let Some(old) = projects.remove(window) { + let mut guard = self.watcher.lock().unwrap(); + if let Some(watcher) = guard.as_mut() { + let _ = watcher.unwatch(&old.root); + } + } + } + Some(p) => { + p.config.read().unwrap().apply(&*p); + + let root = &p.root.clone(); + let mut guard = self.watcher.lock().unwrap(); + if let Some(old) = projects.insert(window.clone(), p) { + if let Some(watcher) = guard.as_mut() { + let _ = watcher.unwatch(&old.root); + } + } + if let Some(watcher) = guard.as_mut() { + let _ = watcher.watch(root, RecursiveMode::Recursive); + } + } + }; + + info!("project set for window {}: {:?}", window.label(), model); + let _ = window.emit("project_changed", ProjectChangeEvent { project: model }); + } + + fn handle_fs_event(&self, event: notify::Event) { + let opt = match event.kind { + EventKind::Create(_) | EventKind::Remove(_) => event.paths[0] + .parent() + .map(|p| (p.to_path_buf(), FSHandleKind::Refresh)), + EventKind::Modify(kind) => match kind { + ModifyKind::Name(_) => event.paths[0] + .parent() + .map(|p| (p.to_path_buf(), FSHandleKind::Refresh)), + ModifyKind::Data(_) => Some((event.paths[0].clone(), FSHandleKind::Reload)), + _ => None, + }, + _ => None, + }; + + if let Some((path, kind)) = opt { + let path = path.canonicalize().unwrap_or(path); + let projects = self.projects.read().unwrap(); + + for (window, project) in &*projects { + if path.starts_with(&project.root) { + self.handle_project_fs_event(project, window, &path, kind); + } + } + } + } + + fn handle_project_fs_event( + &self, + project: &Project, + window: &Window, + path: &PathBuf, + kind: FSHandleKind, + ) { + trace!( + "handling fs event for {:?} (path: {:?}, kind: {:?})", + project, + path, + kind + ); + match kind { + FSHandleKind::Refresh => { + if let Ok(relative) = path.strip_prefix(&project.root) { + let event = FSRefreshEvent { + path: relative.to_path_buf(), + }; + let _ = window.emit("fs_refresh", &event); + } + } + FSHandleKind::Reload => { + if let Ok(relative) = path.strip_prefix(&project.root) { + if is_project_config_file(relative) { + if let Ok(config) = ProjectConfig::read_from_file(path) { + debug!("updating project config for {:?}: {:?}", project, config); + let mut config_write = project.config.write().unwrap(); + *config_write = config; + config_write.apply(project); + } + } + } + } + } + } + + pub fn new() -> Self { + Self { + projects: RwLock::new(HashMap::new()), + watcher: Mutex::new(None), + } + } +} diff --git a/src-tauri/src/project/mod.rs b/src-tauri/src/project/mod.rs index f7e0cfd..3e57926 100644 --- a/src-tauri/src/project/mod.rs +++ b/src-tauri/src/project/mod.rs @@ -1,5 +1,7 @@ mod project; mod world; +mod manager; pub use project::*; pub use world::*; +pub use manager::*; diff --git a/src-tauri/src/project/project.rs b/src-tauri/src/project/project.rs index 18a0bca..8cfcb9b 100644 --- a/src-tauri/src/project/project.rs +++ b/src-tauri/src/project/project.rs @@ -1,19 +1,22 @@ -use crate::ipc::{FSRefreshEvent, ProjectChangeEvent, ProjectModel}; use crate::project::ProjectWorld; -use log::{error, info}; -use notify::event::ModifyKind; -use notify::{Config, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::{Arc, Mutex, RwLock}; -use tauri::{Runtime, Window}; -use tokio::sync::mpsc::channel; +use log::debug; +use serde::{Deserialize, Serialize}; +use std::fmt::{Debug, Formatter}; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, RwLock}; +use std::{fs, io}; +use thiserror::Error; +use typst::diag::{FileError, FileResult}; use typst::doc::Document; +use typst::util::PathExt; + +const PATH_PROJECT_CONFIG_FILE: &str = ".typstudio/project.json"; pub struct Project { pub root: PathBuf, pub world: Mutex, pub cache: RwLock, + pub config: RwLock, } #[derive(Default)] @@ -21,113 +24,96 @@ pub struct ProjectCache { pub document: Option, } -pub struct ProjectManager { - projects: RwLock, Arc>>, - watcher: Mutex>>, +#[derive(Serialize, Deserialize, Debug, Clone, Hash)] +pub struct ProjectConfig { + pub main: Option, } -impl ProjectManager { - pub fn init_watcher( - project_manager: Arc>, - ) -> anyhow::Result> { - let (tx, mut rx) = channel(1); - - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build()?; - - let watcher = RecommendedWatcher::new( - move |res| { - let _ = rt.block_on(tx.send(res)); - }, - Config::default(), - )?; - - tokio::spawn(async move { - while let Some(res) = rx.recv().await { - match res { - Ok(event) => project_manager.handle_fs_event(event), - Err(e) => error!("watch error {:?}", e), - } - } - }); +#[derive(Error, Debug)] +pub enum ProjectConfigError { + #[error("io error")] + IO(#[from] io::Error), + #[error("serial error")] + Serial(#[from] serde_json::Error), +} - Ok(Box::new(watcher)) +impl ProjectConfig { + pub fn read_from_file>(path: P) -> Result { + let json = fs::read_to_string(path).map_err(Into::::into)?; + serde_json::from_str(&*json).map_err(Into::into) } - pub fn set_watcher(&self, watcher: Box) { - let mut inner = self.watcher.lock().unwrap(); - *inner = Some(watcher); + pub fn write_to_file>(&self, path: P) -> Result<(), ProjectConfigError> { + let json = serde_json::to_string(&self).map_err(Into::::into)?; + fs::write(path, json).map_err(Into::into) } - pub fn get_project(&self, window: &Window) -> Option> { - self.projects.read().unwrap().get(window).cloned() + pub fn apply(&self, project: &Project) { + let mut world = project.world.lock().unwrap(); + match self.apply_main(project, &mut *world) { + Ok(_) => debug!( + "applied main source configuration for project {:?}", + project + ), + Err(e) => debug!( + "unable to apply main source configuration for project {:?}: {:?}", + project, e + ), + } } - pub fn set_project(&self, window: &Window, project: Option>) { - let mut projects = self.projects.write().unwrap(); - let model = project.as_ref().map(|p| ProjectModel { - root: p.root.clone(), - }); - match project { - None => { - if let Some(old) = projects.remove(window) { - let mut guard = self.watcher.lock().unwrap(); - if let Some(watcher) = guard.as_mut() { - let _ = watcher.unwatch(&old.root); - } - } - } - Some(p) => { - let root = &p.root.clone(); - let mut guard = self.watcher.lock().unwrap(); - if let Some(old) = projects.insert(window.clone(), p) { - if let Some(watcher) = guard.as_mut() { - let _ = watcher.unwatch(&old.root); - } - } - if let Some(watcher) = guard.as_mut() { - let _ = watcher.watch(root, RecursiveMode::Recursive); - } + pub fn apply_main(&self, project: &Project, world: &mut ProjectWorld) -> FileResult<()> { + if let Some(main) = self.main.as_ref() { + let main = project.root.join(main); + let main = main.canonicalize().unwrap_or_else(|_| main.normalize()); + if main.starts_with(&project.root) { + return world.try_set_main(main); } - }; + } + + // ?? + world.set_main(None); - info!("project set for window {}: {:?}", window.label(), model); - let _ = window.emit("project_changed", ProjectChangeEvent { project: model }); + Err(FileError::Other) } +} - fn handle_fs_event(&self, event: notify::Event) { - let path = match event.kind { - EventKind::Create(_) | EventKind::Remove(_) => { - event.paths[0].parent().map(|p| p.to_path_buf()) - } - EventKind::Modify(kind) => match kind { - ModifyKind::Name(_) => event.paths[0].parent().map(|p| p.to_path_buf()), - _ => None, - }, - _ => None, - }; - - if let Some(path) = path { - let projects = self.projects.read().unwrap(); - - for (window, project) in &*projects { - if path.starts_with(&project.root) { - if let Ok(relative) = path.strip_prefix(&project.root) { - let event = FSRefreshEvent { - path: relative.to_path_buf(), - }; - let _ = window.emit("fs_refresh", &event); - } - } - } +impl Default for ProjectConfig { + fn default() -> Self { + Self { + main: Some(PathBuf::from("main.typ")), } } +} + +impl Project { + pub fn load_from_path(path: PathBuf) -> Self { + let path = fs::canonicalize(&path).unwrap_or(path); + let config = + ProjectConfig::read_from_file(&path.join(PATH_PROJECT_CONFIG_FILE)).unwrap_or_default(); - pub fn new() -> Self { Self { - projects: RwLock::new(HashMap::new()), - watcher: Mutex::new(None), + world: ProjectWorld::new(path.clone()).into(), + cache: RwLock::new(Default::default()), + config: RwLock::new(config), + root: path, } } } + +impl Debug for Project { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Project").field("root", &self.root).finish() + } +} + +pub fn is_project_config_file(relative: &Path) -> bool { + let mut components = relative.components(); + components + .next() + .is_some_and(|c| c.as_os_str() == ".typstudio") + && components + .next() + .is_some_and(|c| c.as_os_str() == "project.json") + && components.next().is_none() +} diff --git a/src-tauri/src/project/world.rs b/src-tauri/src/project/world.rs index ac33155..7730979 100644 --- a/src-tauri/src/project/world.rs +++ b/src-tauri/src/project/world.rs @@ -49,8 +49,17 @@ impl ProjectWorld { } } - pub fn set_main(&mut self, source: SourceId) { - self.main = Some(source) + pub fn set_main(&mut self, source: Option) { + self.main = source + } + + pub fn try_set_main>(&mut self, main: P) -> FileResult<()> { + self.slot_update(main.as_ref(), None) + .map(|source| self.set_main(Some(source))) + } + + pub fn is_main_set(&self) -> bool { + self.main.is_some() } /// Retrieves an existing path slot or inserts a new one.