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    /// Workspace git summary for the status bar, `None` when unknown/absent.
24    git_status: Option<String>,
25    /// When the last git fetch was spawned — throttles the per-event
26    /// subprocess (rapid navigation must not fork one `git status` per note).
27    last_git_fetch: Option<std::time::Instant>,
28    /// When a fetch last reported "no repo / no git" — probing backs off to
29    /// once a minute instead of once per open, but a `git init` mid-session
30    /// still gets picked up.
31    git_unavailable_since: Option<std::time::Instant>,
32    /// Link-under-cursor affordance cache: `(target, backlink count once
33    /// loaded)`. Refreshed when the cursor enters a different link.
34    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    // ── Reads (status bar segments) ─────────────────────────────────────
50
51    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    // ── Note lifecycle ──────────────────────────────────────────────────
60
61    /// A note was (re)opened: reset and re-fetch its backlink count, and
62    /// refresh the git summary.
63    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    /// Spawn the workspace git summary fetch, throttled: at most one
80    /// subprocess per couple of seconds, since rapid navigation would
81    /// otherwise fork a whole-tree `git status` per note open.
82    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    // ── Link-under-cursor affordance (spec §5.2) ────────────────────────
108
109    /// The `→ target · N backlinks` status segment for the link under the
110    /// cursor, if any. Caches per target; the count loads async (one fetch
111    /// per target change, resolved exactly like follow-link so the count
112    /// keys the note that would actually open). `tx == None` (before the
113    /// screen's first on_enter) renders the target without a count.
114    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                        // Resolve like follow_link does: strip a `#fragment`,
126                        // then resolve relative targets against this note.
127                        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(&note_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    // ── Event intake ────────────────────────────────────────────────────
168
169    /// Consume the async-result events this module owns; hand anything else
170    /// back to the caller. `current_note` drives the staleness guards.
171    pub fn handle(&mut self, event: AppEvent, current_note: &VaultPath) -> Option<AppEvent> {
172        match event {
173            AppEvent::BacklinkCountLoaded { path, count } => {
174                // Ignore stale loads for notes already navigated away from.
175                if path == *current_note {
176                    self.backlink_count = Some(count);
177                }
178                None
179            }
180            AppEvent::LinkTargetMeta { target, count } => {
181                // Only land the count if the cursor is still on that link.
182                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                    // No repo (or no git): back off to a slow probe.
192                    (None, None) => {
193                        self.git_unavailable_since = Some(std::time::Instant::now());
194                    }
195                    // Had a value, fetch failed (index.lock contention, …):
196                    // keep showing the last known state.
197                    (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            &current,
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            &current,
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        // First None → unavailable: probing backs off.
259        dm.handle(AppEvent::GitStatusLoaded(None), &current);
260        assert!(dm.git_unavailable_since.is_some());
261        // A value followed by a failure keeps the last value.
262        dm.git_unavailable_since = None;
263        dm.handle(AppEvent::GitStatusLoaded(Some("git ✓".into())), &current);
264        dm.handle(AppEvent::GitStatusLoaded(None), &current);
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        // Cursor enters the link: target cached, no count yet.
275        let seg = dm.link_segment(Some(&target), &current, Some(&tx));
276        assert_eq!(seg.as_deref(), Some("→ b"));
277        // A count for a DIFFERENT target is ignored.
278        dm.handle(
279            AppEvent::LinkTargetMeta {
280                target: "other".into(),
281                count: 9,
282            },
283            &current,
284        );
285        assert_eq!(
286            dm.link_segment(Some(&target), &current, Some(&tx))
287                .as_deref(),
288            Some("→ b")
289        );
290        // The matching one lands.
291        dm.handle(
292            AppEvent::LinkTargetMeta {
293                target: "b".into(),
294                count: 2,
295            },
296            &current,
297        );
298        assert_eq!(
299            dm.link_segment(Some(&target), &current, Some(&tx))
300                .as_deref(),
301            Some("→ b · 2 backlinks")
302        );
303        // Cursor leaves: cache cleared.
304        assert_eq!(dm.link_segment(None, &current, 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, &note("/a.md"));
311        assert!(matches!(back, Some(AppEvent::Redraw)));
312    }
313}