Skip to main content

markon_core/
workspace.rs

1use crate::search::SearchIndex;
2use arc_swap::ArcSwapOption;
3use notify::{EventKind, RecursiveMode, Watcher};
4use serde::{Deserialize, Serialize};
5use std::{
6    collections::HashMap,
7    path::{Path, PathBuf},
8    sync::{
9        atomic::{AtomicBool, Ordering},
10        Arc, RwLock,
11    },
12};
13use tokio::sync::broadcast;
14
15#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)]
16pub struct WorkspaceFlags {
17    #[serde(default)]
18    pub enable_search: bool,
19    #[serde(default)]
20    pub enable_viewed: bool,
21    #[serde(default)]
22    pub enable_edit: bool,
23    #[serde(default)]
24    pub enable_live: bool,
25    #[serde(default)]
26    pub shared_annotation: bool,
27}
28
29#[derive(Clone)]
30pub struct WorkspaceConfig {
31    pub path: PathBuf,
32    pub flags: WorkspaceFlags,
33}
34
35pub struct WorkspaceEntry {
36    pub id: String,
37    pub root: PathBuf,
38    pub enable_search: AtomicBool,
39    pub enable_viewed: AtomicBool,
40    pub enable_edit: AtomicBool,
41    pub enable_live: AtomicBool,
42    pub shared_annotation: AtomicBool,
43    pub config_tx: broadcast::Sender<()>,
44    pub search_index: ArcSwapOption<SearchIndex>,
45}
46
47impl WorkspaceEntry {
48    pub fn search_ready(&self) -> bool {
49        self.enable_search.load(Ordering::Relaxed) && self.search_index.load().is_some()
50    }
51
52    pub fn flags(&self) -> WorkspaceFlags {
53        WorkspaceFlags {
54            enable_search: self.enable_search.load(Ordering::Relaxed),
55            enable_viewed: self.enable_viewed.load(Ordering::Relaxed),
56            enable_edit: self.enable_edit.load(Ordering::Relaxed),
57            enable_live: self.enable_live.load(Ordering::Relaxed),
58            shared_annotation: self.shared_annotation.load(Ordering::Relaxed),
59        }
60    }
61}
62
63#[derive(Serialize)]
64pub struct WorkspaceInfo {
65    pub id: String,
66    pub path: String,
67    #[serde(flatten)]
68    pub flags: WorkspaceFlags,
69    pub search_ready: bool,
70}
71
72/// Invoked whenever the registry mutates (add / update_flags / remove).
73/// The host (GUI or CLI daemon) wires this to persist workspaces to
74/// `~/.markon/settings.json` so CLI-driven and GUI-driven changes are
75/// treated identically.
76pub type PersistHook = Arc<dyn Fn(&WorkspaceRegistry) + Send + Sync>;
77
78pub struct WorkspaceRegistry {
79    inner: RwLock<HashMap<String, Arc<WorkspaceEntry>>>,
80    pub salt: String,
81    persist: RwLock<Option<PersistHook>>,
82}
83
84/// Stable workspace id: truncated SHA-256 of salt + path.
85pub fn hash_id(path: &Path, salt: &str) -> String {
86    use sha2::{Digest, Sha256};
87    let mut h = Sha256::new();
88    h.update(salt.as_bytes());
89    h.update(b"\0");
90    h.update(path.as_os_str().to_string_lossy().as_bytes());
91    let digest = h.finalize();
92    format!(
93        "{:02x}{:02x}{:02x}{:02x}",
94        digest[0], digest[1], digest[2], digest[3]
95    )
96}
97
98pub fn generate_token() -> String {
99    use std::time::{SystemTime, UNIX_EPOCH};
100    let now = SystemTime::now()
101        .duration_since(UNIX_EPOCH)
102        .unwrap_or_default();
103    let a = now.as_nanos() as u64;
104    let b = std::process::id() as u64;
105    let c = now.subsec_nanos() as u64;
106    format!(
107        "{:016x}{:016x}",
108        a.wrapping_mul(6364136223846793005).wrapping_add(b),
109        c.wrapping_mul(2862933555777941757).wrapping_add(a)
110    )
111}
112
113/// Expand `~` / `~` and canonicalize (dunce strips the `\\?\` verbatim prefix
114/// on Windows so UI-visible paths stay clean).
115pub fn expand_and_canonicalize(raw: &str) -> std::io::Result<PathBuf> {
116    let normalized = if raw.starts_with('~') {
117        raw.replacen('~', "~", 1)
118    } else {
119        raw.to_string()
120    };
121    let expanded = if normalized.starts_with("~/") || normalized == "~" {
122        dirs::home_dir()
123            .map(|home| {
124                if normalized == "~" {
125                    home
126                } else {
127                    home.join(&normalized[2..])
128                }
129            })
130            .unwrap_or_else(|| PathBuf::from(&normalized))
131    } else {
132        PathBuf::from(&normalized)
133    };
134    dunce::canonicalize(&expanded).or(Ok(expanded))
135}
136
137impl WorkspaceRegistry {
138    pub fn new(salt: String) -> Self {
139        Self {
140            inner: RwLock::new(HashMap::new()),
141            salt,
142            persist: RwLock::new(None),
143        }
144    }
145    pub fn set_persist_hook(&self, hook: PersistHook) {
146        *self.persist.write().unwrap() = Some(hook);
147    }
148    fn notify_persist(&self) {
149        let hook = self.persist.read().unwrap().clone();
150        if let Some(hook) = hook {
151            hook(self);
152        }
153    }
154    pub fn add(&self, config: WorkspaceConfig) -> String {
155        let id = hash_id(&config.path, &self.salt);
156        // Idempotent: same path registered twice just updates flags on the
157        // existing entry instead of spawning a second indexer thread.
158        if self.inner.read().unwrap().contains_key(&id) {
159            self.update_flags(&id, config.flags);
160            return id;
161        }
162        let (config_tx, _) = broadcast::channel(4);
163        let entry = Arc::new(WorkspaceEntry {
164            id: id.clone(),
165            root: config.path.clone(),
166            enable_search: AtomicBool::new(config.flags.enable_search),
167            enable_viewed: AtomicBool::new(config.flags.enable_viewed),
168            enable_edit: AtomicBool::new(config.flags.enable_edit),
169            enable_live: AtomicBool::new(config.flags.enable_live),
170            shared_annotation: AtomicBool::new(config.flags.shared_annotation),
171            config_tx,
172            search_index: ArcSwapOption::empty(),
173        });
174        self.inner
175            .write()
176            .unwrap()
177            .insert(id.clone(), entry.clone());
178        if config.flags.enable_search {
179            spawn_search_indexer(config.path, entry);
180        }
181        self.notify_persist();
182        id
183    }
184    pub fn update_flags(&self, id: &str, flags: WorkspaceFlags) -> bool {
185        let guard = self.inner.read().unwrap();
186        let Some(entry) = guard.get(id).cloned() else {
187            return false;
188        };
189        drop(guard);
190        let was_search = entry
191            .enable_search
192            .swap(flags.enable_search, Ordering::Relaxed);
193        entry
194            .enable_viewed
195            .store(flags.enable_viewed, Ordering::Relaxed);
196        entry
197            .enable_edit
198            .store(flags.enable_edit, Ordering::Relaxed);
199        entry
200            .enable_live
201            .store(flags.enable_live, Ordering::Relaxed);
202        entry
203            .shared_annotation
204            .store(flags.shared_annotation, Ordering::Relaxed);
205        let _ = entry.config_tx.send(());
206        if flags.enable_search && !was_search && entry.search_index.load().is_none() {
207            let root = entry.root.clone();
208            spawn_search_indexer(root, entry);
209        }
210        self.notify_persist();
211        true
212    }
213    pub fn remove(&self, id: &str) -> bool {
214        let removed = self.inner.write().unwrap().remove(id).is_some();
215        if removed {
216            self.notify_persist();
217        }
218        removed
219    }
220    pub fn get(&self, id: &str) -> Option<Arc<WorkspaceEntry>> {
221        self.inner.read().unwrap().get(id).cloned()
222    }
223    pub fn list(&self) -> Vec<Arc<WorkspaceEntry>> {
224        self.inner.read().unwrap().values().cloned().collect()
225    }
226    pub fn info_list(&self) -> Vec<WorkspaceInfo> {
227        self.list()
228            .into_iter()
229            .map(|e| WorkspaceInfo {
230                id: e.id.clone(),
231                path: e.root.to_string_lossy().to_string(),
232                flags: e.flags(),
233                search_ready: e.search_ready(),
234            })
235            .collect()
236    }
237}
238
239fn spawn_search_indexer(root: PathBuf, entry: Arc<WorkspaceEntry>) {
240    std::thread::spawn(move || {
241        if let Ok(idx) = SearchIndex::new(&root) {
242            let idx = Arc::new(idx);
243            entry.search_index.store(Some(idx.clone()));
244            start_file_watcher(idx, root);
245        }
246    });
247}
248
249fn start_file_watcher(index: Arc<SearchIndex>, root: PathBuf) {
250    std::thread::spawn(move || {
251        let (tx, rx) = std::sync::mpsc::channel();
252        let Ok(mut watcher) = notify::recommended_watcher(move |res| {
253            if let Ok(e) = res {
254                let _ = tx.send(e);
255            }
256        }) else {
257            return;
258        };
259        let _ = watcher.watch(&root, RecursiveMode::Recursive);
260        while let Ok(event) = rx.recv() {
261            for path in event.paths {
262                match event.kind {
263                    EventKind::Create(_) | EventKind::Modify(_) => {
264                        let _ = index.update_file(&path);
265                    }
266                    EventKind::Remove(_) => {
267                        let _ = index.delete_file(&path);
268                    }
269                    _ => {}
270                }
271            }
272        }
273    });
274}
275
276#[derive(serde::Serialize, serde::Deserialize)]
277pub struct ServerLock {
278    pub port: u16,
279    pub token: String,
280}
281impl ServerLock {
282    pub fn path() -> PathBuf {
283        dirs::home_dir()
284            .unwrap()
285            .join(".markon")
286            .join("server.lock")
287    }
288    pub fn write(&self) -> std::io::Result<()> {
289        let path = Self::path();
290        if let Some(parent) = path.parent() {
291            std::fs::create_dir_all(parent)?;
292        }
293        std::fs::write(&path, serde_json::to_string(self).unwrap())
294    }
295    pub fn read() -> Option<Self> {
296        let content = std::fs::read_to_string(Self::path()).ok()?;
297        serde_json::from_str(&content).ok()
298    }
299    pub fn remove() {
300        let _ = std::fs::remove_file(Self::path());
301    }
302    pub fn is_alive(&self) -> bool {
303        std::net::TcpStream::connect_timeout(
304            &std::net::SocketAddr::from(([127, 0, 0, 1], self.port)),
305            std::time::Duration::from_millis(500),
306        )
307        .is_ok()
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314    #[test]
315    fn hash_id_is_deterministic() {
316        let p = std::path::Path::new("/tmp/test");
317        assert_eq!(hash_id(p, "s"), hash_id(p, "s"));
318    }
319
320    #[test]
321    fn hash_id_depends_on_salt() {
322        let p = std::path::Path::new("/tmp/test");
323        assert_ne!(hash_id(p, "a"), hash_id(p, "b"));
324    }
325}