znote 1.1.0

A lightweight desktop editor for Typst with live preview, built with Tauri
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Mutex;

use notify::event::ModifyKind;
use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use serde::Serialize;
use tauri::{AppHandle, Emitter, Manager, State};
use tauri_plugin_dialog::DialogExt;
pub use zsuite_agent::pages::*;

pub fn delete(root: &Path, path: &str) -> Result<(), String> {
    trash::delete(resolve(root, path)?)
        .map_err(|error| format!("Could not delete “{path}”: {error}"))
}

fn structural(event: &Event) -> bool {
    let content = matches!(
        event.kind,
        EventKind::Access(_) | EventKind::Modify(ModifyKind::Data(_) | ModifyKind::Metadata(_))
    );
    !content
        && !event
            .paths
            .iter()
            .all(|path| path.extension().is_some_and(|extension| extension == "tmp"))
}

fn watch(app: AppHandle, root: PathBuf) -> notify::Result<RecommendedWatcher> {
    let folder = root.clone();
    let shown = Mutex::new(tree(&root));
    let mut watcher = notify::recommended_watcher(move |event: notify::Result<Event>| {
        let Ok(event) = event else { return };
        if matches!(event.kind, EventKind::Access(_)) {
            return;
        }
        for page in event.paths.iter().filter_map(|path| page_of(&folder, path)) {
            crate::knowledge::saved(&app, page.clone());
            let _ = app.emit("page-changed", page);
        }
        if !event
            .paths
            .iter()
            .all(|path| path.extension().is_some_and(|extension| extension == "tmp"))
        {
            crate::project::changed(&app);
        }
        if !structural(&event) {
            return;
        }
        let now = tree(&folder);
        if let Ok(mut shown) = shown.lock()
            && *shown != now
        {
            *shown = now;
            let _ = app.emit("workspace-changed", ());
        }
    })?;
    watcher.watch(&root, RecursiveMode::Recursive)?;
    Ok(watcher)
}

#[derive(Default)]
pub struct Workspace {
    root: Mutex<Option<PathBuf>>,
    watcher: Mutex<Option<RecommendedWatcher>>,
}

impl Workspace {
    pub fn root(&self) -> Result<PathBuf, String> {
        let root = self.root.lock().map_err(|error| error.to_string())?;
        root.clone()
            .ok_or_else(|| "Choose a workspace folder first.".to_string())
    }

    pub fn typst_source(&self, page: &str) -> Result<(PathBuf, String), String> {
        let root = self.root()?;
        page_file(&root, page)?;
        let (project, main) = page
            .split_once('/')
            .ok_or_else(|| format!("“{page}” is not a page."))?;
        Ok((root.join(project), format!("{main}.typ")))
    }
}

#[derive(Serialize)]
pub struct PickedFile {
    name: String,
    text: String,
}

#[tauri::command]
pub async fn pick_typst_file(app: AppHandle) -> Result<Option<PickedFile>, String> {
    crate::blocking(move || pick_typst_file_now(app)).await
}

fn pick_typst_file_now(app: AppHandle) -> Result<Option<PickedFile>, String> {
    let Some(file) = app
        .dialog()
        .file()
        .add_filter("Typst", &["typ"])
        .blocking_pick_file()
    else {
        return Ok(None);
    };
    let path = file.into_path().map_err(|error| error.to_string())?;
    let text = fs::read_to_string(&path)
        .map_err(|error| format!("The file could not be read: {error}"))?;
    let name = path
        .file_stem()
        .unwrap_or_default()
        .to_string_lossy()
        .into_owned();
    Ok(Some(PickedFile { name, text }))
}

#[tauri::command]
pub fn open_workspace(
    app: AppHandle,
    workspace: State<Workspace>,
    folder: String,
) -> Result<Vec<Project>, String> {
    let root = crate::project::pages(&app)?;
    if root != Path::new(&folder) {
        return Err("Open a znote file first.".into());
    }
    fs::create_dir_all(&root).map_err(|error| failed("open", &folder, error))?;
    let watcher = watch(app, root.clone()).map_err(|error| failed("watch", &folder, error))?;
    let projects = tree(&root);
    *workspace
        .watcher
        .lock()
        .map_err(|error| error.to_string())? = Some(watcher);
    *workspace.root.lock().map_err(|error| error.to_string())? = Some(root);
    Ok(projects)
}

#[tauri::command]
pub fn list_tree(workspace: State<Workspace>) -> Result<Vec<Project>, String> {
    Ok(tree(&workspace.root()?))
}

#[tauri::command]
pub fn create_entry(
    app: AppHandle,
    workspace: State<Workspace>,
    path: String,
) -> Result<(), String> {
    create(&workspace.root()?, &path)?;
    crate::project::changed(&app);
    Ok(())
}

#[tauri::command]
pub fn move_entry(
    app: AppHandle,
    workspace: State<Workspace>,
    from: String,
    to: String,
) -> Result<(), String> {
    rename(&workspace.root()?, &from, &to)?;
    crate::project::changed(&app);
    Ok(())
}

#[tauri::command]
pub async fn delete_entry(app: AppHandle, path: String) -> Result<(), String> {
    crate::blocking(move || delete_entry_now(app, path)).await
}

fn delete_entry_now(app: AppHandle, path: String) -> Result<(), String> {
    delete(&app.state::<Workspace>().root()?, &path)?;
    crate::project::changed(&app);
    Ok(())
}

#[tauri::command]
pub async fn search_pages(app: AppHandle, query: String) -> Result<Vec<Hit>, String> {
    let root = app.state::<Workspace>().root()?;
    crate::blocking(move || Ok(find(&root, &query, 200))).await
}

#[tauri::command]
pub fn read_page(workspace: State<Workspace>, path: String) -> Result<String, String> {
    read_text(&workspace.root()?, &path)
}

#[tauri::command]
pub fn write_page(
    app: AppHandle,
    workspace: State<Workspace>,
    path: String,
    text: String,
) -> Result<(), String> {
    write_text(&workspace.root()?, &path, &text)?;
    crate::project::changed(&app);
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn saving_a_file_is_not_a_change_to_the_tree() {
        let event = |kind, path: &str| Event::new(kind).add_path(PathBuf::from(path));
        let renamed = EventKind::Modify(ModifyKind::Name(notify::event::RenameMode::Any));
        assert!(!structural(&event(
            EventKind::Modify(ModifyKind::Data(notify::event::DataChange::Any)),
            "/w/A/B/Page.typ"
        )));
        assert!(!structural(&event(
            EventKind::Create(notify::event::CreateKind::File),
            "/w/A/B/Page.typ.tmp"
        )));
        assert!(structural(&event(renamed, "/w/A/B/Page.typ")));
        assert!(structural(&event(
            EventKind::Remove(notify::event::RemoveKind::Any),
            "/w/A/B"
        )));
    }
}