diff --git a/src-tauri/src/core.rs b/src-tauri/src/core.rs index 0ceae83..9c450fc 100644 --- a/src-tauri/src/core.rs +++ b/src-tauri/src/core.rs @@ -1,54 +1,25 @@ -use comrak::plugins::syntect::SyntectAdapterBuilder; -use comrak::{markdown_to_html_with_plugins, Options, Plugins}; -use std::fs; +use crate::domain::MarkdownRenderer; +use crate::infrastructure::comrak_renderer::{ComrakPreviewRenderer, ComrakRenderer}; +use crate::infrastructure::file_repository; use std::path::Path; pub fn render_markdown(content: String) -> String { - let adapter = SyntectAdapterBuilder::new() - .theme("base16-ocean.dark") - .build(); - let options = Options::default(); - let mut plugins = Plugins::default(); - plugins.render.codefence_syntax_highlighter = Some(&adapter); - markdown_to_html_with_plugins(&content, &options, &plugins) + ComrakPreviewRenderer { + theme: "base16-ocean.dark".to_string(), + } + .render(&content) } pub fn convert_file(path: String) -> Result { - let content = fs::read_to_string(&path).map_err(|e| e.to_string())?; - - let adapter = SyntectAdapterBuilder::new() - .theme("InspiredGitHub") - .build(); - - let mut options = Options::default(); - options.extension.table = true; - options.extension.strikethrough = true; - options.extension.autolink = true; - options.extension.tasklist = true; - - let mut plugins = Plugins::default(); - plugins.render.codefence_syntax_highlighter = Some(&adapter); - - Ok(markdown_to_html_with_plugins(&content, &options, &plugins)) + let content = file_repository::read_file(&path)?; + Ok(ComrakRenderer { + theme: "InspiredGitHub".to_string(), + } + .render(&content)) } pub(crate) fn collect_md_files(dir: &Path, result: &mut Vec) -> std::io::Result<()> { - for entry in fs::read_dir(dir)? { - let entry = entry?; - let name = entry.file_name(); - if name.to_string_lossy().starts_with('.') { - continue; - } - let path = entry.path(); - if path.is_dir() { - collect_md_files(&path, result)?; - } else if path.extension().is_some_and(|e| e == "md") { - if let Some(s) = path.to_str() { - result.push(s.to_string()); - } - } - } - Ok(()) + file_repository::collect_md_files(dir, result) } pub fn list_md_files(dir: String) -> Result, String> { @@ -65,6 +36,7 @@ pub fn list_md_files(dir: String) -> Result, String> { #[cfg(test)] mod tests { use super::*; + use std::fs; use std::path::PathBuf; fn tmpdir(name: &str) -> PathBuf { diff --git a/src-tauri/src/infrastructure/comrak_renderer.rs b/src-tauri/src/infrastructure/comrak_renderer.rs new file mode 100644 index 0000000..f337f3c --- /dev/null +++ b/src-tauri/src/infrastructure/comrak_renderer.rs @@ -0,0 +1,36 @@ +use comrak::plugins::syntect::SyntectAdapterBuilder; +use comrak::{markdown_to_html_with_plugins, Options, Plugins}; + +use crate::domain::MarkdownRenderer; + +pub struct ComrakRenderer { + pub theme: String, +} + +pub struct ComrakPreviewRenderer { + pub theme: String, +} + +impl MarkdownRenderer for ComrakRenderer { + fn render(&self, content: &str) -> String { + let adapter = SyntectAdapterBuilder::new().theme(&self.theme).build(); + let mut options = Options::default(); + options.extension.table = true; + options.extension.strikethrough = true; + options.extension.autolink = true; + options.extension.tasklist = true; + let mut plugins = Plugins::default(); + plugins.render.codefence_syntax_highlighter = Some(&adapter); + markdown_to_html_with_plugins(content, &options, &plugins) + } +} + +impl MarkdownRenderer for ComrakPreviewRenderer { + fn render(&self, content: &str) -> String { + let adapter = SyntectAdapterBuilder::new().theme(&self.theme).build(); + let options = Options::default(); + let mut plugins = Plugins::default(); + plugins.render.codefence_syntax_highlighter = Some(&adapter); + markdown_to_html_with_plugins(content, &options, &plugins) + } +} diff --git a/src-tauri/src/infrastructure/file_repository.rs b/src-tauri/src/infrastructure/file_repository.rs new file mode 100644 index 0000000..2008cdb --- /dev/null +++ b/src-tauri/src/infrastructure/file_repository.rs @@ -0,0 +1,25 @@ +use std::fs; +use std::path::Path; + +pub fn collect_md_files(dir: &Path, result: &mut Vec) -> std::io::Result<()> { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let name = entry.file_name(); + if name.to_string_lossy().starts_with('.') { + continue; + } + let path = entry.path(); + if path.is_dir() { + collect_md_files(&path, result)?; + } else if path.extension().is_some_and(|e| e == "md") { + if let Some(s) = path.to_str() { + result.push(s.to_string()); + } + } + } + Ok(()) +} + +pub fn read_file(path: &str) -> Result { + fs::read_to_string(path).map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/infrastructure/mod.rs b/src-tauri/src/infrastructure/mod.rs new file mode 100644 index 0000000..e04ccaf --- /dev/null +++ b/src-tauri/src/infrastructure/mod.rs @@ -0,0 +1,3 @@ +pub mod comrak_renderer; +pub mod file_repository; +pub mod notify_watcher; diff --git a/src-tauri/src/infrastructure/notify_watcher.rs b/src-tauri/src/infrastructure/notify_watcher.rs new file mode 100644 index 0000000..a528cb0 --- /dev/null +++ b/src-tauri/src/infrastructure/notify_watcher.rs @@ -0,0 +1,66 @@ +use notify::EventKind; +use serde::Serialize; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::mpsc::RecvTimeoutError; +use std::time::{Duration, Instant}; +use tauri::{AppHandle, Emitter}; + +#[derive(Serialize, Clone)] +struct FileChangedPayload { + path: String, +} + +pub fn run_debounce_loop( + rx: std::sync::mpsc::Receiver>, + app: AppHandle, +) { + let mut pending: HashMap = HashMap::new(); + + loop { + match rx.recv_timeout(Duration::from_millis(10)) { + Ok(Ok(event)) => { + if matches!( + event.kind, + EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_) + ) { + for p in event.paths { + if is_relevant_path(&p) { + pending.insert(p, Instant::now()); + } + } + } + } + Ok(Err(_)) => {} + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => break, + } + + let now = Instant::now(); + let ready: Vec = pending + .iter() + .filter(|(_, t)| now.duration_since(**t) >= Duration::from_millis(80)) + .map(|(p, _)| p.clone()) + .collect(); + + for p in ready { + pending.remove(&p); + let _ = app.emit( + "file-changed", + FileChangedPayload { + path: p.to_string_lossy().to_string(), + }, + ); + } + } +} + +fn is_relevant_path(path: &Path) -> bool { + if path + .components() + .any(|c| c.as_os_str().to_string_lossy().starts_with('.')) + { + return false; + } + path.extension().is_some_and(|e| e == "md") +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 99a625f..c5c680a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ mod core; mod domain; +mod infrastructure; mod watcher; use std::sync::Mutex; diff --git a/src-tauri/src/watcher.rs b/src-tauri/src/watcher.rs index 92aefad..ea2ee2b 100644 --- a/src-tauri/src/watcher.rs +++ b/src-tauri/src/watcher.rs @@ -1,29 +1,12 @@ -use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher}; -use serde::Serialize; -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::sync::mpsc::RecvTimeoutError; +use notify::{RecommendedWatcher, RecursiveMode, Watcher}; +use std::path::PathBuf; use std::sync::Mutex; -use std::time::{Duration, Instant}; -use tauri::{AppHandle, Emitter, State}; +use tauri::{AppHandle, State}; + +use crate::infrastructure::notify_watcher::run_debounce_loop; pub struct WatcherState(pub Mutex>); -#[derive(Serialize, Clone)] -struct FileChangedPayload { - path: String, -} - -fn is_relevant_path(path: &Path) -> bool { - if path - .components() - .any(|c| c.as_os_str().to_string_lossy().starts_with('.')) - { - return false; - } - path.extension().is_some_and(|e| e == "md") -} - #[tauri::command] pub fn start_watch( app: AppHandle, @@ -54,46 +37,8 @@ pub fn start_watch( .watch(&watch_path, mode) .map_err(|e| e.to_string())?; - // Debounce thread: collects events per path, emits after 80ms of silence std::thread::spawn(move || { - let mut pending: HashMap = HashMap::new(); - - loop { - match rx.recv_timeout(Duration::from_millis(10)) { - Ok(Ok(event)) => { - if matches!( - event.kind, - EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_) - ) { - for p in event.paths { - if is_relevant_path(&p) { - pending.insert(p, Instant::now()); - } - } - } - } - Ok(Err(_)) => {} - Err(RecvTimeoutError::Timeout) => {} - Err(RecvTimeoutError::Disconnected) => break, - } - - let now = Instant::now(); - let ready: Vec = pending - .iter() - .filter(|(_, t)| now.duration_since(**t) >= Duration::from_millis(80)) - .map(|(p, _)| p.clone()) - .collect(); - - for p in ready { - pending.remove(&p); - let _ = app.emit( - "file-changed", - FileChangedPayload { - path: p.to_string_lossy().to_string(), - }, - ); - } - } + run_debounce_loop(rx, app); }); let mut guard = state.0.lock().unwrap(); diff --git a/src/index.html b/src/index.html index f33b577..d9a3eb8 100644 --- a/src/index.html +++ b/src/index.html @@ -40,7 +40,9 @@ @@ -50,7 +52,11 @@