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            is_open: false,
66        }
67    }
68}
69
70#[async_trait]
71impl RowSource<FileListEntry> for FileFinderProvider {
72    async fn load(&self, query: &str, emit: Emit<FileListEntry>) {
73        let vault = Arc::clone(&self.vault);
74        let notes = self
75            .notes_cache
76            .get_or_init(|| async move { vault.get_all_notes().await.unwrap_or_default() })
77            .await;
78
79        if query.is_empty() {
80            let mut sorted = notes.clone();
81            sorted.sort_by_key(|(entry, _)| std::cmp::Reverse(entry.modified_secs));
82            let entries: Vec<FileListEntry> = sorted
83                .iter()
84                .map(|(entry, content)| self.to_entry(entry, content))
85                .collect();
86            emit.replace(entries);
87            return;
88        }
89
90        // Non-empty query: nucleo fuzzy filter
91        let candidates: Vec<MatchEntry> = notes
92            .iter()
93            .enumerate()
94            .map(|(i, (entry, content))| {
95                let filename = entry.path.get_parent_path().1;
96                let text = format!("{} {}", filename, content.title);
97                MatchEntry { idx: i, text }
98            })
99            .collect();
100
101        let query_str = query.to_string();
102        let matched = tokio::task::spawn_blocking(move || {
103            let mut matcher = Matcher::new(nucleo::Config::DEFAULT);
104            let pattern = Pattern::parse(&query_str, CaseMatching::Ignore, Normalization::Smart);
105            pattern.match_list(candidates, &mut matcher)
106        })
107        .await
108        .unwrap_or_default();
109
110        let result: Vec<FileListEntry> = matched
111            .into_iter()
112            .map(|(e, _score)| self.to_entry(&notes[e.idx].0, &notes[e.idx].1))
113            .collect();
114
115        // The "Create: <query>" affordance is supplied as a leading row by the
116        // engine (see `leading_row`), so it is not part of the loaded set.
117        emit.replace(result);
118    }
119
120    fn leading_row(&self, query: &str) -> Option<FileListEntry> {
121        if query.is_empty() {
122            return None;
123        }
124        // Build a note with `query` resolved against the current directory.
125        let resolved = self
126            .current_dir
127            .append(&VaultPath::note_path_from(query))
128            .flatten();
129        Some(FileListEntry::CreateNote {
130            filename: resolved.to_string(),
131            path: resolved,
132        })
133    }
134}