use std::collections::HashSet;
use std::sync::Arc;
use async_trait::async_trait;
use kimun_core::NoteVault;
use kimun_core::nfs::VaultPath;
use kimun_server_client::ChunkResult;
use ratatui::Frame;
use ratatui::layout::Rect;
use crate::components::event_state::EventState;
use crate::components::events::{AppEvent, AppTx, FileOp, InputEvent};
use crate::components::file_list::FileListEntry;
use crate::components::note_browser::format_journal_date;
use crate::components::query_list_panel::{ListPanelSpec, QueryListPanel};
use crate::components::search_list::{Emit, RowSource};
use crate::rag::rag_client;
use crate::settings::SharedSettings;
use crate::settings::icons::Icons;
use crate::settings::themes::Theme;
fn chunks_to_entries(chunks: Vec<ChunkResult>, vault: &NoteVault) -> Vec<FileListEntry> {
let mut seen: HashSet<VaultPath> = HashSet::new();
let mut out = Vec::new();
for chunk in chunks {
let path = VaultPath::new(&chunk.path);
if !seen.insert(path.flatten().absolute()) {
continue;
}
let filename = path.get_parent_path().1;
let title = if chunk.title.trim().is_empty() {
"<no title>".to_string()
} else {
chunk.title
};
let journal_date = vault.journal_date(&path).map(format_journal_date);
out.push(FileListEntry::Note {
path,
title,
filename,
journal_date,
is_open: false,
});
}
out
}
pub struct SemanticSource {
vault: Arc<NoteVault>,
settings: SharedSettings,
}
impl SemanticSource {
pub fn new(vault: Arc<NoteVault>, settings: SharedSettings) -> Self {
Self { vault, settings }
}
}
#[async_trait]
impl RowSource<FileListEntry> for SemanticSource {
async fn load(&self, query: &str, emit: Emit<FileListEntry>) {
if query.trim().is_empty() {
emit.replace(Vec::new());
return;
}
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
let entries = match rag_client(&self.settings, &self.vault).await {
Some(client) => match client.search(query, None).await {
Ok(chunks) => chunks_to_entries(chunks, &self.vault),
Err(e) => {
log::debug!("semantic search failed: {e}");
Vec::new()
}
},
None => Vec::new(),
};
emit.replace(entries);
}
}
pub struct SemanticSpec;
impl ListPanelSpec for SemanticSpec {
type Row = FileListEntry;
const TITLE: &'static str = "Semantic";
const HAS_FILTER: bool = true;
const BORDERED_INPUT: bool = true;
const LOCAL_FILTER: bool = false;
fn submit(row: &FileListEntry, tx: &AppTx) {
if let FileListEntry::Note { path, .. } = row {
tx.send(AppEvent::open(path.clone())).ok();
}
}
fn context_event(row: &FileListEntry) -> Option<AppEvent> {
match row {
FileListEntry::Note { path, .. } => {
Some(AppEvent::FileOp(FileOp::ShowMenu(path.clone())))
}
_ => None,
}
}
fn hints() -> Vec<(String, String)> {
vec![("Enter".into(), "Open".into())]
}
}
pub struct SemanticPanel {
vault: Arc<NoteVault>,
settings: SharedSettings,
body: QueryListPanel<SemanticSpec>,
source_installed: bool,
}
impl SemanticPanel {
pub fn new(vault: Arc<NoteVault>, settings: SharedSettings, icons: Icons) -> Self {
Self {
vault,
settings,
body: QueryListPanel::new(icons),
source_installed: false,
}
}
pub fn ensure_source(&mut self, tx: &AppTx) {
if !self.source_installed {
self.body.set_source(
SemanticSource::new(self.vault.clone(), self.settings.clone()),
tx,
);
self.source_installed = true;
}
}
pub fn hint_shortcuts(&self) -> Vec<(String, String)> {
self.body.hint_shortcuts()
}
pub fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
self.body.handle_input(event, tx)
}
pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
self.body.render(f, rect, theme, focused);
}
}
#[cfg(test)]
mod tests {
use super::*;
use kimun_core::VaultConfig;
use tempfile::TempDir;
fn chunk(path: &str, title: &str, score: f64) -> ChunkResult {
ChunkResult {
path: path.to_string(),
title: title.to_string(),
date: None,
content: String::new(),
hash: String::new(),
similarity_score: score,
ordinal: 0,
}
}
#[tokio::test]
async fn dedups_by_note_keeping_first_and_preserves_order() {
let dir = TempDir::new().unwrap();
let vault = NoteVault::new(VaultConfig::new(dir.path())).await.unwrap();
let chunks = vec![
chunk("/a.md", "A > Intro", 0.9),
chunk("/a.md", "A > Details", 0.7),
chunk("/b.md", "B", 0.5),
];
let entries = chunks_to_entries(chunks, &vault);
assert_eq!(entries.len(), 2);
match (&entries[0], &entries[1]) {
(
FileListEntry::Note {
path: pa,
title: ta,
..
},
FileListEntry::Note { path: pb, .. },
) => {
assert_eq!(pa.to_string(), "/a.md");
assert_eq!(ta, "A > Intro"); assert_eq!(pb.to_string(), "/b.md");
}
_ => panic!("expected note entries"),
}
}
}