1use std::sync::Arc;
11use std::time::Duration;
12
13use kimun_core::NoteVault;
14use kimun_core::nfs::VaultPath;
15
16use crate::components::events::{AppEvent, AppTx};
17use crate::components::text_editor::FollowTarget;
18
19pub struct DocMeta {
20 vault: Arc<NoteVault>,
21 backlink_count: Option<usize>,
23 property_count: Option<usize>,
25 git_status: Option<String>,
27 last_git_fetch: Option<std::time::Instant>,
30 git_unavailable_since: Option<std::time::Instant>,
34 link_meta: Option<(String, Option<usize>)>,
37}
38
39impl DocMeta {
40 pub fn new(vault: Arc<NoteVault>) -> Self {
41 Self {
42 vault,
43 backlink_count: None,
44 property_count: None,
45 git_status: None,
46 last_git_fetch: None,
47 git_unavailable_since: None,
48 link_meta: None,
49 }
50 }
51
52 pub fn backlinks(&self) -> Option<usize> {
55 self.backlink_count
56 }
57
58 pub fn properties(&self) -> Option<usize> {
59 self.property_count
60 }
61
62 pub fn git(&self) -> Option<&String> {
63 self.git_status.as_ref()
64 }
65
66 pub fn note_opened(&mut self, path: &VaultPath, tx: &AppTx) {
71 self.backlink_count = None;
72 let path_for_props = path.clone();
73 let vault = self.vault.clone();
74 let path = path.clone();
75 let tx2 = tx.clone();
76 tokio::spawn(async move {
77 let count = vault
78 .get_backlinks(&path)
79 .await
80 .map(|b| b.len())
81 .unwrap_or_default();
82 tx2.send(AppEvent::BacklinkCountLoaded { path, count }).ok();
83 });
84 self.property_count = None;
85 self.refresh_properties(&path_for_props, tx);
86 self.refresh_git(tx);
87 }
88
89 pub fn refresh_properties(&mut self, path: &VaultPath, tx: &AppTx) {
91 let vault = self.vault.clone();
92 let path = path.clone();
93 let tx2 = tx.clone();
94 tokio::spawn(async move {
95 let count = vault
96 .get_properties(&path)
97 .await
98 .map(|p| p.len())
99 .unwrap_or_default();
100 tx2.send(AppEvent::PropertyCountLoaded { path, count }).ok();
101 });
102 }
103
104 pub fn refresh_git(&mut self, tx: &AppTx) {
108 const GIT_FETCH_MIN_INTERVAL: Duration = Duration::from_secs(2);
109 const GIT_UNAVAILABLE_BACKOFF: Duration = Duration::from_secs(60);
110 let now = std::time::Instant::now();
111 if self
112 .git_unavailable_since
113 .is_some_and(|t| now.duration_since(t) < GIT_UNAVAILABLE_BACKOFF)
114 {
115 return;
116 }
117 if self
118 .last_git_fetch
119 .is_some_and(|t| now.duration_since(t) < GIT_FETCH_MIN_INTERVAL)
120 {
121 return;
122 }
123 self.last_git_fetch = Some(now);
124 let root = self.vault.workspace_path().as_path().to_path_buf();
125 let tx2 = tx.clone();
126 tokio::spawn(async move {
127 let status = crate::util::git_status::fetch(root).await;
128 tx2.send(AppEvent::GitStatusLoaded(status)).ok();
129 });
130 }
131
132 pub fn link_segment(
140 &mut self,
141 link: Option<&FollowTarget>,
142 current_note: &VaultPath,
143 tx: Option<&AppTx>,
144 ) -> Option<String> {
145 match link {
146 Some(FollowTarget::Link(target)) => {
147 if self.link_meta.as_ref().map(|(t, _)| t.as_str()) != Some(target.as_str()) {
148 self.link_meta = Some((target.clone(), None));
149 if let Some(tx) = tx {
150 let target_clean = kimun_core::note::scan::split_link_fragment(target)
153 .0
154 .to_string();
155 let vault = self.vault.clone();
156 let t2 = target.clone();
157 let note_path = current_note.clone();
158 let tx2 = tx.clone();
159 tokio::spawn(async move {
160 let path = VaultPath::note_path_from(&target_clean)
161 .resolve_link_in_note(¬e_path);
162 let count = vault
163 .get_backlinks(&path)
164 .await
165 .map(|b| b.len())
166 .unwrap_or_default();
167 tx2.send(AppEvent::LinkTargetMeta { target: t2, count })
168 .ok();
169 });
170 }
171 }
172 match &self.link_meta {
173 Some((t, Some(n))) => Some(format!("→ {t} · {n} backlinks")),
174 Some((t, None)) => Some(format!("→ {t}")),
175 None => None,
176 }
177 }
178 Some(FollowTarget::Label(name)) => {
179 self.link_meta = None;
180 Some(format!("→ #{name} · tag query"))
181 }
182 None => {
183 self.link_meta = None;
184 None
185 }
186 }
187 }
188
189 pub fn handle(&mut self, event: AppEvent, current_note: &VaultPath) -> Option<AppEvent> {
194 match event {
195 AppEvent::BacklinkCountLoaded { path, count } => {
196 if path == *current_note {
198 self.backlink_count = Some(count);
199 }
200 None
201 }
202 AppEvent::PropertyCountLoaded { path, count } => {
203 if path == *current_note {
204 self.property_count = Some(count);
205 }
206 None
207 }
208 AppEvent::LinkTargetMeta { target, count } => {
209 if let Some((cached, slot)) = &mut self.link_meta
211 && *cached == target
212 {
213 *slot = Some(count);
214 }
215 None
216 }
217 AppEvent::GitStatusLoaded(status) => {
218 match (&self.git_status, status) {
219 (None, None) => {
221 self.git_unavailable_since = Some(std::time::Instant::now());
222 }
223 (Some(_), None) => {}
226 (_, some) => {
227 self.git_status = some;
228 self.git_unavailable_since = None;
229 }
230 }
231 None
232 }
233 other => Some(other),
234 }
235 }
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241 use kimun_core::NoteVault;
242 use tokio::sync::mpsc::unbounded_channel;
243
244 async fn meta() -> (DocMeta, tempfile::TempDir) {
245 let dir = tempfile::tempdir().unwrap();
246 let vault = Arc::new(
247 NoteVault::new(kimun_core::VaultConfig::new(crate::test_support::sys(
248 dir.path(),
249 )))
250 .await
251 .unwrap(),
252 );
253 (DocMeta::new(vault), dir)
254 }
255
256 fn note(p: &str) -> VaultPath {
257 VaultPath::note_path_from(p)
258 }
259
260 #[tokio::test]
261 async fn backlink_count_guards_against_stale_paths() {
262 let (mut dm, _dir) = meta().await;
263 let current = note("/a.md");
264 dm.handle(
265 AppEvent::BacklinkCountLoaded {
266 path: note("/old.md"),
267 count: 7,
268 },
269 ¤t,
270 );
271 assert_eq!(dm.backlinks(), None, "stale path must not land");
272 dm.handle(
273 AppEvent::BacklinkCountLoaded {
274 path: current.clone(),
275 count: 3,
276 },
277 ¤t,
278 );
279 assert_eq!(dm.backlinks(), Some(3));
280 }
281
282 #[tokio::test]
283 async fn git_memoizes_unavailability_and_keeps_last_on_transient_failure() {
284 let (mut dm, _dir) = meta().await;
285 let current = note("/a.md");
286 dm.handle(AppEvent::GitStatusLoaded(None), ¤t);
288 assert!(dm.git_unavailable_since.is_some());
289 dm.git_unavailable_since = None;
291 dm.handle(AppEvent::GitStatusLoaded(Some("git ✓".into())), ¤t);
292 dm.handle(AppEvent::GitStatusLoaded(None), ¤t);
293 assert_eq!(dm.git().map(String::as_str), Some("git ✓"));
294 }
295
296 #[tokio::test]
297 async fn link_count_lands_only_on_matching_cached_target() {
298 let (mut dm, _dir) = meta().await;
299 let current = note("/a.md");
300 let (tx, _rx) = unbounded_channel();
301 let target = FollowTarget::Link("b".to_string());
302 let seg = dm.link_segment(Some(&target), ¤t, Some(&tx));
304 assert_eq!(seg.as_deref(), Some("→ b"));
305 dm.handle(
307 AppEvent::LinkTargetMeta {
308 target: "other".into(),
309 count: 9,
310 },
311 ¤t,
312 );
313 assert_eq!(
314 dm.link_segment(Some(&target), ¤t, Some(&tx))
315 .as_deref(),
316 Some("→ b")
317 );
318 dm.handle(
320 AppEvent::LinkTargetMeta {
321 target: "b".into(),
322 count: 2,
323 },
324 ¤t,
325 );
326 assert_eq!(
327 dm.link_segment(Some(&target), ¤t, Some(&tx))
328 .as_deref(),
329 Some("→ b · 2 backlinks")
330 );
331 assert_eq!(dm.link_segment(None, ¤t, Some(&tx)), None);
333 }
334
335 #[tokio::test]
336 async fn unowned_events_are_handed_back() {
337 let (mut dm, _dir) = meta().await;
338 let back = dm.handle(AppEvent::Redraw, ¬e("/a.md"));
339 assert!(matches!(back, Some(AppEvent::Redraw)));
340 }
341
342 #[tokio::test]
343 async fn property_count_guards_against_stale_paths() {
344 let (mut dm, _dir) = meta().await;
345 let current = note("/a.md");
346 dm.handle(
347 AppEvent::PropertyCountLoaded {
348 path: note("/old.md"),
349 count: 4,
350 },
351 ¤t,
352 );
353 assert_eq!(dm.properties(), None);
354 dm.handle(
355 AppEvent::PropertyCountLoaded {
356 path: current.clone(),
357 count: 2,
358 },
359 ¤t,
360 );
361 assert_eq!(dm.properties(), Some(2));
362 }
363}