use std::sync::Arc;
use std::sync::mpsc::Receiver;
use chrono::NaiveDate;
use kimun_core::NoteVault;
use kimun_core::nfs::VaultPath;
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::Style;
use ratatui::widgets::{Block, Borders, Paragraph};
use crate::components::autocomplete::AutocompleteMode;
use crate::components::event_state::EventState;
use crate::components::events::{AppEvent, AppTx, InputEvent, redraw_callback};
use crate::components::file_list::FileListEntry;
use crate::components::overlay::{Overlay, OverlayKind};
use crate::components::panel::{ModalBg, ModalSpec, modal_chrome};
use crate::components::saved_search_breadcrumb::SavedSearchBreadcrumb;
use crate::components::search_list::{
KeyReaction, RowSource, SearchList, SearchMouse, VaultSuggestions,
};
use crate::keys::KeyBindings;
use crate::keys::action_shortcuts::ActionShortcuts;
use crate::settings::icons::Icons;
use crate::settings::themes::Theme;
pub mod file_finder_provider;
pub mod link_results_provider;
pub mod search_provider;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BrowserScope {
Query,
Files,
}
pub struct NoteBrowserModal {
scope: BrowserScope,
prefix_glyph: &'static str,
title: String,
list: SearchList<FileListEntry>,
vault: Arc<NoteVault>,
tx: AppTx,
preview_text: String,
preview_task: Option<tokio::task::JoinHandle<()>>,
preview_rx: Option<Receiver<String>>,
preview_path: Option<VaultPath>,
key_bindings: KeyBindings,
saved_search: SavedSearchBreadcrumb,
}
impl NoteBrowserModal {
pub fn new(
title: impl Into<String>,
scope: BrowserScope,
provider: impl RowSource<FileListEntry>,
vault: Arc<NoteVault>,
key_bindings: KeyBindings,
icons: Icons,
tx: AppTx,
) -> Self {
Self::new_with_query(
title,
scope,
provider,
vault,
key_bindings,
icons,
tx,
String::new(),
)
}
#[allow(clippy::too_many_arguments)]
pub fn with_initial_query<S: Into<String>>(
title: impl Into<String>,
scope: BrowserScope,
provider: impl RowSource<FileListEntry>,
vault: Arc<NoteVault>,
key_bindings: KeyBindings,
icons: Icons,
tx: AppTx,
query: S,
) -> Self {
Self::new_with_query(
title,
scope,
provider,
vault,
key_bindings,
icons,
tx,
query.into(),
)
}
#[allow(clippy::too_many_arguments)]
fn new_with_query(
title: impl Into<String>,
scope: BrowserScope,
provider: impl RowSource<FileListEntry>,
vault: Arc<NoteVault>,
key_bindings: KeyBindings,
icons: Icons,
tx: AppTx,
initial_query: String,
) -> Self {
let prefix_glyph = match scope {
BrowserScope::Query => icons.rail_find,
BrowserScope::Files => icons.rail_files,
};
let mut builder = SearchList::builder(provider, redraw_callback(tx.clone()))
.initial_query(initial_query)
.icons(icons)
.autocomplete(
Arc::new(VaultSuggestions {
vault: vault.clone(),
}),
AutocompleteMode::SearchQuery,
);
if scope == BrowserScope::Query {
builder = builder.highlight_query();
}
let list = builder.build();
let mut modal = Self {
scope,
prefix_glyph,
title: title.into(),
list,
vault,
tx,
preview_text: String::new(),
preview_task: None,
preview_rx: None,
preview_path: None,
key_bindings,
saved_search: SavedSearchBreadcrumb::default(),
};
modal.refresh_preview(None);
modal
}
fn preview_needles(&self) -> Vec<String> {
if self.scope != BrowserScope::Query {
return Vec::new();
}
crate::components::query_highlight::emphasis_needles(self.list.query())
}
fn emphasis(&self) -> Option<Vec<String>> {
let needles = self.preview_needles();
(!needles.is_empty()).then_some(needles)
}
fn schedule_preview(&mut self, path: VaultPath) {
if let Some(handle) = self.preview_task.take() {
handle.abort();
}
let vault = Arc::clone(&self.vault);
let tx = self.tx.clone();
let (result_tx, result_rx) = std::sync::mpsc::channel();
self.preview_rx = Some(result_rx);
let handle = tokio::spawn(async move {
let text = vault.get_note_text(&path).await.unwrap_or_default();
result_tx.send(text).ok();
tx.send(AppEvent::Redraw).ok();
});
self.preview_task = Some(handle);
}
fn poll_preview(&mut self) {
let Some(rx) = &self.preview_rx else { return };
match rx.try_recv() {
Ok(text) => {
self.preview_text = text;
self.preview_rx = None;
self.preview_task = None;
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
self.preview_rx = None;
}
Err(std::sync::mpsc::TryRecvError::Empty) => {}
}
}
fn refresh_preview(&mut self, selected: Option<&FileListEntry>) {
let maybe_path = selected.and_then(|e| match e {
FileListEntry::Note { path, .. } => Some(path.clone()),
_ => None,
});
if let Some(path) = maybe_path {
self.schedule_preview(path);
} else {
self.preview_text.clear();
if let Some(h) = self.preview_task.take() {
h.abort();
}
}
}
fn selected_note_path(&self) -> Option<VaultPath> {
self.list.selected_row().and_then(|e| match e {
FileListEntry::Note { path, .. } => Some(path.clone()),
_ => None,
})
}
fn refresh_preview_from_list(&mut self) {
let path = self.selected_note_path();
self.preview_path = path.clone();
match path {
Some(path) => self.schedule_preview(path),
None => {
self.preview_text.clear();
if let Some(h) = self.preview_task.take() {
h.abort();
}
}
}
}
fn open_selected(&self, tx: &AppTx) {
let Some(entry) = self.list.selected_row() else {
return;
};
if let FileListEntry::CreateNote { path, .. } = entry {
let path = path.clone();
let vault = Arc::clone(&self.vault);
let tx = tx.clone();
tokio::spawn(async move {
vault.load_or_create_note(&path, None).await.ok();
tx.send(AppEvent::open(path)).ok();
});
return;
}
let path = entry.path().clone();
tx.send(AppEvent::OpenPath {
path,
emphasis: self.emphasis(),
})
.ok();
}
#[cfg(test)]
fn saved_search_breadcrumb(&self) -> Option<String> {
self.saved_search.label(self.list.query())
}
#[cfg(test)]
pub(super) fn query_text(&self) -> &str {
self.list.query()
}
}
impl Overlay for NoteBrowserModal {
fn kind(&self) -> OverlayKind {
OverlayKind::NoteBrowser
}
fn query(&self) -> Option<&str> {
Some(self.list.query())
}
fn saved_search_provenance(&self) -> Option<&str> {
self.saved_search.name()
}
fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
match event {
InputEvent::Mouse(mouse) => match self.list.handle_mouse(mouse) {
SearchMouse::Activated(_) => {
self.open_selected(tx);
EventState::Consumed
}
SearchMouse::Context(_) | SearchMouse::Selected(_) | SearchMouse::Scrolled => {
self.refresh_preview_from_list();
EventState::Consumed
}
SearchMouse::ContentScrollUp | SearchMouse::ContentScrollDown => {
EventState::Consumed
}
SearchMouse::None => EventState::NotConsumed,
},
InputEvent::Key(key) => match self.list.handle_key(key) {
KeyReaction::Submit => {
self.open_selected(tx);
EventState::Consumed
}
KeyReaction::Cancel => {
tx.send(AppEvent::CloseOverlay).ok();
EventState::Consumed
}
KeyReaction::Consumed => {
let accepted = self.list.take_accepted_saved_search();
let blank = self.list.query().trim().is_empty();
self.saved_search
.on_query_consumed(accepted, self.list.query(), blank);
self.refresh_preview_from_list();
EventState::Consumed
}
KeyReaction::Intercepted(_) | KeyReaction::Unhandled => EventState::NotConsumed,
},
_ => EventState::NotConsumed,
}
}
fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme) {
self.poll_preview();
let popup_rect = crate::components::centered_rect(75, 75, area);
let modal_style = Style::default()
.fg(theme.fg.to_ratatui())
.bg(theme.bg_hard.to_ratatui());
let title = format!(" {} ", self.title);
let inner = modal_chrome(
f,
popup_rect,
theme,
ModalSpec {
title: Some(&title),
bg: ModalBg::Hard,
..Default::default()
},
);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(1),
])
.split(inner);
let search_title = self
.saved_search
.border_title(self.list.query(), " Search ");
let result_count = self.list.match_count();
let search_block = Block::default()
.title(search_title)
.title(
ratatui::text::Line::from(ratatui::text::Span::styled(
format!(" {result_count} results "),
Style::default().fg(theme.gray.to_ratatui()),
))
.right_aligned(),
)
.borders(Borders::ALL)
.border_style(theme.border_style(true))
.style(modal_style);
let search_inner = search_block.inner(rows[0]);
f.render_widget(search_block, rows[0]);
let prefix = format!("{} ", self.prefix_glyph);
let prefix_w = unicode_width::UnicodeWidthStr::width(prefix.as_str()) as u16;
f.render_widget(
Paragraph::new(prefix).style(
Style::default()
.fg(theme.yellow.to_ratatui())
.bg(theme.bg_hard.to_ratatui()),
),
Rect {
width: prefix_w.min(search_inner.width),
..search_inner
},
);
let input_rect = Rect {
x: search_inner.x.saturating_add(prefix_w),
width: search_inner.width.saturating_sub(prefix_w),
..search_inner
};
self.list.render_query(f, input_rect, theme, true);
let columns = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(rows[1]);
let list_block = Block::default()
.borders(Borders::ALL)
.border_style(theme.border_style(false))
.style(modal_style);
let list_inner = list_block.inner(columns[0]);
f.render_widget(list_block, columns[0]);
self.list.render(f, list_inner, theme, false);
self.list.set_list_rect(list_inner);
self.list.set_panel_rect(popup_rect);
if self.selected_note_path() != self.preview_path {
self.refresh_preview_from_list();
}
let needles = self.preview_needles();
let match_count = count_matches(&self.preview_text, &needles);
let preview_title = match (&self.preview_path, match_count) {
(Some(path), Some(n)) => {
format!(" {} · {} matches ", path.get_name(), n)
}
(Some(path), None) => format!(" {} ", path.get_name()),
(None, _) => " Preview ".to_string(),
};
let preview_block = Block::default()
.title(preview_title)
.borders(Borders::ALL)
.border_style(theme.border_style(false))
.style(modal_style);
let preview_inner = preview_block.inner(columns[1]);
f.render_widget(preview_block, columns[1]);
f.render_widget(
Paragraph::new(highlight_matches(
&self.preview_text,
&needles,
theme,
modal_style,
)),
preview_inner,
);
f.render_widget(
Paragraph::new("↑↓: navigate | Enter: open | Esc: close")
.style(Style::default().fg(theme.fg_secondary.to_ratatui())),
rows[2],
);
self.list.render_autocomplete(f, popup_rect, theme);
}
fn hint_shortcuts(&self) -> Vec<(String, String)> {
let mut hints = vec![
("↑↓".to_string(), "navigate".to_string()),
("Enter".to_string(), "open".to_string()),
("Esc".to_string(), "close".to_string()),
];
if let Some(k) = self
.key_bindings
.first_combo_for(&ActionShortcuts::SaveCurrentQuery)
{
hints.push((k, "save query".to_string()));
}
hints
}
}
pub(super) fn format_journal_date(date: NaiveDate) -> String {
date.format("%A, %B %-d, %Y").to_string()
}
fn count_matches(text: &str, needles: &[String]) -> Option<usize> {
if needles.is_empty() {
return None;
}
let lower = text.to_lowercase();
Some(
needles
.iter()
.map(|n| lower.match_indices(n.as_str()).count())
.sum(),
)
}
fn highlight_matches<'a>(
text: &'a str,
needles: &[String],
theme: &Theme,
base: Style,
) -> ratatui::text::Text<'a> {
use ratatui::text::{Line, Span};
if needles.is_empty() {
return ratatui::text::Text::styled(text, base);
}
let emphasis = base.patch(
Style::default()
.fg(theme.color_search_match.to_ratatui())
.add_modifier(ratatui::style::Modifier::BOLD),
);
let mut lines = Vec::new();
for line in text.lines() {
let lower = line.to_lowercase();
if lower.len() != line.len() {
lines.push(Line::styled(line, base));
continue;
}
let mut ranges: Vec<(usize, usize)> = needles
.iter()
.flat_map(|n| {
lower
.match_indices(n.as_str())
.map(|(i, m)| (i, i + m.len()))
})
.collect();
ranges.sort_unstable_by_key(|(s, e)| (*s, std::cmp::Reverse(*e)));
ranges.dedup();
let mut spans = Vec::new();
let mut pos = 0;
for (start, end) in ranges {
if start < pos {
continue; }
if !line.is_char_boundary(start) || !line.is_char_boundary(end) {
continue;
}
if start > pos {
spans.push(Span::styled(&line[pos..start], base));
}
spans.push(Span::styled(&line[start..end], emphasis));
pos = end;
}
if pos < line.len() {
spans.push(Span::styled(&line[pos..], base));
}
lines.push(Line::from(spans));
}
ratatui::text::Text::from(lines)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::components::search_list::{Emit, RowSource};
use crate::settings::AppSettings;
use crate::test_support::temp_vault;
use async_trait::async_trait;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use tokio::sync::mpsc::unbounded_channel;
struct OneNoteSource {
path: VaultPath,
}
#[async_trait]
impl RowSource<FileListEntry> for OneNoteSource {
async fn load(&self, _query: &str, emit: Emit<FileListEntry>) {
emit.replace(vec![FileListEntry::Note {
path: self.path.clone(),
title: "Note".to_string(),
filename: self.path.to_string(),
journal_date: None,
}]);
}
}
async fn make_modal_with(source: impl RowSource<FileListEntry>, tx: AppTx) -> NoteBrowserModal {
let vault = temp_vault("modal").await;
let settings = AppSettings::default();
NoteBrowserModal::new(
"test",
BrowserScope::Query,
source,
vault,
settings.key_bindings.clone(),
settings.icons(),
tx,
)
}
#[tokio::test]
async fn modal_constructed_with_initial_query_prefills_input() {
let vault = temp_vault("modal_iq").await;
let settings = AppSettings::default();
let (tx, _rx) = unbounded_channel();
let modal = NoteBrowserModal::with_initial_query(
"test",
BrowserScope::Query,
OneNoteSource {
path: VaultPath::note_path_from("/a.md"),
},
vault,
settings.key_bindings.clone(),
settings.icons(),
tx,
"#important",
);
assert_eq!(modal.query_text(), "#important");
}
#[tokio::test]
async fn submit_opens_selected_note() {
let (tx, mut rx) = unbounded_channel();
let path = VaultPath::note_path_from("/a.md");
let mut modal = make_modal_with(OneNoteSource { path: path.clone() }, tx.clone()).await;
modal.list.poll_until_idle().await;
Overlay::handle_input(
&mut modal,
&InputEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
&tx,
);
let mut events = Vec::new();
while let Ok(ev) = rx.try_recv() {
events.push(ev);
}
assert!(
events
.iter()
.any(|e| matches!(e, AppEvent::OpenPath { path: p, .. } if *p == path)),
"expected OpenPath, got {events:?}"
);
assert!(
!events.iter().any(|e| matches!(e, AppEvent::CloseOverlay)),
"select must not emit CloseOverlay; editor's OpenPath handler closes the overlay, got {events:?}"
);
}
#[tokio::test]
async fn refresh_preview_tracks_selected_path() {
let (tx, _rx) = unbounded_channel();
let path = VaultPath::note_path_from("/a.md");
let mut modal = make_modal_with(OneNoteSource { path: path.clone() }, tx.clone()).await;
modal.list.poll_until_idle().await;
assert_eq!(modal.preview_path, None, "no path tracked before refresh");
modal.refresh_preview_from_list();
assert_eq!(
modal.preview_path,
Some(path),
"preview_path should track the selected note"
);
}
#[tokio::test]
async fn esc_closes_modal() {
let (tx, mut rx) = unbounded_channel();
let mut modal = make_modal_with(
OneNoteSource {
path: VaultPath::note_path_from("/a.md"),
},
tx.clone(),
)
.await;
Overlay::handle_input(
&mut modal,
&InputEvent::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
&tx,
);
let mut sent = false;
while let Ok(ev) = rx.try_recv() {
if matches!(ev, AppEvent::CloseOverlay) {
sent = true;
}
}
assert!(sent, "expected CloseOverlay on Esc");
}
#[tokio::test(flavor = "multi_thread")]
async fn accepting_saved_search_pins_breadcrumb() {
let vault = temp_vault("modal-ss").await;
vault.validate_and_init().await.unwrap();
vault.save_search("todo-week", "#todo").await.unwrap();
let settings = AppSettings::default();
let (tx, _rx) = unbounded_channel();
let mut modal = NoteBrowserModal::new(
"test",
BrowserScope::Query,
OneNoteSource {
path: VaultPath::note_path_from("/a.md"),
},
vault,
settings.key_bindings.clone(),
settings.icons(),
tx.clone(),
);
for ch in ['?', 't', 'o'] {
Overlay::handle_input(
&mut modal,
&InputEvent::Key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)),
&tx,
);
for _ in 0..30 {
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
modal.list.poll();
}
}
Overlay::handle_input(
&mut modal,
&InputEvent::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)),
&tx,
);
assert_eq!(modal.query_text(), "#todo");
assert_eq!(
modal.saved_search_breadcrumb().as_deref(),
Some("todo-week")
);
assert_eq!(Overlay::saved_search_provenance(&modal), Some("todo-week"));
}
}