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 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 pub single_file: Option<String>,
58 pub allowed_assets: RwLock<HashSet<String>>,
62 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 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#[derive(Serialize)]
106pub struct WorkspaceInfo {
107 pub id: String,
108 pub path: String,
114 #[serde(flatten)]
115 pub flags: WorkspaceFlags,
116 pub search_ready: bool,
117 pub ephemeral: bool,
120 #[serde(skip_serializing_if = "Option::is_none")]
123 pub single_file: Option<String>,
124}
125
126pub 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 live_tx: Arc<ArcSwapOption<broadcast::Sender<String>>>,
141}
142
143pub 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
157pub(crate) fn generate_token() -> String {
162 uuid::Uuid::new_v4().simple().to_string()
163}
164
165pub 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 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 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 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 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
327fn 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
339fn 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 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}