Skip to main content

kimun_notes/components/dialogs/
pinned_notes_dialog.rs

1//! The pinned-notes dialog: the vault's pinned notes by number, and where
2//! they are managed. A digit opens that note at once (no query input — nine
3//! rows never need filtering); `j`/`k`/↑/↓ move, Enter opens the selected
4//! row, `J`/`K` move it down/up, `d`/Delete unpins it, Esc closes. With the
5//! mouse: click selects, clicking the selected row opens it, and the footer
6//! chips run their keys. Every
7//! change is written as it is made and the list reloads through
8//! [`OverlayData::PinnedNotesLoaded`]; there is no commit or cancel step.
9//! A pin whose note is missing on disk is kept, drawn dimmed with
10//! "(missing)", and refuses to open with a flash.
11
12use 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;
29/// Body rows: the cap, so the popup never resizes as pins come and go.
30const BODY_ROWS: u16 = kimun_core::PINNED_NOTES_CAP as u16;
31
32pub struct PinnedNotesDialog {
33    vault: Arc<NoteVault>,
34    rows: Vec<PinnedRow>,
35    /// Cursor row; always `< rows.len()` unless the list is empty.
36    pub(crate) selected: usize,
37    /// `false` until the first load lands, so an empty vault is not drawn
38    /// as "no pinned notes" for the frame before the read completes.
39    loaded: bool,
40    /// `true` while a reorder or unpin write started by this dialog, and
41    /// the reload that follows it, are in flight. Core serializes the
42    /// writes themselves (one lock per pin file) and anchors a move on the
43    /// note's path, so overlapping writes cannot corrupt the order any
44    /// more — but each write is followed by its own reload, and two reloads
45    /// racing can land out of order, leaving the rows one edit behind the
46    /// disk. One write-and-reload at a time (ordinary key repeat is enough
47    /// to start a second) keeps the rows in step. Cleared when this
48    /// dialog's own reload lands (success or failure — see
49    /// `handle_loaded`); no other event may clear it.
50    persist_pending: bool,
51    /// Outer popup rect from the last render; a press outside closes.
52    popup_rect: Rect,
53    /// Where the rows were drawn in the last render.
54    body_rect: Rect,
55    hints: HintRow,
56}
57
58impl PinnedNotesDialog {
59    /// Build the dialog and kick off the first load.
60    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                // `K`/`J` reorder the pins on disk — "Raise"/"Lower", never
72                // "Up"/"Down", which read as moving the highlight.
73                (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    /// Read the list and check each note's existence, then deliver it as
93    /// [`OverlayData::PinnedNotesLoaded`] — `Err` when the read fails. Used
94    /// for the first load and after every edit; the overlay host drops the
95    /// event if the dialog has closed meanwhile.
96    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    /// A load or reload landed: `Ok` replaces the rows, `Err` flashes the
123    /// message. Either way the dialog's own bookkeeping settles — `loaded`
124    /// so a failed *first* load renders the empty-state placeholder instead
125    /// of a blank body, and `persist_pending` so a failed *reload* after a
126    /// write doesn't leave `J`/`K`/`d` refusing forever.
127    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    /// Replace the rows (a load landed). The cursor is clamped so it never
139    /// points past the end after an unpin. Also clears `persist_pending`:
140    /// a reload is exactly what a pending write was waiting for.
141    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    /// Open the row at `index` (0-based): OpenPath, or a flash when there is
153    /// no such pin or its note is missing. Does not send `CloseOverlay`
154    /// itself — the editor's `OpenPath` handler (`try_open_path`) already
155    /// dismisses the active overlay unconditionally, so sending it here
156    /// too would just be a redundant second close.
157    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    /// Run a persisting write, then always reload — on success so the list
182    /// reflects the edit, on failure so the screen falls back to whatever
183    /// is actually on disk instead of leaving an optimistic edit standing
184    /// (the error itself reaches the user as a flash before the reload).
185    /// Refuses to start while a previous write from this dialog is still
186    /// in flight (see `persist_pending`); callers check this themselves so
187    /// they can skip their own optimistic local edit too, not just the
188    /// write.
189    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    /// Move the selected row by `delta` (−1 up, +1 down), persist, reload.
206    /// The cursor follows the row so a second press keeps moving it — but
207    /// only once the previous move's write has landed; see
208    /// `persist_pending`. The write is anchored on the row's path, not its
209    /// index, so if the list changed underneath (an external edit, another
210    /// process) the note the user selected still moves — or, when it is no
211    /// longer pinned, nothing does and the reload shows why.
212    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    /// Unpin the selected row, persist, reload. Refuses while a previous
244    /// write is still in flight; see `persist_pending`. `unpin_note` returns
245    /// `Ok(false)` when the path was no longer in the list — the list
246    /// changed underneath (an external rename, another process) between the
247    /// row being drawn and the keypress landing — which is flashed rather
248    /// than left silent, since otherwise the keypress would just vanish.
249    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        // Only unbound keys reach a modal dialog, and the combo layer drops
275        // ALT — so an Alt+d chord meant for something else would arrive
276        // here as a bare `d` and unpin without confirmation. Chorded keys
277        // are not this dialog's; swallow them (it is modal) and do nothing.
278        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    /// Click a row to select it, click the selected row to open it; the
312    /// wheel moves the selection; footer chips run their key; a press
313    /// outside the popup closes it. Modal: every mouse event is consumed.
314    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        // body rows + borders(2) + footer(1)
348        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    /// A dialog over a vault where `names` exist and are pinned, in order,
460    /// with its rows already reflecting them.
461    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    /// Wait for the reload that follows a write; panics on a load failure.
475    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        // The digit path and the Enter path both funnel through `open_row`,
558        // but nothing stops a future refactor from special-casing the digit
559        // arm — so the refusal is exercised on both paths, not just Enter's.
560        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        // The move + reload run on a spawned task; wait for the reload event.
606        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    /// Only unbound keys reach the dialog, and the combo layer drops ALT —
653    /// so a chord like Alt+d would otherwise land here as a bare `d` and
654    /// unpin (irreversibly, position lost) with no confirmation.
655    #[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    /// The rows the user is looking at can be stale: another process (or a
679    /// sync merge) unpinned the first note after the dialog loaded. `J` on
680    /// that row must not move some *other* note by index — it targets the
681    /// note the user selected, which is gone, so nothing moves and the
682    /// reload shows the real list.
683    #[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        // The list changes underneath: a.md is unpinned outside the dialog.
687        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    /// `unpin_note` returns `Ok(false)` when the row's path is no longer in
699    /// the stored list — e.g. the list changed underneath between the row
700    /// being drawn and the keypress landing. The dialog still reloads (there
701    /// is nothing to persist), but the keypress must not just vanish: it
702    /// flashes instead.
703    #[tokio::test(flavor = "multi_thread")]
704    async fn unpin_selected_flashes_when_the_row_is_already_gone() {
705        // Nothing is pinned in the vault, so the dialog's row for "a.md" is
706        // stale by construction.
707        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        // Without the `persist_pending` guard, the second `J` below would
734        // run its own optimistic swap immediately (both key presses happen
735        // synchronously, before the first press's spawned write has had a
736        // chance to run) and start a second write-and-reload whose reload
737        // can land before the first's, leaving the rows one edit behind
738        // the disk. With the guard, the second press is a no-op: the cursor
739        // stays where the first press left it, and only one write (and
740        // therefore one reload) happens.
741        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        // Deliver the reload, exactly as `dialogs/mod.rs` would: this is
764        // what clears the guard.
765        d.set_rows(loaded);
766        assert!(!d.persist_pending, "the reload cleared the guard");
767
768        // No second write means no second reload: nothing else should ever
769        // arrive on this channel.
770        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    /// A failed reload must settle the dialog (flash, clear the in-flight
779    /// guard) or `J`/`K`/`d` would refuse forever.
780    #[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}