1use std::collections::HashSet;
6use std::sync::Arc;
7
8use crate::server_client::ChunkResult;
9use async_trait::async_trait;
10use kimun_core::NoteVault;
11use kimun_core::nfs::VaultPath;
12
13use ratatui::Frame;
14use ratatui::layout::Rect;
15
16use crate::components::event_state::EventState;
17use crate::components::events::{AppEvent, AppTx, FileOp, InputEvent};
18use crate::components::file_list::FileListEntry;
19use crate::components::note_browser::format_journal_date;
20use crate::components::query_list_panel::{ListPanelSpec, QueryListPanel};
21use crate::components::search_list::{Emit, RowSource};
22use crate::rag::rag_client;
23use crate::settings::SharedSettings;
24use crate::settings::icons::Icons;
25use crate::settings::themes::Theme;
26
27fn chunks_to_entries(chunks: Vec<ChunkResult>, vault: &NoteVault) -> Vec<FileListEntry> {
30 let mut seen: HashSet<VaultPath> = HashSet::new();
31 let mut out = Vec::new();
32 for chunk in chunks {
33 let path = VaultPath::new(&chunk.path);
34 if !seen.insert(path.flatten().absolute()) {
36 continue;
37 }
38 let filename = path.get_parent_path().1;
39 let title = if chunk.title.trim().is_empty() {
42 "<no title>".to_string()
43 } else {
44 chunk.title
45 };
46 let journal_date = vault.journal_date(&path).map(format_journal_date);
47 out.push(FileListEntry::Note {
48 path,
49 title,
50 filename,
51 journal_date,
52 is_open: false,
53 });
54 }
55 out
56}
57
58pub struct SemanticSource {
60 vault: Arc<NoteVault>,
61 settings: SharedSettings,
62}
63
64impl SemanticSource {
65 pub fn new(vault: Arc<NoteVault>, settings: SharedSettings) -> Self {
66 Self { vault, settings }
67 }
68}
69
70#[async_trait]
71impl RowSource<FileListEntry> for SemanticSource {
72 async fn load(&self, query: &str, emit: Emit<FileListEntry>) {
73 if query.trim().is_empty() {
74 emit.replace(Vec::new());
75 return;
76 }
77 tokio::time::sleep(std::time::Duration::from_millis(250)).await;
81 let entries = match rag_client(&self.settings, &self.vault).await {
82 Some(client) => match client.search(query, None).await {
83 Ok(chunks) => chunks_to_entries(chunks, &self.vault),
84 Err(e) => {
87 log::debug!("semantic search failed: {e}");
88 Vec::new()
89 }
90 },
91 None => Vec::new(),
92 };
93 emit.replace(entries);
94 }
95}
96
97pub struct SemanticSpec;
100
101impl ListPanelSpec for SemanticSpec {
102 type Row = FileListEntry;
103 const TITLE: &'static str = "Semantic";
104 const HAS_FILTER: bool = true;
105 const BORDERED_INPUT: bool = true;
108 const LOCAL_FILTER: bool = false;
112
113 fn submit(row: &FileListEntry, tx: &AppTx) {
114 if let FileListEntry::Note { path, .. } = row {
115 tx.send(AppEvent::open(path.clone())).ok();
116 }
117 }
118
119 fn context_event(row: &FileListEntry) -> Option<AppEvent> {
120 match row {
121 FileListEntry::Note { path, .. } => {
122 Some(AppEvent::FileOp(FileOp::ShowMenu(path.clone())))
123 }
124 _ => None,
125 }
126 }
127
128 fn hints() -> Vec<(String, String)> {
129 vec![("Enter".into(), "Open".into())]
130 }
131}
132
133pub struct SemanticPanel {
136 vault: Arc<NoteVault>,
137 settings: SharedSettings,
138 body: QueryListPanel<SemanticSpec>,
139 source_installed: bool,
140}
141
142impl SemanticPanel {
143 pub fn new(
144 vault: Arc<NoteVault>,
145 settings: SharedSettings,
146 icons: Icons,
147 yank_combos: Vec<crate::keys::key_combo::KeyCombo>,
148 ) -> Self {
149 Self {
150 vault,
151 settings,
152 body: QueryListPanel::new(icons, yank_combos),
153 source_installed: false,
154 }
155 }
156
157 pub fn ensure_source(&mut self, tx: &AppTx) {
159 if !self.source_installed {
160 self.body.set_source(
161 SemanticSource::new(self.vault.clone(), self.settings.clone()),
162 tx,
163 );
164 self.source_installed = true;
165 }
166 }
167
168 pub fn hint_shortcuts(&self) -> Vec<(String, String)> {
169 self.body.hint_shortcuts()
170 }
171
172 pub fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
173 self.body.handle_input(event, tx)
174 }
175
176 pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
177 self.body.render(f, rect, theme, focused);
178 }
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184 use kimun_core::VaultConfig;
185 use tempfile::TempDir;
186
187 fn chunk(path: &str, title: &str, score: f64) -> ChunkResult {
188 ChunkResult {
189 path: path.to_string(),
190 title: title.to_string(),
191 date: None,
192 content: String::new(),
193 hash: String::new(),
194 similarity_score: score,
195 ordinal: 0,
196 }
197 }
198
199 #[tokio::test]
200 async fn dedups_by_note_keeping_first_and_preserves_order() {
201 let dir = TempDir::new().unwrap();
202 let vault = NoteVault::new(VaultConfig::new(crate::test_support::sys(dir.path())))
203 .await
204 .unwrap();
205
206 let chunks = vec![
208 chunk("/a.md", "A > Intro", 0.9),
209 chunk("/a.md", "A > Details", 0.7),
210 chunk("/b.md", "B", 0.5),
211 ];
212 let entries = chunks_to_entries(chunks, &vault);
213 assert_eq!(entries.len(), 2);
214 match (&entries[0], &entries[1]) {
215 (
216 FileListEntry::Note {
217 path: pa,
218 title: ta,
219 ..
220 },
221 FileListEntry::Note { path: pb, .. },
222 ) => {
223 assert_eq!(pa.to_string(), "/a.md");
224 assert_eq!(ta, "A > Intro"); assert_eq!(pb.to_string(), "/b.md");
226 }
227 _ => panic!("expected note entries"),
228 }
229 }
230}