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
72pub 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
84pub 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
113pub 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 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}