Skip to main content

markon_core/
workspace.rs

1use crate::chat::edits::PendingEditStore;
2use crate::markdown::extract_referenced_assets;
3use crate::search::SearchIndex;
4use arc_swap::ArcSwapOption;
5use notify::{EventKind, RecursiveMode, Watcher};
6use serde::{Deserialize, Serialize};
7use std::{
8    collections::{HashMap, HashSet},
9    path::{Path, PathBuf},
10    sync::{
11        atomic::{AtomicBool, Ordering},
12        Arc, RwLock,
13    },
14};
15use tokio::sync::broadcast;
16
17#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
18pub struct WorkspaceFlags {
19    #[serde(default)]
20    pub enable_search: bool,
21    #[serde(default)]
22    pub enable_viewed: bool,
23    #[serde(default)]
24    pub enable_edit: bool,
25    #[serde(default)]
26    pub enable_live: bool,
27    #[serde(default)]
28    pub enable_chat: bool,
29    #[serde(default)]
30    pub shared_annotation: bool,
31}
32
33#[derive(Clone)]
34pub struct WorkspaceConfig {
35    pub path: PathBuf,
36    pub flags: WorkspaceFlags,
37    /// `Some(name)` → the workspace exposes only `name` (relative to `path`)
38    /// plus assets that file references. Used by Open-With on macOS so that
39    /// opening `~/Downloads/note.md` does not turn `~/Downloads` into an
40    /// indexed, browsable workspace. Ephemeral (not persisted).
41    pub single_file: Option<String>,
42}
43
44pub(crate) struct WorkspaceEntry {
45    pub id: String,
46    pub root: PathBuf,
47    pub enable_search: AtomicBool,
48    pub enable_viewed: AtomicBool,
49    pub enable_edit: AtomicBool,
50    pub enable_live: AtomicBool,
51    pub enable_chat: AtomicBool,
52    pub shared_annotation: AtomicBool,
53    pub config_tx: broadcast::Sender<()>,
54    pub search_index: ArcSwapOption<SearchIndex>,
55    /// Set for single-file ephemeral workspaces. Holds the file name (relative
56    /// to `root`); routes outside this file + `allowed_assets` return 404.
57    pub single_file: Option<String>,
58    /// Co-located assets the single-file's markdown references (images,
59    /// stylesheets, etc.). Re-derived whenever the file is modified.
60    /// Empty (and unread) for normal directory workspaces.
61    pub allowed_assets: RwLock<HashSet<String>>,
62    /// In-flight `edit_file` proposals from the chat tool, awaiting the
63    /// user's accept/reject. Lives on the workspace so HTTP handlers and
64    /// the agent loop can share the same store.
65    pub pending_edits: Arc<PendingEditStore>,
66}
67
68impl WorkspaceEntry {
69    pub(crate) fn search_ready(&self) -> bool {
70        self.enable_search.load(Ordering::Relaxed) && self.search_index.load().is_some()
71    }
72
73    pub(crate) fn flags(&self) -> WorkspaceFlags {
74        WorkspaceFlags {
75            enable_search: self.enable_search.load(Ordering::Relaxed),
76            enable_viewed: self.enable_viewed.load(Ordering::Relaxed),
77            enable_edit: self.enable_edit.load(Ordering::Relaxed),
78            enable_live: self.enable_live.load(Ordering::Relaxed),
79            enable_chat: self.enable_chat.load(Ordering::Relaxed),
80            shared_annotation: self.shared_annotation.load(Ordering::Relaxed),
81        }
82    }
83
84    pub(crate) fn is_ephemeral(&self) -> bool {
85        self.single_file.is_some()
86    }
87
88    /// True when `rel` is the workspace's pinned file or one of the assets it
89    /// currently references. Always true for non-single-file workspaces.
90    pub(crate) fn allows(&self, rel: &str) -> bool {
91        let Some(only) = &self.single_file else {
92            return true;
93        };
94        if rel == only {
95            return true;
96        }
97        self.allowed_assets.read().unwrap().contains(rel)
98    }
99}
100
101/// Workspace info as serialized to JSON by `GET /api/workspaces`. Lives here
102/// because it's built from [`WorkspaceEntry`] state, but its only public
103/// contract is the wire format — see `crate::server::api` for the canonical
104/// re-export.
105#[derive(Serialize)]
106pub struct WorkspaceInfo {
107    pub id: String,
108    /// Workspace **serving root** — what `/{id}/…` resolves under. For
109    /// single-file (ephemeral) workspaces this is the parent directory, not
110    /// the file itself; the file name lives in `single_file`. Consumers that
111    /// render a user-visible path **must** join the two for ephemeral entries
112    /// (or filter ephemeral entries out entirely).
113    pub path: String,
114    #[serde(flatten)]
115    pub flags: WorkspaceFlags,
116    pub search_ready: bool,
117    /// True for single-file workspaces — the GUI's Settings list filters these
118    /// out (they're created by Open-With and live in memory only).
119    pub ephemeral: bool,
120    /// `Some(filename)` only when ephemeral, for callers that want to display
121    /// or re-derive the URL. Omitted from the wire format when None.
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub single_file: Option<String>,
124}
125
126/// Invoked whenever the registry mutates (add / update_flags / remove).
127/// The host (GUI or CLI daemon) wires this to persist workspaces to
128/// `~/.markon/settings.json` so CLI-driven and GUI-driven changes are
129/// treated identically.
130pub type PersistHook = Arc<dyn Fn(&WorkspaceRegistry) + Send + Sync>;
131
132pub struct WorkspaceRegistry {
133    inner: RwLock<HashMap<String, Arc<WorkspaceEntry>>>,
134    pub(crate) salt: String,
135    persist: RwLock<Option<PersistHook>>,
136    /// Shared broadcaster the server populates once its WS channel is alive.
137    /// Watchers spawned by `add()` capture a clone of this Arc and read it
138    /// lazily on each event, so setting the broadcaster after some entries
139    /// already exist still wires them up correctly.
140    live_tx: Arc<ArcSwapOption<broadcast::Sender<String>>>,
141}
142
143/// Stable workspace id: truncated SHA-256 of salt + path.
144pub fn hash_id(path: &Path, salt: &str) -> String {
145    use sha2::{Digest, Sha256};
146    let mut h = Sha256::new();
147    h.update(salt.as_bytes());
148    h.update(b"\0");
149    h.update(path.as_os_str().to_string_lossy().as_bytes());
150    let digest = h.finalize();
151    format!(
152        "{:02x}{:02x}{:02x}{:02x}",
153        digest[0], digest[1], digest[2], digest[3]
154    )
155}
156
157/// Cryptographically-random 32-hex-char token. Used as the management API
158/// bearer (`X-Markon-Token`) and as the per-install salt for workspace IDs.
159/// Backed by `uuid::Uuid::new_v4` which sources 122 bits of entropy from the
160/// OS RNG — a meaningful upgrade over a hash of `SystemTime + pid`.
161pub(crate) fn generate_token() -> String {
162    uuid::Uuid::new_v4().simple().to_string()
163}
164
165/// Expand `~` / `~` and canonicalize (dunce strips the `\\?\` verbatim prefix
166/// on Windows so UI-visible paths stay clean).
167pub fn expand_and_canonicalize(raw: &str) -> std::io::Result<PathBuf> {
168    let normalized = if raw.starts_with('~') {
169        raw.replacen('~', "~", 1)
170    } else {
171        raw.to_string()
172    };
173    let expanded = if normalized.starts_with("~/") || normalized == "~" {
174        dirs::home_dir()
175            .map(|home| {
176                if normalized == "~" {
177                    home
178                } else {
179                    home.join(&normalized[2..])
180                }
181            })
182            .unwrap_or_else(|| PathBuf::from(&normalized))
183    } else {
184        PathBuf::from(&normalized)
185    };
186    dunce::canonicalize(&expanded).or(Ok(expanded))
187}
188
189impl WorkspaceRegistry {
190    pub fn new(salt: String) -> Self {
191        Self {
192            inner: RwLock::new(HashMap::new()),
193            salt,
194            persist: RwLock::new(None),
195            live_tx: Arc::new(ArcSwapOption::empty()),
196        }
197    }
198    pub fn set_persist_hook(&self, hook: PersistHook) {
199        *self.persist.write().unwrap() = Some(hook);
200    }
201    /// Wire a broadcast sender that watchers use to push file-change events to
202    /// connected browser tabs. Pass `None` to disconnect (e.g. on shutdown).
203    pub(crate) fn set_live_broadcaster(&self, tx: Option<broadcast::Sender<String>>) {
204        self.live_tx.store(tx.map(Arc::new));
205    }
206    fn notify_persist(&self) {
207        let hook = self.persist.read().unwrap().clone();
208        if let Some(hook) = hook {
209            hook(self);
210        }
211    }
212    pub fn add(&self, config: WorkspaceConfig) -> String {
213        // Hash on the workspace's identity, not its serving root: a single-file
214        // workspace and an enclosing directory workspace coexist with distinct
215        // ids even though they share the same `root` (parent dir). Same file
216        // re-opened → same id → idempotent reuse.
217        let identity = match &config.single_file {
218            Some(name) => config.path.join(name),
219            None => config.path.clone(),
220        };
221        let id = hash_id(&identity, &self.salt);
222        // Idempotent: same identity registered twice just updates flags on the
223        // existing entry instead of spawning a second indexer thread.
224        if self.inner.read().unwrap().contains_key(&id) {
225            self.update_flags(&id, config.flags);
226            return id;
227        }
228        let (config_tx, _) = broadcast::channel(4);
229        let single_file = config.single_file.clone();
230        let entry = Arc::new(WorkspaceEntry {
231            id: id.clone(),
232            root: config.path.clone(),
233            enable_search: AtomicBool::new(config.flags.enable_search),
234            enable_viewed: AtomicBool::new(config.flags.enable_viewed),
235            enable_edit: AtomicBool::new(config.flags.enable_edit),
236            enable_live: AtomicBool::new(config.flags.enable_live),
237            enable_chat: AtomicBool::new(config.flags.enable_chat),
238            shared_annotation: AtomicBool::new(config.flags.shared_annotation),
239            config_tx,
240            search_index: ArcSwapOption::empty(),
241            single_file: single_file.clone(),
242            allowed_assets: RwLock::new(HashSet::new()),
243            pending_edits: Arc::new(PendingEditStore::new()),
244        });
245        self.inner
246            .write()
247            .unwrap()
248            .insert(id.clone(), entry.clone());
249        match single_file {
250            Some(name) => {
251                // Seed allowed_assets from the file's current content, then watch
252                // for external edits to keep it fresh. Search indexing is
253                // suppressed regardless of `enable_search` — single-file mode
254                // skips the tantivy spin-up entirely (use Cmd/Ctrl+F instead).
255                refresh_allowed_assets(&entry, &name);
256                spawn_single_file_watcher(config.path, entry, name, self.live_tx.clone());
257            }
258            None => {
259                if config.flags.enable_search {
260                    spawn_search_indexer(config.path, entry);
261                }
262            }
263        }
264        self.notify_persist();
265        id
266    }
267    pub fn update_flags(&self, id: &str, flags: WorkspaceFlags) -> bool {
268        let guard = self.inner.read().unwrap();
269        let Some(entry) = guard.get(id).cloned() else {
270            return false;
271        };
272        drop(guard);
273        let was_search = entry
274            .enable_search
275            .swap(flags.enable_search, Ordering::Relaxed);
276        entry
277            .enable_viewed
278            .store(flags.enable_viewed, Ordering::Relaxed);
279        entry
280            .enable_edit
281            .store(flags.enable_edit, Ordering::Relaxed);
282        entry
283            .enable_live
284            .store(flags.enable_live, Ordering::Relaxed);
285        entry
286            .enable_chat
287            .store(flags.enable_chat, Ordering::Relaxed);
288        entry
289            .shared_annotation
290            .store(flags.shared_annotation, Ordering::Relaxed);
291        let _ = entry.config_tx.send(());
292        if flags.enable_search && !was_search && entry.search_index.load().is_none() {
293            let root = entry.root.clone();
294            spawn_search_indexer(root, entry);
295        }
296        self.notify_persist();
297        true
298    }
299    pub fn remove(&self, id: &str) -> bool {
300        let removed = self.inner.write().unwrap().remove(id).is_some();
301        if removed {
302            self.notify_persist();
303        }
304        removed
305    }
306    pub(crate) fn get(&self, id: &str) -> Option<Arc<WorkspaceEntry>> {
307        self.inner.read().unwrap().get(id).cloned()
308    }
309    pub(crate) fn list(&self) -> Vec<Arc<WorkspaceEntry>> {
310        self.inner.read().unwrap().values().cloned().collect()
311    }
312    pub fn info_list(&self) -> Vec<WorkspaceInfo> {
313        self.list()
314            .into_iter()
315            .map(|e| WorkspaceInfo {
316                id: e.id.clone(),
317                path: e.root.to_string_lossy().to_string(),
318                flags: e.flags(),
319                search_ready: e.search_ready(),
320                ephemeral: e.is_ephemeral(),
321                single_file: e.single_file.clone(),
322            })
323            .collect()
324    }
325}
326
327/// Read the single-file's current content and replace `entry.allowed_assets`
328/// with the asset paths it references. Errors (file gone, unreadable) clear
329/// the set — a missing source can't legitimately bless any sibling.
330fn refresh_allowed_assets(entry: &WorkspaceEntry, file_name: &str) {
331    let abs = entry.root.join(file_name);
332    let new_set = match std::fs::read_to_string(&abs) {
333        Ok(content) => extract_referenced_assets(&content),
334        Err(_) => HashSet::new(),
335    };
336    *entry.allowed_assets.write().unwrap() = new_set;
337}
338
339/// Watch the parent directory of a single-file workspace and:
340///   * filter events down to `{file_name} ∪ allowed_assets`
341///   * on changes to `file_name`, re-derive the asset allowlist so that newly
342///     referenced images become accessible (and removed ones stop being)
343///   * push a `file_changed` WS message so the open browser tab reloads.
344///
345/// `notify` cannot reliably watch a single file across platforms, so the
346/// minimum viable scope is the parent directory — non-recursive.
347fn spawn_single_file_watcher(
348    root: PathBuf,
349    entry: Arc<WorkspaceEntry>,
350    file_name: String,
351    live_tx: Arc<ArcSwapOption<broadcast::Sender<String>>>,
352) {
353    std::thread::spawn(move || {
354        let (tx, rx) = std::sync::mpsc::channel();
355        let Ok(mut watcher) = notify::recommended_watcher(move |res| {
356            if let Ok(e) = res {
357                let _ = tx.send(e);
358            }
359        }) else {
360            return;
361        };
362        if watcher.watch(&root, RecursiveMode::NonRecursive).is_err() {
363            return;
364        }
365        let target = root.join(&file_name);
366        while let Ok(event) = rx.recv() {
367            for path in event.paths {
368                let Ok(rel) = path.strip_prefix(&root) else {
369                    continue;
370                };
371                let rel_str = rel.to_string_lossy().to_string();
372                let touched_pinned = path == target;
373                let touched_asset = entry.allowed_assets.read().unwrap().contains(&rel_str);
374                if !(touched_pinned || touched_asset) {
375                    continue;
376                }
377                let mut should_broadcast = false;
378                match event.kind {
379                    EventKind::Create(_) | EventKind::Modify(_) => {
380                        if touched_pinned {
381                            refresh_allowed_assets(&entry, &file_name);
382                        }
383                        should_broadcast = true;
384                    }
385                    // Don't broadcast for Remove: the file just went away,
386                    // reloading would 404 the tab.
387                    EventKind::Remove(_) if touched_pinned => {
388                        entry.allowed_assets.write().unwrap().clear();
389                    }
390                    _ => {}
391                }
392                if should_broadcast {
393                    if let Some(tx) = live_tx.load_full() {
394                        let payload = serde_json::json!({
395                            "type": "file_changed",
396                            "workspace_id": entry.id,
397                            "path": rel_str,
398                        })
399                        .to_string();
400                        let _ = tx.send(payload);
401                    }
402                }
403            }
404        }
405    });
406}
407
408fn spawn_search_indexer(root: PathBuf, entry: Arc<WorkspaceEntry>) {
409    std::thread::spawn(move || {
410        if let Ok(idx) = SearchIndex::new(&root) {
411            let idx = Arc::new(idx);
412            entry.search_index.store(Some(idx.clone()));
413            start_file_watcher(idx, root);
414        }
415    });
416}
417
418fn start_file_watcher(index: Arc<SearchIndex>, root: PathBuf) {
419    std::thread::spawn(move || {
420        let (tx, rx) = std::sync::mpsc::channel();
421        let Ok(mut watcher) = notify::recommended_watcher(move |res| {
422            if let Ok(e) = res {
423                let _ = tx.send(e);
424            }
425        }) else {
426            return;
427        };
428        let _ = watcher.watch(&root, RecursiveMode::Recursive);
429        while let Ok(event) = rx.recv() {
430            for path in event.paths {
431                match event.kind {
432                    EventKind::Create(_) | EventKind::Modify(_) => {
433                        let _ = index.update_file(&path);
434                    }
435                    EventKind::Remove(_) => {
436                        let _ = index.delete_file(&path);
437                    }
438                    _ => {}
439                }
440            }
441        }
442    });
443}
444
445#[derive(serde::Serialize, serde::Deserialize)]
446pub struct ServerLock {
447    pub port: u16,
448    pub token: String,
449}
450impl ServerLock {
451    pub(crate) fn path() -> PathBuf {
452        dirs::home_dir()
453            .expect("HOME directory required")
454            .join(".markon")
455            .join("server.lock")
456    }
457    pub(crate) fn write(&self) -> std::io::Result<()> {
458        let path = Self::path();
459        if let Some(parent) = path.parent() {
460            std::fs::create_dir_all(parent)?;
461        }
462        std::fs::write(&path, serde_json::to_string(self).unwrap())
463    }
464    pub fn read() -> Option<Self> {
465        let path = Self::path();
466        let content = match std::fs::read_to_string(&path) {
467            Ok(c) => c,
468            Err(e) => {
469                if e.kind() != std::io::ErrorKind::NotFound {
470                    tracing::warn!("cannot read server lock {}: {e}", path.display());
471                }
472                return None;
473            }
474        };
475        match serde_json::from_str(&content) {
476            Ok(v) => Some(v),
477            Err(e) => {
478                tracing::warn!(
479                    "corrupted server lock file {}: {e}; ignoring",
480                    path.display()
481                );
482                None
483            }
484        }
485    }
486    pub(crate) fn remove() {
487        let _ = std::fs::remove_file(Self::path());
488    }
489    pub fn is_alive(&self) -> bool {
490        std::net::TcpStream::connect_timeout(
491            &std::net::SocketAddr::from(([127, 0, 0, 1], self.port)),
492            std::time::Duration::from_millis(500),
493        )
494        .is_ok()
495    }
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501    #[test]
502    fn hash_id_is_deterministic() {
503        let p = std::path::Path::new("/tmp/test");
504        assert_eq!(hash_id(p, "s"), hash_id(p, "s"));
505    }
506
507    #[test]
508    fn hash_id_depends_on_salt() {
509        let p = std::path::Path::new("/tmp/test");
510        assert_ne!(hash_id(p, "a"), hash_id(p, "b"));
511    }
512}