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 git_status: Option<String>,
25 last_git_fetch: Option<std::time::Instant>,
28 git_unavailable_since: Option<std::time::Instant>,
32 link_meta: Option<(String, Option<usize>)>,
35}
36
37impl DocMeta {
38 pub fn new(vault: Arc<NoteVault>) -> Self {
39 Self {
40 vault,
41 backlink_count: None,
42 git_status: None,
43 last_git_fetch: None,
44 git_unavailable_since: None,
45 link_meta: None,
46 }
47 }
48
49 pub fn backlinks(&self) -> Option<usize> {
52 self.backlink_count
53 }
54
55 pub fn git(&self) -> Option<&String> {
56 self.git_status.as_ref()
57 }
58
59 pub fn note_opened(&mut self, path: &VaultPath, tx: &AppTx) {
64 self.backlink_count = None;
65 let vault = self.vault.clone();
66 let path = path.clone();
67 let tx2 = tx.clone();
68 tokio::spawn(async move {
69 let count = vault
70 .get_backlinks(&path)
71 .await
72 .map(|b| b.len())
73 .unwrap_or_default();
74 tx2.send(AppEvent::BacklinkCountLoaded { path, count }).ok();
75 });
76 self.refresh_git(tx);
77 }
78
79 pub fn refresh_git(&mut self, tx: &AppTx) {
83 const GIT_FETCH_MIN_INTERVAL: Duration = Duration::from_secs(2);
84 const GIT_UNAVAILABLE_BACKOFF: Duration = Duration::from_secs(60);
85 let now = std::time::Instant::now();
86 if self
87 .git_unavailable_since
88 .is_some_and(|t| now.duration_since(t) < GIT_UNAVAILABLE_BACKOFF)
89 {
90 return;
91 }
92 if self
93 .last_git_fetch
94 .is_some_and(|t| now.duration_since(t) < GIT_FETCH_MIN_INTERVAL)
95 {
96 return;
97 }
98 self.last_git_fetch = Some(now);
99 let root = self.vault.workspace_path().as_path().to_path_buf();
100 let tx2 = tx.clone();
101 tokio::spawn(async move {
102 let status = crate::util::git_status::fetch(root).await;
103 tx2.send(AppEvent::GitStatusLoaded(status)).ok();
104 });
105 }
106
107 pub fn link_segment(
115 &mut self,
116 link: Option<&FollowTarget>,
117 current_note: &VaultPath,
118 tx: Option<&AppTx>,
119 ) -> Option<String> {
120 match link {
121 Some(FollowTarget::Link(target)) => {
122 if self.link_meta.as_ref().map(|(t, _)| t.as_str()) != Some(target.as_str()) {
123 self.link_meta = Some((target.clone(), None));
124 if let Some(tx) = tx {
125 let target_clean = target
128 .split('#')
129 .next()
130 .unwrap_or(target)
131 .trim_end()
132 .to_string();
133 let vault = self.vault.clone();
134 let t2 = target.clone();
135 let note_path = current_note.clone();
136 let tx2 = tx.clone();
137 tokio::spawn(async move {
138 let path = VaultPath::note_path_from(&target_clean)
139 .resolve_link_in_note(¬e_path);
140 let count = vault
141 .get_backlinks(&path)
142 .await
143 .map(|b| b.len())
144 .unwrap_or_default();
145 tx2.send(AppEvent::LinkTargetMeta { target: t2, count })
146 .ok();
147 });
148 }
149 }
150 match &self.link_meta {
151 Some((t, Some(n))) => Some(format!("→ {t} · {n} backlinks")),
152 Some((t, None)) => Some(format!("→ {t}")),
153 None => None,
154 }
155 }
156 Some(FollowTarget::Label(name)) => {
157 self.link_meta = None;
158 Some(format!("→ #{name} · tag query"))
159 }
160 None => {
161 self.link_meta = None;
162 None
163 }
164 }
165 }
166
167 pub fn handle(&mut self, event: AppEvent, current_note: &VaultPath) -> Option<AppEvent> {
172 match event {
173 AppEvent::BacklinkCountLoaded { path, count } => {
174 if path == *current_note {
176 self.backlink_count = Some(count);
177 }
178 None
179 }
180 AppEvent::LinkTargetMeta { target, count } => {
181 if let Some((cached, slot)) = &mut self.link_meta
183 && *cached == target
184 {
185 *slot = Some(count);
186 }
187 None
188 }
189 AppEvent::GitStatusLoaded(status) => {
190 match (&self.git_status, status) {
191 (None, None) => {
193 self.git_unavailable_since = Some(std::time::Instant::now());
194 }
195 (Some(_), None) => {}
198 (_, some) => {
199 self.git_status = some;
200 self.git_unavailable_since = None;
201 }
202 }
203 None
204 }
205 other => Some(other),
206 }
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213 use kimun_core::NoteVault;
214 use tokio::sync::mpsc::unbounded_channel;
215
216 async fn meta() -> (DocMeta, tempfile::TempDir) {
217 let dir = tempfile::tempdir().unwrap();
218 let vault = Arc::new(
219 NoteVault::new(kimun_core::VaultConfig::new(crate::test_support::sys(
220 dir.path(),
221 )))
222 .await
223 .unwrap(),
224 );
225 (DocMeta::new(vault), dir)
226 }
227
228 fn note(p: &str) -> VaultPath {
229 VaultPath::note_path_from(p)
230 }
231
232 #[tokio::test]
233 async fn backlink_count_guards_against_stale_paths() {
234 let (mut dm, _dir) = meta().await;
235 let current = note("/a.md");
236 dm.handle(
237 AppEvent::BacklinkCountLoaded {
238 path: note("/old.md"),
239 count: 7,
240 },
241 ¤t,
242 );
243 assert_eq!(dm.backlinks(), None, "stale path must not land");
244 dm.handle(
245 AppEvent::BacklinkCountLoaded {
246 path: current.clone(),
247 count: 3,
248 },
249 ¤t,
250 );
251 assert_eq!(dm.backlinks(), Some(3));
252 }
253
254 #[tokio::test]
255 async fn git_memoizes_unavailability_and_keeps_last_on_transient_failure() {
256 let (mut dm, _dir) = meta().await;
257 let current = note("/a.md");
258 dm.handle(AppEvent::GitStatusLoaded(None), ¤t);
260 assert!(dm.git_unavailable_since.is_some());
261 dm.git_unavailable_since = None;
263 dm.handle(AppEvent::GitStatusLoaded(Some("git ✓".into())), ¤t);
264 dm.handle(AppEvent::GitStatusLoaded(None), ¤t);
265 assert_eq!(dm.git().map(String::as_str), Some("git ✓"));
266 }
267
268 #[tokio::test]
269 async fn link_count_lands_only_on_matching_cached_target() {
270 let (mut dm, _dir) = meta().await;
271 let current = note("/a.md");
272 let (tx, _rx) = unbounded_channel();
273 let target = FollowTarget::Link("b".to_string());
274 let seg = dm.link_segment(Some(&target), ¤t, Some(&tx));
276 assert_eq!(seg.as_deref(), Some("→ b"));
277 dm.handle(
279 AppEvent::LinkTargetMeta {
280 target: "other".into(),
281 count: 9,
282 },
283 ¤t,
284 );
285 assert_eq!(
286 dm.link_segment(Some(&target), ¤t, Some(&tx))
287 .as_deref(),
288 Some("→ b")
289 );
290 dm.handle(
292 AppEvent::LinkTargetMeta {
293 target: "b".into(),
294 count: 2,
295 },
296 ¤t,
297 );
298 assert_eq!(
299 dm.link_segment(Some(&target), ¤t, Some(&tx))
300 .as_deref(),
301 Some("→ b · 2 backlinks")
302 );
303 assert_eq!(dm.link_segment(None, ¤t, Some(&tx)), None);
305 }
306
307 #[tokio::test]
308 async fn unowned_events_are_handed_back() {
309 let (mut dm, _dir) = meta().await;
310 let back = dm.handle(AppEvent::Redraw, ¬e("/a.md"));
311 assert!(matches!(back, Some(AppEvent::Redraw)));
312 }
313}