1use std::sync::Arc;
13
14use kimun_core::NoteVault;
15use ratatui::Frame;
16use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
17use ratatui::layout::{Constraint, Direction, Layout, Rect};
18use ratatui::style::{Modifier, Style};
19use ratatui::widgets::Paragraph;
20
21use super::ModalShell;
22use crate::components::clickable::list_index_at;
23use crate::components::event_state::EventState;
24use crate::components::events::{AppEvent, AppTx, InputEvent, OverlayData, PinnedRow};
25use crate::components::hint_row::HintRow;
26use crate::components::panel::{ModalSpec, modal_chrome};
27use crate::settings::themes::Theme;
28
29const OUTER_WIDTH: u16 = 60;
30const BODY_ROWS: u16 = kimun_core::PINNED_NOTES_CAP as u16;
32
33pub struct PinnedNotesDialog {
34 vault: Arc<NoteVault>,
35 rows: Vec<PinnedRow>,
36 pub(crate) selected: usize,
38 loaded: bool,
41 persist_pending: bool,
52 shell: ModalShell,
54 body_rect: Rect,
56 hints: HintRow,
57}
58
59impl PinnedNotesDialog {
60 pub fn new(vault: Arc<NoteVault>, tx: &AppTx) -> Self {
62 Self::spawn_load(vault.clone(), tx);
63 Self {
64 vault,
65 rows: Vec::new(),
66 selected: 0,
67 loaded: false,
68 persist_pending: false,
69 shell: ModalShell::default(),
70 body_rect: Rect::default(),
71 hints: HintRow::new(&[
72 (KeyCode::Enter, "⏎", "Open"),
75 (KeyCode::Char('K'), "K", "Raise"),
76 (KeyCode::Char('J'), "J", "Lower"),
77 (KeyCode::Char('d'), "d", "Unpin"),
78 (KeyCode::Esc, "Esc", "Close"),
79 ]),
80 }
81 }
82
83 #[cfg(test)]
84 pub(crate) fn rows(&self) -> &[PinnedRow] {
85 &self.rows
86 }
87
88 #[cfg(test)]
89 pub(crate) fn is_loaded(&self) -> bool {
90 self.loaded
91 }
92
93 pub(crate) fn spawn_load(vault: Arc<NoteVault>, tx: &AppTx) {
98 let tx = tx.clone();
99 tokio::spawn(async move {
100 let paths = match vault.list_pinned_notes().await {
101 Ok(paths) => paths,
102 Err(e) => {
103 tracing::warn!("failed to load pinned notes: {e}");
104 tx.send(AppEvent::OverlayData(OverlayData::PinnedNotesLoaded(Err(
105 format!("could not read pinned notes: {e}"),
106 ))))
107 .ok();
108 return;
109 }
110 };
111 let mut rows = Vec::with_capacity(paths.len());
112 for path in paths {
113 let missing = !vault.exists(&path).await;
114 rows.push(PinnedRow { path, missing });
115 }
116 tx.send(AppEvent::OverlayData(OverlayData::PinnedNotesLoaded(Ok(
117 rows,
118 ))))
119 .ok();
120 });
121 }
122
123 pub fn handle_loaded(&mut self, result: &Result<Vec<PinnedRow>, String>, tx: &AppTx) {
129 match result {
130 Ok(rows) => self.set_rows(rows.clone()),
131 Err(msg) => {
132 self.loaded = true;
133 self.persist_pending = false;
134 tx.send(AppEvent::FlashMessage(msg.clone())).ok();
135 }
136 }
137 }
138
139 pub fn set_rows(&mut self, rows: Vec<PinnedRow>) {
143 self.rows = rows;
144 self.loaded = true;
145 self.persist_pending = false;
146 if self.rows.is_empty() {
147 self.selected = 0;
148 } else {
149 self.selected = self.selected.min(self.rows.len() - 1);
150 }
151 }
152
153 fn open_row(&self, index: usize, tx: &AppTx) {
159 let Some(row) = self.rows.get(index) else {
160 tx.send(AppEvent::FlashMessage(format!(
161 "no pinned note {}",
162 index + 1
163 )))
164 .ok();
165 return;
166 };
167 if row.missing {
168 tx.send(AppEvent::FlashMessage(format!(
169 "pinned note not found: {}",
170 row.path
171 )))
172 .ok();
173 return;
174 }
175 tx.send(AppEvent::OpenPath {
176 path: row.path.clone(),
177 emphasis: None,
178 })
179 .ok();
180 }
181
182 fn persist_and_reload(
191 &mut self,
192 tx: &AppTx,
193 op: impl std::future::Future<Output = Result<(), String>> + Send + 'static,
194 ) {
195 self.persist_pending = true;
196 let vault = self.vault.clone();
197 let tx = tx.clone();
198 tokio::spawn(async move {
199 if let Err(msg) = op.await {
200 tx.send(AppEvent::FlashMessage(msg)).ok();
201 }
202 Self::spawn_load(vault, &tx);
203 });
204 }
205
206 fn move_selected(&mut self, delta: isize, tx: &AppTx) {
214 if self.persist_pending || self.rows.is_empty() {
215 return;
216 }
217 let from = self.selected;
218 let to = from as isize + delta;
219 if to < 0 || to as usize >= self.rows.len() {
220 return;
221 }
222 let to = to as usize;
223 self.rows.swap(from, to);
224 self.selected = to;
225 let path = self.rows[to].path.clone();
226 let vault = self.vault.clone();
227 let flash_tx = tx.clone();
228 self.persist_and_reload(tx, async move {
229 match vault.move_pinned_note(&path, delta).await {
230 Ok(true) => Ok(()),
231 Ok(false) => {
232 flash_tx
233 .send(AppEvent::FlashMessage(format!(
234 "pinned notes changed — could not move {path}"
235 )))
236 .ok();
237 Ok(())
238 }
239 Err(e) => Err(format!("could not reorder pinned notes: {e}")),
240 }
241 });
242 }
243
244 fn unpin_selected(&mut self, tx: &AppTx) {
251 if self.persist_pending {
252 return;
253 }
254 let Some(row) = self.rows.get(self.selected) else {
255 return;
256 };
257 let path = row.path.clone();
258 let vault = self.vault.clone();
259 let flash_tx = tx.clone();
260 self.persist_and_reload(tx, async move {
261 match vault.unpin_note(&path).await {
262 Ok(true) => Ok(()),
263 Ok(false) => {
264 flash_tx
265 .send(AppEvent::FlashMessage(format!("not pinned: {path}")))
266 .ok();
267 Ok(())
268 }
269 Err(e) => Err(format!("could not unpin {path}: {e}")),
270 }
271 });
272 }
273
274 pub fn handle_key(&mut self, key: KeyEvent, tx: &AppTx) -> EventState {
275 if key
280 .modifiers
281 .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
282 {
283 return EventState::Consumed;
284 }
285 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
286 match key.code {
287 KeyCode::Char(c @ '1'..='9') if !shift => {
288 self.open_row((c as u8 - b'1') as usize, tx);
289 }
290 KeyCode::Enter => self.open_row(self.selected, tx),
291 KeyCode::Char('j') | KeyCode::Down => {
292 if !self.rows.is_empty() {
293 self.selected = (self.selected + 1).min(self.rows.len() - 1);
294 }
295 }
296 KeyCode::Char('k') | KeyCode::Up => {
297 self.selected = self.selected.saturating_sub(1);
298 }
299 KeyCode::Char('J') => self.move_selected(1, tx),
300 KeyCode::Char('K') => self.move_selected(-1, tx),
301 KeyCode::Char('d') | KeyCode::Delete => self.unpin_selected(tx),
302 KeyCode::Esc => {
303 tx.send(AppEvent::CloseOverlay).ok();
304 }
305 _ => {}
306 }
307 EventState::Consumed
308 }
309}
310
311impl PinnedNotesDialog {
312 pub fn handle_mouse(&mut self, m: &MouseEvent, tx: &AppTx) -> EventState {
316 if let Some(key) = self.shell.pointer_key(m, &[&self.hints]) {
317 return self.handle_key(key, tx);
318 }
319 match m.kind {
320 MouseEventKind::ScrollUp => self.handle_key(KeyEvent::from(KeyCode::Up), tx),
321 MouseEventKind::ScrollDown => self.handle_key(KeyEvent::from(KeyCode::Down), tx),
322 _ => {
323 if let Some(idx) = list_index_at(m, self.body_rect, 0, self.rows.len()) {
324 if idx == self.selected {
325 return self.handle_key(KeyEvent::from(KeyCode::Enter), tx);
326 }
327 self.selected = idx;
328 }
329 EventState::Consumed
330 }
331 }
332 }
333}
334
335impl crate::components::Component for PinnedNotesDialog {
336 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
337 match event {
338 InputEvent::Key(key) => self.handle_key(*key, tx),
339 InputEvent::Mouse(m) => self.handle_mouse(m, tx),
340 InputEvent::Paste(_) => EventState::NotConsumed,
341 }
342 }
343
344 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
345 let outer_height = BODY_ROWS + 3;
347 let popup = super::fixed_centered_rect(OUTER_WIDTH, outer_height, rect);
348 self.shell.set(popup);
349 let inner = modal_chrome(
350 f,
351 popup,
352 theme,
353 ModalSpec {
354 title: Some(" Pinned notes "),
355 border: Some(Style::default().fg(theme.fg.to_ratatui())),
356 ..Default::default()
357 },
358 );
359 if inner.height < 2 {
360 return;
361 }
362 let chunks = Layout::default()
363 .direction(Direction::Vertical)
364 .constraints([Constraint::Min(1), Constraint::Length(1)])
365 .split(inner);
366 let body = chunks[0];
367 let footer_area = chunks[1];
368 self.body_rect = body;
369
370 let bg = theme.bg_panel.to_ratatui();
371 let fg = theme.fg.to_ratatui();
372 let gray = theme.gray.to_ratatui();
373 let fg_sel = theme.selection_fg.to_ratatui();
374 let bg_sel = theme.selection_bg.to_ratatui();
375
376 if self.loaded && self.rows.is_empty() {
377 f.render_widget(
378 Paragraph::new(" No pinned notes yet — leader m i pins the open note")
379 .style(Style::default().fg(gray).bg(bg)),
380 Rect {
381 x: body.x,
382 y: body.y,
383 width: body.width,
384 height: 1,
385 },
386 );
387 }
388
389 for (i, row) in self.rows.iter().enumerate() {
390 let y = body.y + i as u16;
391 if y >= body.y + body.height {
392 break;
393 }
394 let selected = i == self.selected;
395 let style = if selected {
396 Style::default()
397 .fg(fg_sel)
398 .bg(bg_sel)
399 .add_modifier(Modifier::BOLD)
400 } else if row.missing {
401 Style::default().fg(gray).bg(bg)
402 } else {
403 Style::default().fg(fg).bg(bg)
404 };
405 let marker = if selected { ">" } else { " " };
406 let name = row.path.get_clean_name();
407 let suffix = if row.missing { " (missing)" } else { "" };
408 let text = format!(" {marker} {} {name}{suffix} {}", i + 1, row.path);
409 f.render_widget(
410 Paragraph::new(text).style(style),
411 Rect {
412 x: body.x,
413 y,
414 width: body.width,
415 height: 1,
416 },
417 );
418 }
419
420 self.hints
421 .render(f, footer_area, Style::default().fg(gray).bg(bg), theme);
422 }
423}
424
425impl_dialog!(PinnedNotesDialog);
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430 use crate::components::events::AppEvent;
431 use kimun_core::nfs::VaultPath;
432 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
433 use tokio::sync::mpsc::unbounded_channel;
434
435 fn key(code: KeyCode) -> KeyEvent {
436 KeyEvent::from(code)
437 }
438
439 fn shift(c: char) -> KeyEvent {
440 KeyEvent::new(KeyCode::Char(c), KeyModifiers::SHIFT)
441 }
442
443 fn row(path: &str, missing: bool) -> PinnedRow {
444 PinnedRow {
445 path: VaultPath::new(path),
446 missing,
447 }
448 }
449
450 async fn dialog_with(rows: Vec<PinnedRow>) -> (PinnedNotesDialog, Arc<NoteVault>) {
451 let vault = crate::test_support::temp_vault("pinned-dialog").await;
452 vault.validate_and_init().await.unwrap();
453 let (tx, _rx) = unbounded_channel();
454 let mut d = PinnedNotesDialog::new(vault.clone(), &tx);
455 d.set_rows(rows);
456 (d, vault)
457 }
458
459 async fn dialog_with_pins(names: &[&str]) -> (PinnedNotesDialog, Arc<NoteVault>) {
462 let (mut d, vault) = dialog_with(Vec::new()).await;
463 let mut rows = Vec::new();
464 for n in names {
465 let path = VaultPath::new(n);
466 vault.create_note(&path, "hi").await.unwrap();
467 vault.toggle_pinned_note(&path).await.unwrap();
468 rows.push(row(n, false));
469 }
470 d.set_rows(rows);
471 (d, vault)
472 }
473
474 async fn wait_for_reload(
476 rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
477 ) -> Vec<PinnedRow> {
478 tokio::time::timeout(std::time::Duration::from_secs(2), async {
479 loop {
480 match rx.recv().await {
481 Some(AppEvent::OverlayData(OverlayData::PinnedNotesLoaded(Ok(rows)))) => {
482 break rows;
483 }
484 Some(AppEvent::OverlayData(OverlayData::PinnedNotesLoaded(Err(e)))) => {
485 panic!("load failed: {e}")
486 }
487 Some(_) => {}
488 None => panic!("channel closed before the reload landed"),
489 }
490 }
491 })
492 .await
493 .expect("reload event")
494 }
495
496 fn paths(rows: &[PinnedRow]) -> Vec<VaultPath> {
497 rows.iter().map(|r| r.path.clone()).collect()
498 }
499
500 fn drain(rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>) -> Vec<AppEvent> {
501 let mut out = Vec::new();
502 while let Ok(e) = rx.try_recv() {
503 out.push(e);
504 }
505 out
506 }
507
508 #[tokio::test]
509 async fn digit_opens_that_note() {
510 let (mut d, _) = dialog_with(vec![row("a.md", false), row("b.md", false)]).await;
511 let (tx, mut rx) = unbounded_channel();
512 d.handle_key(key(KeyCode::Char('2')), &tx);
513 let events = drain(&mut rx);
514 assert!(
515 events.iter().any(|e| matches!(
516 e,
517 AppEvent::OpenPath { path, emphasis: None } if *path == VaultPath::new("b.md")
518 )),
519 "expected OpenPath, got {events:?}"
520 );
521 assert!(
522 !events.iter().any(|e| matches!(e, AppEvent::CloseOverlay)),
523 "open_row must not emit CloseOverlay; editor's OpenPath handler closes the overlay, got {events:?}"
524 );
525 }
526
527 #[tokio::test]
528 async fn digit_past_the_end_flashes() {
529 let (mut d, _) = dialog_with(vec![row("a.md", false)]).await;
530 let (tx, mut rx) = unbounded_channel();
531 d.handle_key(key(KeyCode::Char('5')), &tx);
532 let events = drain(&mut rx);
533 assert!(matches!(&events[0], AppEvent::FlashMessage(m) if m == "no pinned note 5"));
534 assert!(!events.iter().any(|e| matches!(e, AppEvent::CloseOverlay)));
535 }
536
537 #[tokio::test]
538 async fn missing_note_flashes_instead_of_opening() {
539 let (mut d, _) = dialog_with(vec![row("gone.md", true)]).await;
540 let (tx, mut rx) = unbounded_channel();
541 d.handle_key(key(KeyCode::Enter), &tx);
542 let events = drain(&mut rx);
543 assert!(matches!(
544 &events[0],
545 AppEvent::FlashMessage(m) if m == "pinned note not found: gone.md"
546 ));
547 assert!(
548 !events
549 .iter()
550 .any(|e| matches!(e, AppEvent::OpenPath { .. }))
551 );
552 assert!(!events.iter().any(|e| matches!(e, AppEvent::CloseOverlay)));
553 }
554
555 #[tokio::test]
556 async fn digit_for_a_missing_note_flashes_instead_of_opening() {
557 let (mut d, _) = dialog_with(vec![row("a.md", false), row("gone.md", true)]).await;
561 let (tx, mut rx) = unbounded_channel();
562 d.handle_key(key(KeyCode::Char('2')), &tx);
563 let events = drain(&mut rx);
564 assert!(matches!(
565 &events[0],
566 AppEvent::FlashMessage(m) if m == "pinned note not found: gone.md"
567 ));
568 assert!(
569 !events
570 .iter()
571 .any(|e| matches!(e, AppEvent::OpenPath { .. }))
572 );
573 assert!(!events.iter().any(|e| matches!(e, AppEvent::CloseOverlay)));
574 }
575
576 #[tokio::test]
577 async fn j_k_move_the_cursor_within_bounds() {
578 let (mut d, _) = dialog_with(vec![row("a.md", false), row("b.md", false)]).await;
579 let (tx, _rx) = unbounded_channel();
580 assert_eq!(d.selected, 0);
581 d.handle_key(key(KeyCode::Char('j')), &tx);
582 assert_eq!(d.selected, 1);
583 d.handle_key(key(KeyCode::Down), &tx);
584 assert_eq!(d.selected, 1, "clamped at the last row");
585 d.handle_key(key(KeyCode::Char('k')), &tx);
586 d.handle_key(key(KeyCode::Up), &tx);
587 assert_eq!(d.selected, 0, "clamped at the first row");
588 }
589
590 #[tokio::test]
591 async fn esc_closes_without_opening() {
592 let (mut d, _) = dialog_with(vec![row("a.md", false)]).await;
593 let (tx, mut rx) = unbounded_channel();
594 d.handle_key(key(KeyCode::Esc), &tx);
595 let events = drain(&mut rx);
596 assert_eq!(events.len(), 1);
597 assert!(matches!(events[0], AppEvent::CloseOverlay));
598 }
599
600 #[tokio::test(flavor = "multi_thread")]
601 async fn shift_j_moves_the_row_down_and_reloads() {
602 let (mut d, vault) = dialog_with_pins(&["a.md", "b.md"]).await;
603 let (tx, mut rx) = unbounded_channel();
604 d.handle_key(shift('J'), &tx);
605 let loaded = wait_for_reload(&mut rx).await;
607 assert_eq!(
608 paths(&loaded),
609 vec![VaultPath::new("/b.md"), VaultPath::new("/a.md")]
610 );
611 assert_eq!(
612 vault.list_pinned_notes().await.unwrap(),
613 vec![VaultPath::new("/b.md"), VaultPath::new("/a.md")]
614 );
615 d.set_rows(loaded);
616 assert_eq!(d.selected, 1, "the cursor follows the moved row");
617 }
618
619 #[tokio::test(flavor = "multi_thread")]
620 async fn shift_k_moves_the_row_up_and_reloads() {
621 let (mut d, vault) = dialog_with_pins(&["a.md", "b.md"]).await;
622 d.selected = 1;
623 let (tx, mut rx) = unbounded_channel();
624 d.handle_key(shift('K'), &tx);
625 let loaded = wait_for_reload(&mut rx).await;
626 assert_eq!(
627 paths(&loaded),
628 vec![VaultPath::new("/b.md"), VaultPath::new("/a.md")]
629 );
630 assert_eq!(
631 vault.list_pinned_notes().await.unwrap(),
632 vec![VaultPath::new("/b.md"), VaultPath::new("/a.md")]
633 );
634 d.set_rows(loaded);
635 assert_eq!(d.selected, 0, "the cursor follows the moved row");
636 }
637
638 #[tokio::test(flavor = "multi_thread")]
639 async fn d_unpins_the_selected_row_and_reloads() {
640 let (mut d, vault) = dialog_with_pins(&["a.md", "b.md"]).await;
641 let (tx, mut rx) = unbounded_channel();
642 d.handle_key(key(KeyCode::Char('d')), &tx);
643 let loaded = wait_for_reload(&mut rx).await;
644 assert_eq!(loaded.len(), 1);
645 assert_eq!(loaded[0].path, VaultPath::new("/b.md"));
646 assert_eq!(
647 vault.list_pinned_notes().await.unwrap(),
648 vec![VaultPath::new("/b.md")]
649 );
650 }
651
652 #[tokio::test]
656 async fn chorded_keys_never_unpin_or_reorder() {
657 let (mut d, vault) = dialog_with_pins(&["a.md", "b.md"]).await;
658 let (tx, mut rx) = unbounded_channel();
659 for m in [KeyModifiers::ALT, KeyModifiers::CONTROL] {
660 d.handle_key(KeyEvent::new(KeyCode::Char('d'), m), &tx);
661 d.handle_key(KeyEvent::new(KeyCode::Delete, m), &tx);
662 d.handle_key(
663 KeyEvent::new(KeyCode::Char('J'), m | KeyModifiers::SHIFT),
664 &tx,
665 );
666 }
667 assert!(
668 drain(&mut rx).is_empty(),
669 "a chorded key must start no write and no reload"
670 );
671 assert!(!d.persist_pending);
672 assert_eq!(
673 vault.list_pinned_notes().await.unwrap(),
674 vec![VaultPath::new("/a.md"), VaultPath::new("/b.md")]
675 );
676 }
677
678 #[tokio::test(flavor = "multi_thread")]
684 async fn reorder_of_a_stale_row_moves_nothing_else() {
685 let (mut d, vault) = dialog_with_pins(&["a.md", "b.md", "c.md"]).await;
686 vault.unpin_note(&VaultPath::new("a.md")).await.unwrap();
688 let (tx, mut rx) = unbounded_channel();
689 d.handle_key(shift('J'), &tx);
690 let loaded = wait_for_reload(&mut rx).await;
691 assert_eq!(
692 paths(&loaded),
693 vec![VaultPath::new("/b.md"), VaultPath::new("/c.md")],
694 "b and c must keep their order"
695 );
696 }
697
698 #[tokio::test(flavor = "multi_thread")]
704 async fn unpin_selected_flashes_when_the_row_is_already_gone() {
705 let (mut d, _vault) = dialog_with(vec![row("a.md", false)]).await;
708 let (tx, mut rx) = unbounded_channel();
709 d.handle_key(key(KeyCode::Char('d')), &tx);
710
711 let mut flashed = None;
712 tokio::time::timeout(std::time::Duration::from_secs(2), async {
713 loop {
714 match rx.recv().await {
715 Some(AppEvent::FlashMessage(m)) => flashed = Some(m),
716 Some(AppEvent::OverlayData(OverlayData::PinnedNotesLoaded(Ok(_)))) => break,
717 Some(AppEvent::OverlayData(OverlayData::PinnedNotesLoaded(Err(e)))) => {
718 panic!("unexpected load failure: {e}")
719 }
720 Some(_) => {}
721 None => panic!("channel closed before the reload landed"),
722 }
723 }
724 })
725 .await
726 .expect("reload event");
727
728 assert_eq!(flashed.as_deref(), Some("not pinned: a.md"));
729 }
730
731 #[tokio::test(flavor = "multi_thread")]
732 async fn a_second_reorder_press_is_ignored_while_a_write_is_in_flight() {
733 let (mut d, _vault) = dialog_with_pins(&["a.md", "b.md", "c.md"]).await;
742 let (tx, mut rx) = unbounded_channel();
743
744 d.handle_key(shift('J'), &tx);
745 assert!(d.persist_pending, "the first press started a write");
746
747 d.handle_key(shift('J'), &tx);
748 assert_eq!(
749 d.selected, 1,
750 "the second press must not move the cursor again while the first write is pending"
751 );
752
753 let loaded = wait_for_reload(&mut rx).await;
754 assert_eq!(
755 paths(&loaded),
756 vec![
757 VaultPath::new("/b.md"),
758 VaultPath::new("/a.md"),
759 VaultPath::new("/c.md")
760 ],
761 "only the first press's move should have been written"
762 );
763 d.set_rows(loaded);
766 assert!(!d.persist_pending, "the reload cleared the guard");
767
768 assert!(
771 tokio::time::timeout(std::time::Duration::from_millis(200), rx.recv())
772 .await
773 .is_err(),
774 "a second write must not have happened"
775 );
776 }
777
778 #[tokio::test]
781 async fn a_failed_load_flashes_and_clears_the_guard() {
782 let (mut d, _) = dialog_with_pins(&["a.md", "b.md"]).await;
783 let (tx, mut rx) = unbounded_channel();
784 d.handle_key(shift('J'), &tx);
785 assert!(d.persist_pending);
786 d.handle_loaded(&Err("boom".to_string()), &tx);
787 assert!(!d.persist_pending);
788 assert!(d.is_loaded());
789 assert!(
790 drain(&mut rx)
791 .iter()
792 .any(|e| matches!(e, AppEvent::FlashMessage(m) if m == "boom")),
793 );
794 }
795
796 #[tokio::test]
797 async fn set_rows_clamps_the_cursor() {
798 let (mut d, _) = dialog_with(vec![row("a.md", false), row("b.md", false)]).await;
799 d.selected = 1;
800 d.set_rows(vec![row("a.md", false)]);
801 assert_eq!(d.selected, 0);
802 d.set_rows(Vec::new());
803 assert_eq!(d.selected, 0);
804 }
805}