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(key_bindings: &crate::keys::KeyBindings) -> Self {
98 ActiveDialog::Help(HelpDialog::new(key_bindings))
99 }
100
101 pub fn cheatsheet(settings: &crate::settings::AppSettings) -> Self {
103 ActiveDialog::Help(HelpDialog::cheatsheet(settings))
104 }
105
106 pub fn query_syntax() -> Self {
108 ActiveDialog::Help(HelpDialog::query_syntax())
109 }
110
111 pub fn theme_picker(settings: &crate::settings::AppSettings) -> Self {
113 ActiveDialog::ThemePicker(ThemePickerDialog::new(settings))
114 }
115
116 pub fn update(status: &crate::update::UpdateStatus) -> Self {
118 ActiveDialog::UpdateAvailable(UpdateAvailableDialog::new(status))
119 }
120
121 pub fn quick_note(vault: Arc<NoteVault>) -> Self {
122 ActiveDialog::QuickNote(QuickNoteModal::new(vault))
123 }
124
125 pub fn workspace_switcher(settings: &crate::settings::AppSettings) -> Self {
126 ActiveDialog::WorkspaceSwitcher(WorkspaceSwitcherModal::new(settings))
127 }
128
129 pub fn create_note(
130 path: kimun_core::nfs::VaultPath,
131 vault: Arc<NoteVault>,
132 content: Option<String>,
133 ) -> Self {
134 ActiveDialog::CreateNote(CreateNoteDialog::new(path, vault, content))
135 }
136
137 pub fn save_search(
142 query: String,
143 provenance: Option<String>,
144 source: SaveSource,
145 vault: Arc<NoteVault>,
146 tx: &AppTx,
147 ) -> Self {
148 let tx = tx.clone();
149 tokio::spawn(async move {
150 if let Ok(searches) = vault.list_saved_searches().await {
151 let names = searches.into_iter().map(|s| s.name).collect();
152 tx.send(AppEvent::OverlayData(OverlayData::SavedSearchNamesLoaded(
153 names,
154 )))
155 .ok();
156 }
157 });
158 ActiveDialog::SaveSearch(SaveSearchDialog::new(query, provenance, source))
159 }
160
161 pub fn sort(
162 target: SortTarget,
163 field: SortField,
164 order: SortOrder,
165 group_directories: bool,
166 ) -> Self {
167 ActiveDialog::Sort(SortDialog::new(target, field, order, group_directories))
168 }
169
170 pub fn pinned_notes(vault: Arc<NoteVault>, tx: &AppTx) -> Self {
173 ActiveDialog::PinnedNotes(PinnedNotesDialog::new(vault, tx))
174 }
175
176 pub fn file_ops_menu(path: kimun_core::nfs::VaultPath) -> Self {
177 ActiveDialog::Menu(FileOpsMenuDialog::new(path))
178 }
179
180 pub fn delete(path: kimun_core::nfs::VaultPath, vault: Arc<NoteVault>) -> Self {
181 ActiveDialog::Delete(DeleteConfirmDialog::new(path, vault))
182 }
183
184 pub fn rename(path: kimun_core::nfs::VaultPath, vault: Arc<NoteVault>) -> Self {
185 ActiveDialog::Rename(RenameDialog::new(path, vault))
186 }
187
188 pub fn move_to(path: kimun_core::nfs::VaultPath, vault: Arc<NoteVault>, tx: &AppTx) -> Self {
189 ActiveDialog::Move(MoveDialog::new(path, vault, tx))
190 }
191}
192
193impl Overlay for ActiveDialog {
194 fn kind(&self) -> OverlayKind {
195 OverlayKind::Dialog
196 }
197
198 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
199 <Self as Component>::handle_input(self, event, tx)
200 }
201
202 fn handle_data(
203 &mut self,
204 data: &OverlayData,
205 _vault: &Arc<NoteVault>,
206 tx: &AppTx,
207 ) -> OverlayMsg {
208 match data {
209 OverlayData::RenameValidation { available } => {
210 if let ActiveDialog::Rename(d) = self {
211 d.validation_state = if *available {
212 ValidationState::Available
213 } else {
214 ValidationState::Taken
215 };
216 d.validation_task = None;
217 }
218 OverlayMsg::Consumed
219 }
220 OverlayData::MoveDirectoriesLoaded(paths) => {
221 if let ActiveDialog::Move(d) = self {
222 d.all_dirs = paths.clone();
223 d.filtered = None;
224 d.load_task = None;
225 if d.list_state.selected().is_none() && !d.results().is_empty() {
226 d.list_state.select(Some(0));
227 }
228 d.spawn_validation(tx);
229 }
230 OverlayMsg::Consumed
231 }
232 OverlayData::MoveFilterResults(paths) => {
233 if let ActiveDialog::Move(d) = self {
234 d.filter_task = None;
235 d.filtered = Some(paths.clone());
236 if !d.results().is_empty() {
237 d.list_state.select(Some(0));
238 } else {
239 d.list_state.select(None);
240 }
241 d.spawn_validation(tx);
242 }
243 OverlayMsg::Consumed
244 }
245 OverlayData::MoveDestValidation { available } => {
246 if let ActiveDialog::Move(d) = self {
247 d.dest_validation = if *available {
248 ValidationState::Available
249 } else {
250 ValidationState::Taken
251 };
252 d.validation_task = None;
253 }
254 OverlayMsg::Consumed
255 }
256 OverlayData::SavedSearchNamesLoaded(names) => {
257 if let ActiveDialog::SaveSearch(d) = self {
258 d.set_existing_names(names.clone());
259 }
260 OverlayMsg::Consumed
261 }
262 OverlayData::PinnedNotesLoaded(result) => {
263 if let ActiveDialog::PinnedNotes(d) = self {
264 d.handle_loaded(result, tx);
265 }
266 OverlayMsg::Consumed
267 }
268 OverlayData::Error(text) => {
269 self.set_error(text.clone());
270 OverlayMsg::Consumed
271 }
272 }
273 }
274
275 fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme) {
276 <Self as Component>::render(self, f, area, theme, true);
277 }
278}
279
280impl Component for ActiveDialog {
281 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
282 let InputEvent::Key(key) = event else {
283 return EventState::NotConsumed;
284 };
285 match self {
286 ActiveDialog::Menu(d) => d.handle_key(*key, tx),
287 ActiveDialog::Delete(d) => d.handle_key(*key, tx),
288 ActiveDialog::Rename(d) => d.handle_key(*key, tx),
289 ActiveDialog::Move(d) => d.handle_key(*key, tx),
290 ActiveDialog::CreateNote(d) => d.handle_key(*key, tx),
291 ActiveDialog::Help(d) => d.handle_key(*key, tx),
292 ActiveDialog::QuickNote(d) => d.handle_key(*key, tx),
293 ActiveDialog::WorkspaceSwitcher(d) => d.handle_key(*key, tx),
294 ActiveDialog::SaveSearch(d) => d.handle_input(event, tx),
295 ActiveDialog::Sort(d) => d.handle_input(event, tx),
296 ActiveDialog::PinnedNotes(d) => d.handle_input(event, tx),
297 ActiveDialog::ThemePicker(d) => d.handle_key(*key, tx),
298 ActiveDialog::UpdateAvailable(d) => d.handle_key(*key, tx),
299 }
300 }
301
302 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
303 match self {
304 ActiveDialog::Menu(d) => d.render(f, rect, theme, focused),
305 ActiveDialog::Delete(d) => d.render(f, rect, theme, focused),
306 ActiveDialog::Rename(d) => d.render(f, rect, theme, focused),
307 ActiveDialog::Move(d) => d.render(f, rect, theme, focused),
308 ActiveDialog::CreateNote(d) => d.render(f, rect, theme, focused),
309 ActiveDialog::Help(d) => d.render(f, rect, theme, focused),
310 ActiveDialog::QuickNote(d) => d.render(f, rect, theme, focused),
311 ActiveDialog::WorkspaceSwitcher(d) => d.render(f, rect, theme, focused),
312 ActiveDialog::SaveSearch(d) => d.render(f, rect, theme, focused),
313 ActiveDialog::Sort(d) => d.render(f, rect, theme, focused),
314 ActiveDialog::PinnedNotes(d) => d.render(f, rect, theme, focused),
315 ActiveDialog::ThemePicker(d) => d.render(f, rect, theme, focused),
316 ActiveDialog::UpdateAvailable(d) => d.render(f, rect, theme, focused),
317 }
318 }
319}
320
321pub(super) fn render_path_row(f: &mut Frame, rect: Rect, path: &str, fg: Color, bg: Color) {
327 f.render_widget(
328 Paragraph::new(path).style(Style::default().fg(fg).bg(bg)),
329 rect,
330 );
331}
332
333pub(super) fn render_separator(f: &mut Frame, rect: Rect, gray: Color, bg: Color) {
335 Block::default()
336 .borders(Borders::TOP)
337 .border_style(Style::default().fg(gray))
338 .style(Style::default().bg(bg))
339 .render(rect, f.buffer_mut());
340}
341
342pub(super) fn render_error_row(f: &mut Frame, rect: Rect, msg: &str, theme: &Theme) {
344 f.render_widget(
345 Paragraph::new(format!(" Error: {msg}")).style(
346 Style::default()
347 .fg(theme.red.to_ratatui())
348 .bg(theme.bg_panel.to_ratatui()),
349 ),
350 rect,
351 );
352}
353
354pub(super) fn render_confirm_hint(
357 f: &mut Frame,
358 rect: Rect,
359 enter_text: &str,
360 enter_active: bool,
361 fg: Color,
362 gray: Color,
363 bg: Color,
364) {
365 let enter_style = if enter_active {
366 Style::default().fg(fg).bg(bg)
367 } else {
368 Style::default().fg(gray).bg(bg).add_modifier(Modifier::DIM)
369 };
370 let chunks = Layout::default()
371 .direction(Direction::Horizontal)
372 .constraints([
373 Constraint::Length(enter_text.len() as u16 + 1),
374 Constraint::Min(1),
375 ])
376 .split(rect);
377 f.render_widget(Paragraph::new(enter_text).style(enter_style), chunks[0]);
378 f.render_widget(
379 Paragraph::new(" [Esc] Cancel").style(Style::default().fg(gray).bg(bg)),
380 chunks[1],
381 );
382}
383
384pub(super) use crate::components::fixed_centered_rect;
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394 use crate::keys::KeyBindings;
395
396 #[test]
397 fn active_dialog_help_variant_compiles() {
398 let dialog = HelpDialog::new(&KeyBindings::empty());
399 let _active: ActiveDialog = ActiveDialog::Help(dialog);
400 }
401
402 #[test]
403 fn active_dialog_sort_variant_compiles() {
404 use crate::components::events::SortTarget;
405 use crate::components::file_list::{SortField, SortOrder};
406 let _active: ActiveDialog = ActiveDialog::sort(
407 SortTarget::Sidebar,
408 SortField::Name,
409 SortOrder::Ascending,
410 false,
411 );
412 }
413
414 #[tokio::test]
420 async fn active_dialog_routes_pinned_notes_loaded_rows() {
421 use crate::components::events::PinnedRow;
422 use kimun_core::nfs::VaultPath;
423 use tokio::sync::mpsc::unbounded_channel;
424
425 let vault = crate::test_support::temp_vault("active-dialog-pinned").await;
426 let (tx, _rx) = unbounded_channel();
427 let mut active = ActiveDialog::pinned_notes(vault.clone(), &tx);
428
429 let rows = vec![PinnedRow {
430 path: VaultPath::new("a.md"),
431 missing: false,
432 }];
433 let msg = active.handle_data(
434 &OverlayData::PinnedNotesLoaded(Ok(rows.clone())),
435 &vault,
436 &tx,
437 );
438 assert!(matches!(msg, OverlayMsg::Consumed));
439
440 match &active {
441 ActiveDialog::PinnedNotes(d) => assert_eq!(d.rows(), rows.as_slice()),
442 _ => panic!("expected ActiveDialog::PinnedNotes"),
443 }
444 }
445
446 #[tokio::test]
451 async fn active_dialog_leaves_pinned_notes_alone_on_a_foreign_error() {
452 use tokio::sync::mpsc::unbounded_channel;
453
454 let vault = crate::test_support::temp_vault("active-dialog-pinned-err").await;
455 let (tx, mut rx) = unbounded_channel();
456 let mut active = ActiveDialog::pinned_notes(vault.clone(), &tx);
457
458 let msg = active.handle_data(
459 &OverlayData::Error("rename failed".to_string()),
460 &vault,
461 &tx,
462 );
463 assert!(matches!(msg, OverlayMsg::Consumed));
464 assert!(
465 rx.try_recv().is_err(),
466 "a foreign error must not be flashed as a pinned-notes failure"
467 );
468 match &active {
469 ActiveDialog::PinnedNotes(d) => assert!(
470 !d.is_loaded(),
471 "a foreign error must not settle the dialog's own load"
472 ),
473 _ => panic!("expected ActiveDialog::PinnedNotes"),
474 }
475 }
476}