refactor: module infrastructure avec comrak_renderer, file_repository, notify_watcher
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+14
-42
@@ -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<String, String> {
|
||||
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<String>) -> 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<Vec<String>, String> {
|
||||
@@ -65,6 +36,7 @@ pub fn list_md_files(dir: String) -> Result<Vec<String>, String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn tmpdir(name: &str) -> PathBuf {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
pub fn collect_md_files(dir: &Path, result: &mut Vec<String>) -> 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<String, String> {
|
||||
fs::read_to_string(path).map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod comrak_renderer;
|
||||
pub mod file_repository;
|
||||
pub mod notify_watcher;
|
||||
@@ -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<notify::Result<notify::Event>>,
|
||||
app: AppHandle,
|
||||
) {
|
||||
let mut pending: HashMap<PathBuf, Instant> = 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<PathBuf> = 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")
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
mod core;
|
||||
mod domain;
|
||||
mod infrastructure;
|
||||
mod watcher;
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
@@ -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<Option<RecommendedWatcher>>);
|
||||
|
||||
#[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<PathBuf, Instant> = 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<PathBuf> = 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();
|
||||
|
||||
Reference in New Issue
Block a user