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 crate::components::clickable::{is_press_outside, list_index_at};
22use crate::components::event_state::EventState;
23use crate::components::events::{AppEvent, AppTx, InputEvent, OverlayData, PinnedRow};
24use crate::components::hint_row::HintRow;
25use crate::components::panel::{ModalSpec, modal_chrome};
26use crate::settings::themes::Theme;
27
28const OUTER_WIDTH: u16 = 60;
29const BODY_ROWS: u16 = kimun_core::PINNED_NOTES_CAP as u16;
31
32pub struct PinnedNotesDialog {
33 vault: Arc<NoteVault>,
34 rows: Vec<PinnedRow>,
35 pub(crate) selected: usize,
37 loaded: bool,
40 persist_pending: bool,
51 popup_rect: Rect,
53 body_rect: Rect,
55 hints: HintRow,
56}
57
58impl PinnedNotesDialog {
59 pub fn new(vault: Arc<NoteVault>, tx: &AppTx) -> Self {
61 Self::spawn_load(vault.clone(), tx);
62 Self {
63 vault,
64 rows: Vec::new(),
65 selected: 0,
66 loaded: false,
67 persist_pending: false,
68 popup_rect: Rect::default(),
69 body_rect: Rect::default(),
70 hints: HintRow::new(&[
71 (KeyCode::Enter, "⏎", "Open"),
74 (KeyCode::Char('K'), "K", "Raise"),
75 (KeyCode::Char('J'), "J", "Lower"),
76 (KeyCode::Char('d'), "d", "Unpin"),
77 (KeyCode::Esc, "Esc", "Close"),
78 ]),
79 }
80 }
81
82 #[cfg(test)]
83 pub(crate) fn rows(&self) -> &[PinnedRow] {
84 &self.rows
85 }
86
87 #[cfg(test)]
88 pub(crate) fn is_loaded(&self) -> bool {
89 self.loaded
90 }
91
92 pub(crate) fn spawn_load(vault: Arc<NoteVault>, tx: &AppTx) {
97 let tx = tx.clone();
98 tokio::spawn(async move {
99 let paths = match vault.list_pinned_notes().await {
100 Ok(paths) => paths,
101 Err(e) => {
102 tracing::warn!("failed to load pinned notes: {e}");
103 tx.send(AppEvent::OverlayData(OverlayData::PinnedNotesLoaded(Err(
104 format!("could not read pinned notes: {e}"),
105 ))))
106 .ok();
107 return;
108 }
109 };
110 let mut rows = Vec::with_capacity(paths.len());
111 for path in paths {
112 let missing = !vault.exists(&path).await;
113 rows.push(PinnedRow { path, missing });
114 }
115 tx.send(AppEvent::OverlayData(OverlayData::PinnedNotesLoaded(Ok(
116 rows,
117 ))))
118 .ok();
119 });
120 }
121
122 pub fn handle_loaded(&mut self, result: &Result<Vec<PinnedRow>, String>, tx: &AppTx) {
128 match result {
129 Ok(rows) => self.set_rows(rows.clone()),
130 Err(msg) => {
131 self.loaded = true;
132 self.persist_pending = false;
133 tx.send(AppEvent::FlashMessage(msg.clone())).ok();
134 }
135 }
136 }
137
138 pub fn set_rows(&mut self, rows: Vec<PinnedRow>) {
142 self.rows = rows;
143 self.loaded = true;
144 self.persist_pending = false;
145 if self.rows.is_empty() {
146 self.selected = 0;
147 } else {
148 self.selected = self.selected.min(self.rows.len() - 1);
149 }
150 }
151
152 fn open_row(&self, index: usize, tx: &AppTx) {
158 let Some(row) = self.rows.get(index) else {
159 tx.send(AppEvent::FlashMessage(format!(
160 "no pinned note {}",
161 index + 1
162 )))
163 .ok();
164 return;
165 };
166 if row.missing {
167 tx.send(AppEvent::FlashMessage(format!(
168 "pinned note not found: {}",
169 row.path
170 )))
171 .ok();
172 return;
173 }
174 tx.send(AppEvent::OpenPath {
175 path: row.path.clone(),
176 emphasis: None,
177 })
178 .ok();
179 }
180
181 fn persist_and_reload(
190 &mut self,
191 tx: &AppTx,
192 op: impl std::future::Future<Output = Result<(), String>> + Send + 'static,
193 ) {
194 self.persist_pending = true;
195 let vault = self.vault.clone();
196 let tx = tx.clone();
197 tokio::spawn(async move {
198 if let Err(msg) = op.await {
199 tx.send(AppEvent::FlashMessage(msg)).ok();
200 }
201 Self::spawn_load(vault, &tx);
202 });
203 }
204
205 fn move_selected(&mut self, delta: isize, tx: &AppTx) {
213 if self.persist_pending || self.rows.is_empty() {
214 return;
215 }
216 let from = self.selected;
217 let to = from as isize + delta;
218 if to < 0 || to as usize >= self.rows.len() {
219 return;
220 }
221 let to = to as usize;
222 self.rows.swap(from, to);
223 self.selected = to;
224 let path = self.rows[to].path.clone();
225 let vault = self.vault.clone();
226 let flash_tx = tx.clone();
227 self.persist_and_reload(tx, async move {
228 match vault.move_pinned_note(&path, delta).await {
229 Ok(true) => Ok(()),
230 Ok(false) => {
231 flash_tx
232 .send(AppEvent::FlashMessage(format!(
233 "pinned notes changed — could not move {path}"
234 )))
235 .ok();
236 Ok(())
237 }
238 Err(e) => Err(format!("could not reorder pinned notes: {e}")),
239 }
240 });
241 }
242
243 fn unpin_selected(&mut self, tx: &AppTx) {
250 if self.persist_pending {
251 return;
252 }
253 let Some(row) = self.rows.get(self.selected) else {
254 return;
255 };
256 let path = row.path.clone();
257 let vault = self.vault.clone();
258 let flash_tx = tx.clone();
259 self.persist_and_reload(tx, async move {
260 match vault.unpin_note(&path).await {
261 Ok(true) => Ok(()),
262 Ok(false) => {
263 flash_tx
264 .send(AppEvent::FlashMessage(format!("not pinned: {path}")))
265 .ok();
266 Ok(())
267 }
268 Err(e) => Err(format!("could not unpin {path}: {e}")),
269 }
270 });
271 }
272
273 pub fn handle_key(&mut self, key: KeyEvent, tx: &AppTx) -> EventState {
274 if key
279 .modifiers
280 .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
281 {
282 return EventState::Consumed;
283 }
284 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
285 match key.code {
286 KeyCode::Char(c @ '1'..='9') if !shift => {
287 self.open_row((c as u8 - b'1') as usize, tx);
288 }
289 KeyCode::Enter => self.open_row(self.selected, tx),
290 KeyCode::Char('j') | KeyCode::Down => {
291 if !self.rows.is_empty() {
292 self.selected = (self.selected + 1).min(self.rows.len() - 1);
293 }
294 }
295 KeyCode::Char('k') | KeyCode::Up => {
296 self.selected = self.selected.saturating_sub(1);
297 }
298 KeyCode::Char('J') => self.move_selected(1, tx),
299 KeyCode::Char('K') => self.move_selected(-1, tx),
300 KeyCode::Char('d') | KeyCode::Delete => self.unpin_selected(tx),
301 KeyCode::Esc => {
302 tx.send(AppEvent::CloseOverlay).ok();
303 }
304 _ => {}
305 }
306 EventState::Consumed
307 }
308}
309
310impl PinnedNotesDialog {
311 pub fn handle_mouse(&mut self, m: &MouseEvent, tx: &AppTx) -> EventState {
315 if is_press_outside(m, self.popup_rect) {
316 return self.handle_key(KeyEvent::from(KeyCode::Esc), tx);
317 }
318 if let Some(key) = self.hints.hit(m) {
319 return self.handle_key(key, tx);
320 }
321 match m.kind {
322 MouseEventKind::ScrollUp => self.handle_key(KeyEvent::from(KeyCode::Up), tx),
323 MouseEventKind::ScrollDown => self.handle_key(KeyEvent::from(KeyCode::Down), tx),
324 _ => {
325 if let Some(idx) = list_index_at(m, self.body_rect, 0, self.rows.len()) {
326 if idx == self.selected {
327 return self.handle_key(KeyEvent::from(KeyCode::Enter), tx);
328 }
329 self.selected = idx;
330 }
331 EventState::Consumed
332 }
333 }
334 }
335}
336
337impl crate::components::Component for PinnedNotesDialog {
338 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
339 match event {
340 InputEvent::Key(key) => self.handle_key(*key, tx),
341 InputEvent::Mouse(m) => self.handle_mouse(m, tx),
342 InputEvent::Paste(_) => EventState::NotConsumed,
343 }
344 }
345
346 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
347 let outer_height = BODY_ROWS + 3;
349 let popup = super::fixed_centered_rect(OUTER_WIDTH, outer_height, rect);
350 self.popup_rect = popup;
351 let inner = modal_chrome(
352 f,
353 popup,
354 theme,
355 ModalSpec {
356 title: Some(" Pinned notes "),
357 border: Some(Style::default().fg(theme.fg.to_ratatui())),
358 ..Default::default()
359 },
360 );
361 if inner.height < 2 {
362 return;
363 }
364 let chunks = Layout::default()
365 .direction(Direction::Vertical)
366 .constraints([Constraint::Min(1), Constraint::Length(1)])
367 .split(inner);
368 let body = chunks[0];
369 let footer_area = chunks[1];
370 self.body_rect = body;
371
372 let bg = theme.bg_panel.to_ratatui();
373 let fg = theme.fg.to_ratatui();
374 let gray = theme.gray.to_ratatui();
375 let fg_sel = theme.selection_fg.to_ratatui();
376 let bg_sel = theme.selection_bg.to_ratatui();
377
378 if self.loaded && self.rows.is_empty() {
379 f.render_widget(
380 Paragraph::new(" No pinned notes yet — leader m i pins the open note")
381 .style(Style::default().fg(gray).bg(bg)),
382 Rect {
383 x: body.x,
384 y: body.y,
385 width: body.width,
386 height: 1,
387 },
388 );
389 }
390
391 for (i, row) in self.rows.iter().enumerate() {
392 let y = body.y + i as u16;
393 if y >= body.y + body.height {
394 break;
395 }
396 let selected = i == self.selected;
397 let style = if selected {
398 Style::default()
399 .fg(fg_sel)
400 .bg(bg_sel)
401 .add_modifier(Modifier::BOLD)
402 } else if row.missing {
403 Style::default().fg(gray).bg(bg)
404 } else {
405 Style::default().fg(fg).bg(bg)
406 };
407 let marker = if selected { ">" } else { " " };
408 let name = row.path.get_clean_name();
409 let suffix = if row.missing { " (missing)" } else { "" };
410 let text = format!(" {marker} {} {name}{suffix} {}", i + 1, row.path);
411 f.render_widget(
412 Paragraph::new(text).style(style),
413 Rect {
414 x: body.x,
415 y,
416 width: body.width,
417 height: 1,
418 },
419 );
420 }
421
422 self.hints
423 .render(f, footer_area, Style::default().fg(gray).bg(bg), theme);
424 }
425}
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}