Skip to main content

kimun_notes/app_screen/
doc_meta.rs

1//! **DocMeta** — the editor screen's async document/status state behind one
2//! interface: backlink count of the open note, throttled workspace git
3//! summary, and the link-under-cursor affordance cache.
4//!
5//! Everything here shares one shape: a spawn site, a staleness guard, and a
6//! small cache — the bug class the revamp reviews kept finding. Concentrating
7//! them makes each rule unit-testable by feeding events and asserting
8//! segments, with no screen, vault contents, or terminal involved.
9
10use 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 of the open note (status line 2), async-loaded.
22    backlink_count: Option<usize>,
23    /// Frontmatter property count of the open note (status line 2).
24    property_count: Option<usize>,
25    /// Workspace git summary for the status bar, `None` when unknown/absent.
26    git_status: Option<String>,
27    /// When the last git fetch was spawned — throttles the per-event
28    /// subprocess (rapid navigation must not fork one `git status` per note).
29    last_git_fetch: Option<std::time::Instant>,
30    /// When a fetch last reported "no repo / no git" — probing backs off to
31    /// once a minute instead of once per open, but a `git init` mid-session
32    /// still gets picked up.
33    git_unavailable_since: Option<std::time::Instant>,
34    /// Link-under-cursor affordance cache: `(target, backlink count once
35    /// loaded)`. Refreshed when the cursor enters a different link.
36    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    // ── Reads (status bar segments) ─────────────────────────────────────
53
54    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    // ── Note lifecycle ──────────────────────────────────────────────────
67
68    /// A note was (re)opened: reset and re-fetch its backlink count, and
69    /// refresh the git summary.
70    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    /// Re-read the open note's property count (note open, save, dialog write).
90    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    /// Spawn the workspace git summary fetch, throttled: at most one
105    /// subprocess per couple of seconds, since rapid navigation would
106    /// otherwise fork a whole-tree `git status` per note open.
107    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    // ── Link-under-cursor affordance ────────────────────────
133
134    /// The `→ target · N backlinks` status segment for the link under the
135    /// cursor, if any. Caches per target; the count loads async (one fetch
136    /// per target change, resolved exactly like follow-link so the count
137    /// keys the note that would actually open). `tx == None` (before the
138    /// screen's first on_enter) renders the target without a count.
139    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                        // Resolve like follow_link does: strip a `#fragment`,
151                        // then resolve relative targets against this note.
152                        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(&note_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    // ── Event intake ────────────────────────────────────────────────────
190
191    /// Consume the async-result events this module owns; hand anything else
192    /// back to the caller. `current_note` drives the staleness guards.
193    pub fn handle(&mut self, event: AppEvent, current_note: &VaultPath) -> Option<AppEvent> {
194        match event {
195            AppEvent::BacklinkCountLoaded { path, count } => {
196                // Ignore stale loads for notes already navigated away from.
197                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                // Only land the count if the cursor is still on that link.
210                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                    // No repo (or no git): back off to a slow probe.
220                    (None, None) => {
221                        self.git_unavailable_since = Some(std::time::Instant::now());
222                    }
223                    // Had a value, fetch failed (index.lock contention, …):
224                    // keep showing the last known state.
225                    (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            &current,
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            &current,
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        // First None → unavailable: probing backs off.
287        dm.handle(AppEvent::GitStatusLoaded(None), &current);
288        assert!(dm.git_unavailable_since.is_some());
289        // A value followed by a failure keeps the last value.
290        dm.git_unavailable_since = None;
291        dm.handle(AppEvent::GitStatusLoaded(Some("git ✓".into())), &current);
292        dm.handle(AppEvent::GitStatusLoaded(None), &current);
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        // Cursor enters the link: target cached, no count yet.
303        let seg = dm.link_segment(Some(&target), &current, Some(&tx));
304        assert_eq!(seg.as_deref(), Some("→ b"));
305        // A count for a DIFFERENT target is ignored.
306        dm.handle(
307            AppEvent::LinkTargetMeta {
308                target: "other".into(),
309                count: 9,
310            },
311            &current,
312        );
313        assert_eq!(
314            dm.link_segment(Some(&target), &current, Some(&tx))
315                .as_deref(),
316            Some("→ b")
317        );
318        // The matching one lands.
319        dm.handle(
320            AppEvent::LinkTargetMeta {
321                target: "b".into(),
322                count: 2,
323            },
324            &current,
325        );
326        assert_eq!(
327            dm.link_segment(Some(&target), &current, Some(&tx))
328                .as_deref(),
329            Some("→ b · 2 backlinks")
330        );
331        // Cursor leaves: cache cleared.
332        assert_eq!(dm.link_segment(None, &current, 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, &note("/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            &current,
352        );
353        assert_eq!(dm.properties(), None);
354        dm.handle(
355            AppEvent::PropertyCountLoaded {
356                path: current.clone(),
357                count: 2,
358            },
359            &current,
360        );
361        assert_eq!(dm.properties(), Some(2));
362    }
363}