Skip to main content

kimun_notes/components/note_browser/
file_finder_provider.rs

1use std::sync::Arc;
2
3use async_trait::async_trait;
4use kimun_core::NoteVault;
5use kimun_core::nfs::{NoteEntryData, VaultPath};
6use kimun_core::note::NoteContentData;
7use nucleo::Matcher;
8use nucleo::pattern::{CaseMatching, Normalization, Pattern};
9
10use super::format_journal_date;
11use crate::components::file_list::FileListEntry;
12use crate::components::search_list::{Emit, RowSource};
13
14// ---------------------------------------------------------------------------
15// MatchEntry — adapts (index, haystack_str) for nucleo match_list
16// ---------------------------------------------------------------------------
17
18#[derive(Clone)]
19struct MatchEntry {
20    idx: usize,
21    text: String,
22}
23
24impl AsRef<str> for MatchEntry {
25    fn as_ref(&self) -> &str {
26        &self.text
27    }
28}
29
30// ---------------------------------------------------------------------------
31// FileFinderProvider
32// ---------------------------------------------------------------------------
33
34pub struct FileFinderProvider {
35    vault: Arc<NoteVault>,
36    current_dir: VaultPath,
37    notes_cache: Arc<tokio::sync::OnceCell<Vec<(NoteEntryData, NoteContentData)>>>,
38}
39
40impl FileFinderProvider {
41    pub fn new(vault: Arc<NoteVault>, current_dir: VaultPath) -> Self {
42        Self {
43            vault,
44            current_dir,
45            notes_cache: Arc::new(tokio::sync::OnceCell::new()),
46        }
47    }
48
49    fn to_entry(&self, entry: &NoteEntryData, content: &NoteContentData) -> FileListEntry {
50        let filename = entry.path.get_parent_path().1;
51        let title = if content.title.trim().is_empty() {
52            "<no title>".to_string()
53        } else {
54            content.title.clone()
55        };
56        let journal_date = self
57            .vault
58            .journal_date(&entry.path)
59            .map(format_journal_date);
60        FileListEntry::Note {
61            path: entry.path.clone(),
62            title,
63            filename,
64            journal_date,
65        }
66    }
67}
68
69#[async_trait]
70impl RowSource<FileListEntry> for FileFinderProvider {
71    async fn load(&self, query: &str, emit: Emit<FileListEntry>) {
72        let vault = Arc::clone(&self.vault);
73        let notes = self
74            .notes_cache
75            .get_or_init(|| async move { vault.get_all_notes().await.unwrap_or_default() })
76            .await;
77
78        if query.is_empty() {
79            let mut sorted = notes.clone();
80            sorted.sort_by_key(|(entry, _)| std::cmp::Reverse(entry.modified_secs));
81            let entries: Vec<FileListEntry> = sorted
82                .iter()
83                .map(|(entry, content)| self.to_entry(entry, content))
84                .collect();
85            emit.replace(entries);
86            return;
87        }
88
89        // Non-empty query: nucleo fuzzy filter
90        let candidates: Vec<MatchEntry> = notes
91            .iter()
92            .enumerate()
93            .map(|(i, (entry, content))| {
94                let filename = entry.path.get_parent_path().1;
95                let text = format!("{} {}", filename, content.title);
96                MatchEntry { idx: i, text }
97            })
98            .collect();
99
100        let query_str = query.to_string();
101        let matched = tokio::task::spawn_blocking(move || {
102            let mut matcher = Matcher::new(nucleo::Config::DEFAULT);
103            let pattern = Pattern::parse(&query_str, CaseMatching::Ignore, Normalization::Smart);
104            pattern.match_list(candidates, &mut matcher)
105        })
106        .await
107        .unwrap_or_default();
108
109        let result: Vec<FileListEntry> = matched
110            .into_iter()
111            .map(|(e, _score)| self.to_entry(&notes[e.idx].0, &notes[e.idx].1))
112            .collect();
113
114        // The "Create: <query>" affordance is supplied as a leading row by the
115        // engine (see `leading_row`), so it is not part of the loaded set.
116        emit.replace(result);
117    }
118
119    fn leading_row(&self, query: &str) -> Option<FileListEntry> {
120        if query.is_empty() {
121            return None;
122        }
123        // Build a note with `query` resolved against the current directory.
124        let resolved = self
125            .current_dir
126            .append(&VaultPath::note_path_from(query))
127            .flatten();
128        Some(FileListEntry::CreateNote {
129            filename: resolved.to_string(),
130            path: resolved,
131        })
132    }
133}