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