1pub use create_note_dialog::CreateNoteDialog;
2pub use delete_dialog::DeleteConfirmDialog;
3pub use file_ops_menu::FileOpsMenuDialog;
4pub use help_dialog::HelpDialog;
5pub use move_dialog::MoveDialog;
6pub use pinned_notes_dialog::PinnedNotesDialog;
7pub use quick_note_modal::QuickNoteModal;
8pub use rename_dialog::RenameDialog;
9pub use save_search_dialog::SaveSearchDialog;
10pub use sort_dialog::SortDialog;
11pub use theme_picker::ThemePickerDialog;
12pub use update_dialog::UpdateAvailableDialog;
13pub use workspace_switcher::WorkspaceSwitcherModal;
14
15use std::sync::Arc;
16
17use kimun_core::NoteVault;
18use ratatui::Frame;
19use ratatui::layout::{Constraint, Direction, Layout, Rect};
20use ratatui::style::{Color, Modifier, Style};
21use ratatui::widgets::{Block, Borders, Paragraph, Widget};
22
23use crate::components::Component;
24use crate::components::event_state::EventState;
25use crate::components::events::{AppEvent, AppTx, InputEvent, OverlayData, SaveSource, SortTarget};
26use crate::components::file_list::{SortField, SortOrder};
27use crate::components::overlay::{Overlay, OverlayKind, OverlayMsg};
28use crate::settings::themes::Theme;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum ValidationState {
37 Idle,
39 Pending,
41 Available,
43 Taken,
45}
46
47pub mod create_note_dialog;
48pub mod delete_dialog;
49pub mod file_ops_menu;
50pub mod help_dialog;
51pub mod move_dialog;
52pub mod pinned_notes_dialog;
53pub mod quick_note_modal;
54pub mod rename_dialog;
55pub mod save_search_dialog;
56pub mod sort_dialog;
57pub mod theme_picker;
58pub mod update_dialog;
59pub mod workspace_switcher;
60
61pub enum ActiveDialog {
62 Menu(FileOpsMenuDialog),
63 Delete(DeleteConfirmDialog),
64 Rename(RenameDialog),
65 Move(MoveDialog),
66 CreateNote(CreateNoteDialog),
67 Help(HelpDialog),
68 QuickNote(QuickNoteModal),
69 WorkspaceSwitcher(WorkspaceSwitcherModal),
70 SaveSearch(SaveSearchDialog),
71 Sort(SortDialog),
72 PinnedNotes(PinnedNotesDialog),
73 ThemePicker(ThemePickerDialog),
74 UpdateAvailable(UpdateAvailableDialog),
75}
76
77impl ActiveDialog {
78 pub fn set_error(&mut self, msg: String) {
79 match self {
80 ActiveDialog::Menu(_) => {} ActiveDialog::Delete(d) => d.error = Some(msg),
82 ActiveDialog::Rename(d) => d.error = Some(msg),
83 ActiveDialog::Move(d) => d.error = Some(msg),
84 ActiveDialog::CreateNote(d) => d.error = Some(msg),
85 ActiveDialog::Help(_) => {}
86 ActiveDialog::QuickNote(d) => d.error = Some(msg),
87 ActiveDialog::WorkspaceSwitcher(_) => {} ActiveDialog::SaveSearch(_) => {} ActiveDialog::Sort(_) => {} ActiveDialog::PinnedNotes(_) => {} ActiveDialog::ThemePicker(_) => {} ActiveDialog::UpdateAvailable(_) => {} }
94 }
95
96 pub fn help(
98 key_bindings: &crate::keys::KeyBindings,
99 tree: &crate::keys::leader::LeaderNode,
100 ) -> Self {
101 ActiveDialog::Help(HelpDialog::new(key_bindings, tree))
102 }
103
104 pub fn cheatsheet(settings: &crate::settings::AppSettings) -> Self {
106 ActiveDialog::Help(HelpDialog::cheatsheet(settings))
107 }
108
109 pub fn query_syntax() -> Self {
111 ActiveDialog::Help(HelpDialog::query_syntax())
112 }
113
114 pub fn theme_picker(settings: &crate::settings::AppSettings) -> Self {
116 ActiveDialog::ThemePicker(ThemePickerDialog::new(settings))
117 }
118
119 pub fn update(status: &crate::update::UpdateStatus) -> Self {
121 ActiveDialog::UpdateAvailable(UpdateAvailableDialog::new(status))
122 }
123
124 pub fn quick_note(vault: Arc<NoteVault>) -> Self {
125 ActiveDialog::QuickNote(QuickNoteModal::new(vault))
126 }
127
128 pub fn workspace_switcher(settings: &crate::settings::AppSettings) -> Self {
129 ActiveDialog::WorkspaceSwitcher(WorkspaceSwitcherModal::new(settings))
130 }
131
132 pub fn create_note(
133 path: kimun_core::nfs::VaultPath,
134 vault: Arc<NoteVault>,
135 content: Option<String>,
136 ) -> Self {
137 ActiveDialog::CreateNote(CreateNoteDialog::new(path, vault, content))
138 }
139
140 pub fn save_search(
145 query: String,
146 provenance: Option<String>,
147 source: SaveSource,
148 vault: Arc<NoteVault>,
149 tx: &AppTx,
150 ) -> Self {
151 let tx = tx.clone();
152 tokio::spawn(async move {
153 if let Ok(searches) = vault.list_saved_searches().await {
154 let names = searches.into_iter().map(|s| s.name).collect();
155 tx.send(AppEvent::OverlayData(OverlayData::SavedSearchNamesLoaded(
156 names,
157 )))
158 .ok();
159 }
160 });
161 ActiveDialog::SaveSearch(SaveSearchDialog::new(query, provenance, source))
162 }
163
164 pub fn sort(
165 target: SortTarget,
166 field: SortField,
167 order: SortOrder,
168 group_directories: bool,
169 ) -> Self {
170 ActiveDialog::Sort(SortDialog::new(target, field, order, group_directories))
171 }
172
173 pub fn pinned_notes(vault: Arc<NoteVault>, tx: &AppTx) -> Self {
176 ActiveDialog::PinnedNotes(PinnedNotesDialog::new(vault, tx))
177 }
178
179 pub fn file_ops_menu(path: kimun_core::nfs::VaultPath) -> Self {
180 ActiveDialog::Menu(FileOpsMenuDialog::new(path))
181 }
182
183 pub fn delete(path: kimun_core::nfs::VaultPath, vault: Arc<NoteVault>) -> Self {
184 ActiveDialog::Delete(DeleteConfirmDialog::new(path, vault))
185 }
186
187 pub fn rename(path: kimun_core::nfs::VaultPath, vault: Arc<NoteVault>) -> Self {
188 ActiveDialog::Rename(RenameDialog::new(path, vault))
189 }
190
191 pub fn move_to(path: kimun_core::nfs::VaultPath, vault: Arc<NoteVault>, tx: &AppTx) -> Self {
192 ActiveDialog::Move(MoveDialog::new(path, vault, tx))
193 }
194}
195
196impl Overlay for ActiveDialog {
197 fn kind(&self) -> OverlayKind {
198 OverlayKind::Dialog
199 }
200
201 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
202 <Self as Component>::handle_input(self, event, tx)
203 }
204
205 fn handle_data(
206 &mut self,
207 data: &OverlayData,
208 _vault: &Arc<NoteVault>,
209 tx: &AppTx,
210 ) -> OverlayMsg {
211 match data {
212 OverlayData::RenameValidation { available } => {
213 if let ActiveDialog::Rename(d) = self {
214 d.validation_state = if *available {
215 ValidationState::Available
216 } else {
217 ValidationState::Taken
218 };
219 d.validation_task = None;
220 }
221 OverlayMsg::Consumed
222 }
223 OverlayData::MoveDirectoriesLoaded(paths) => {
224 if let ActiveDialog::Move(d) = self {
225 d.all_dirs = paths.clone();
226 d.filtered = None;
227 d.load_task = None;
228 if d.list_state.selected().is_none() && !d.results().is_empty() {
229 d.list_state.select(Some(0));
230 }
231 d.spawn_validation(tx);
232 }
233 OverlayMsg::Consumed
234 }
235 OverlayData::MoveFilterResults(paths) => {
236 if let ActiveDialog::Move(d) = self {
237 d.filter_task = None;
238 d.filtered = Some(paths.clone());
239 if !d.results().is_empty() {
240 d.list_state.select(Some(0));
241 } else {
242 d.list_state.select(None);
243 }
244 d.spawn_validation(tx);
245 }
246 OverlayMsg::Consumed
247 }
248 OverlayData::MoveDestValidation { available } => {
249 if let ActiveDialog::Move(d) = self {
250 d.dest_validation = if *available {
251 ValidationState::Available
252 } else {
253 ValidationState::Taken
254 };
255 d.validation_task = None;
256 }
257 OverlayMsg::Consumed
258 }
259 OverlayData::SavedSearchNamesLoaded(names) => {
260 if let ActiveDialog::SaveSearch(d) = self {
261 d.set_existing_names(names.clone());
262 }
263 OverlayMsg::Consumed
264 }
265 OverlayData::PinnedNotesLoaded(result) => {
266 if let ActiveDialog::PinnedNotes(d) = self {
267 d.handle_loaded(result, tx);
268 }
269 OverlayMsg::Consumed
270 }
271 OverlayData::Error(text) => {
272 self.set_error(text.clone());
273 OverlayMsg::Consumed
274 }
275 }
276 }
277
278 fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme) {
279 <Self as Component>::render(self, f, area, theme, true);
280 }
281}
282
283impl Component for ActiveDialog {
284 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
285 let InputEvent::Key(key) = event else {
286 return EventState::NotConsumed;
287 };
288 match self {
289 ActiveDialog::Menu(d) => d.handle_key(*key, tx),
290 ActiveDialog::Delete(d) => d.handle_key(*key, tx),
291 ActiveDialog::Rename(d) => d.handle_key(*key, tx),
292 ActiveDialog::Move(d) => d.handle_key(*key, tx),
293 ActiveDialog::CreateNote(d) => d.handle_key(*key, tx),
294 ActiveDialog::Help(d) => d.handle_key(*key, tx),
295 ActiveDialog::QuickNote(d) => d.handle_key(*key, tx),
296 ActiveDialog::WorkspaceSwitcher(d) => d.handle_key(*key, tx),
297 ActiveDialog::SaveSearch(d) => d.handle_input(event, tx),
298 ActiveDialog::Sort(d) => d.handle_input(event, tx),
299 ActiveDialog::PinnedNotes(d) => d.handle_input(event, tx),
300 ActiveDialog::ThemePicker(d) => d.handle_key(*key, tx),
301 ActiveDialog::UpdateAvailable(d) => d.handle_key(*key, tx),
302 }
303 }
304
305 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
306 match self {
307 ActiveDialog::Menu(d) => d.render(f, rect, theme, focused),
308 ActiveDialog::Delete(d) => d.render(f, rect, theme, focused),
309 ActiveDialog::Rename(d) => d.render(f, rect, theme, focused),
310 ActiveDialog::Move(d) => d.render(f, rect, theme, focused),
311 ActiveDialog::CreateNote(d) => d.render(f, rect, theme, focused),
312 ActiveDialog::Help(d) => d.render(f, rect, theme, focused),
313 ActiveDialog::QuickNote(d) => d.render(f, rect, theme, focused),
314 ActiveDialog::WorkspaceSwitcher(d) => d.render(f, rect, theme, focused),
315 ActiveDialog::SaveSearch(d) => d.render(f, rect, theme, focused),
316 ActiveDialog::Sort(d) => d.render(f, rect, theme, focused),
317 ActiveDialog::PinnedNotes(d) => d.render(f, rect, theme, focused),
318 ActiveDialog::ThemePicker(d) => d.render(f, rect, theme, focused),
319 ActiveDialog::UpdateAvailable(d) => d.render(f, rect, theme, focused),
320 }
321 }
322}
323
324pub(super) fn render_path_row(f: &mut Frame, rect: Rect, path: &str, fg: Color, bg: Color) {
330 f.render_widget(
331 Paragraph::new(path).style(Style::default().fg(fg).bg(bg)),
332 rect,
333 );
334}
335
336pub(super) fn render_separator(f: &mut Frame, rect: Rect, gray: Color, bg: Color) {
338 Block::default()
339 .borders(Borders::TOP)
340 .border_style(Style::default().fg(gray))
341 .style(Style::default().bg(bg))
342 .render(rect, f.buffer_mut());
343}
344
345pub(super) fn render_error_row(f: &mut Frame, rect: Rect, msg: &str, theme: &Theme) {
347 f.render_widget(
348 Paragraph::new(format!(" Error: {msg}")).style(
349 Style::default()
350 .fg(theme.red.to_ratatui())
351 .bg(theme.bg_panel.to_ratatui()),
352 ),
353 rect,
354 );
355}
356
357pub(super) fn render_confirm_hint(
360 f: &mut Frame,
361 rect: Rect,
362 enter_text: &str,
363 enter_active: bool,
364 fg: Color,
365 gray: Color,
366 bg: Color,
367) {
368 let enter_style = if enter_active {
369 Style::default().fg(fg).bg(bg)
370 } else {
371 Style::default().fg(gray).bg(bg).add_modifier(Modifier::DIM)
372 };
373 let chunks = Layout::default()
374 .direction(Direction::Horizontal)
375 .constraints([
376 Constraint::Length(enter_text.len() as u16 + 1),
377 Constraint::Min(1),
378 ])
379 .split(rect);
380 f.render_widget(Paragraph::new(enter_text).style(enter_style), chunks[0]);
381 f.render_widget(
382 Paragraph::new(" [Esc] Cancel").style(Style::default().fg(gray).bg(bg)),
383 chunks[1],
384 );
385}
386
387pub(super) use crate::components::fixed_centered_rect;
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397 use crate::keys::KeyBindings;
398
399 #[test]
400 fn active_dialog_help_variant_compiles() {
401 let dialog = HelpDialog::new(&KeyBindings::empty(), &crate::keys::leader::leader_tree());
402 let _active: ActiveDialog = ActiveDialog::Help(dialog);
403 }
404
405 #[test]
406 fn active_dialog_sort_variant_compiles() {
407 use crate::components::events::SortTarget;
408 use crate::components::file_list::{SortField, SortOrder};
409 let _active: ActiveDialog = ActiveDialog::sort(
410 SortTarget::Sidebar,
411 SortField::Name,
412 SortOrder::Ascending,
413 false,
414 );
415 }
416
417 #[tokio::test]
423 async fn active_dialog_routes_pinned_notes_loaded_rows() {
424 use crate::components::events::PinnedRow;
425 use kimun_core::nfs::VaultPath;
426 use tokio::sync::mpsc::unbounded_channel;
427
428 let vault = crate::test_support::temp_vault("active-dialog-pinned").await;
429 let (tx, _rx) = unbounded_channel();
430 let mut active = ActiveDialog::pinned_notes(vault.clone(), &tx);
431
432 let rows = vec![PinnedRow {
433 path: VaultPath::new("a.md"),
434 missing: false,
435 }];
436 let msg = active.handle_data(
437 &OverlayData::PinnedNotesLoaded(Ok(rows.clone())),
438 &vault,
439 &tx,
440 );
441 assert!(matches!(msg, OverlayMsg::Consumed));
442
443 match &active {
444 ActiveDialog::PinnedNotes(d) => assert_eq!(d.rows(), rows.as_slice()),
445 _ => panic!("expected ActiveDialog::PinnedNotes"),
446 }
447 }
448
449 #[tokio::test]
454 async fn active_dialog_leaves_pinned_notes_alone_on_a_foreign_error() {
455 use tokio::sync::mpsc::unbounded_channel;
456
457 let vault = crate::test_support::temp_vault("active-dialog-pinned-err").await;
458 let (tx, mut rx) = unbounded_channel();
459 let mut active = ActiveDialog::pinned_notes(vault.clone(), &tx);
460
461 let msg = active.handle_data(
462 &OverlayData::Error("rename failed".to_string()),
463 &vault,
464 &tx,
465 );
466 assert!(matches!(msg, OverlayMsg::Consumed));
467 assert!(
468 rx.try_recv().is_err(),
469 "a foreign error must not be flashed as a pinned-notes failure"
470 );
471 match &active {
472 ActiveDialog::PinnedNotes(d) => assert!(
473 !d.is_loaded(),
474 "a foreign error must not settle the dialog's own load"
475 ),
476 _ => panic!("expected ActiveDialog::PinnedNotes"),
477 }
478 }
479}