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 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;
30/// Body rows: the cap, so the popup never resizes as pins come and go.
31const BODY_ROWS: u16 = kimun_core::PINNED_NOTES_CAP as u16;
32
33pub struct PinnedNotesDialog {
34    vault: Arc<NoteVault>,
35    rows: Vec<PinnedRow>,
36    /// Cursor row; always `< rows.len()` unless the list is empty.
37    pub(crate) selected: usize,
38    /// `false` until the first load lands, so an empty vault is not drawn
39    /// as "no pinned notes" for the frame before the read completes.
40    loaded: bool,
41    /// `true` while a reorder or unpin write started by this dialog, and
42    /// the reload that follows it, are in flight. Core serializes the
43    /// writes themselves (one lock per pin file) and anchors a move on the
44    /// note's path, so overlapping writes cannot corrupt the order any
45    /// more — but each write is followed by its own reload, and two reloads
46    /// racing can land out of order, leaving the rows one edit behind the
47    /// disk. One write-and-reload at a time (ordinary key repeat is enough
48    /// to start a second) keeps the rows in step. Cleared when this
49    /// dialog's own reload lands (success or failure — see
50    /// `handle_loaded`); no other event may clear it.
51    persist_pending: bool,
52    /// Dismiss-on-outside-press and hint-chip clicks (see [`ModalShell`]).
53    shell: ModalShell,
54    /// Where the rows were drawn in the last render.
55    body_rect: Rect,
56    hints: HintRow,
57}
58
59impl PinnedNotesDialog {
60    /// Build the dialog and kick off the first load.
61    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                // `K`/`J` reorder the pins on disk — "Raise"/"Lower", never
73                // "Up"/"Down", which read as moving the highlight.
74                (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    /// Read the list and check each note's existence, then deliver it as
94    /// [`OverlayData::PinnedNotesLoaded`] — `Err` when the read fails. Used
95    /// for the first load and after every edit; the overlay host drops the
96    /// event if the dialog has closed meanwhile.
97    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    /// A load or reload landed: `Ok` replaces the rows, `Err` flashes the
124    /// message. Either way the dialog's own bookkeeping settles — `loaded`
125    /// so a failed *first* load renders the empty-state placeholder instead
126    /// of a blank body, and `persist_pending` so a failed *reload* after a
127    /// write doesn't leave `J`/`K`/`d` refusing forever.
128    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    /// Replace the rows (a load landed). The cursor is clamped so it never
140    /// points past the end after an unpin. Also clears `persist_pending`:
141    /// a reload is exactly what a pending write was waiting for.
142    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    /// Open the row at `index` (0-based): OpenPath, or a flash when there is
154    /// no such pin or its note is missing. Does not send `CloseOverlay`
155    /// itself — the editor's `OpenPath` handler (`try_open_path`) already
156    /// dismisses the active overlay unconditionally, so sending it here
157    /// too would just be a redundant second close.
158    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    /// Run a persisting write, then always reload — on success so the list
183    /// reflects the edit, on failure so the screen falls back to whatever
184    /// is actually on disk instead of leaving an optimistic edit standing
185    /// (the error itself reaches the user as a flash before the reload).
186    /// Refuses to start while a previous write from this dialog is still
187    /// in flight (see `persist_pending`); callers check this themselves so
188    /// they can skip their own optimistic local edit too, not just the
189    /// write.
190    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    /// Move the selected row by `delta` (−1 up, +1 down), persist, reload.
207    /// The cursor follows the row so a second press keeps moving it — but
208    /// only once the previous move's write has landed; see
209    /// `persist_pending`. The write is anchored on the row's path, not its
210    /// index, so if the list changed underneath (an external edit, another
211    /// process) the note the user selected still moves — or, when it is no
212    /// longer pinned, nothing does and the reload shows why.
213    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    /// Unpin the selected row, persist, reload. Refuses while a previous
245    /// write is still in flight; see `persist_pending`. `unpin_note` returns
246    /// `Ok(false)` when the path was no longer in the list — the list
247    /// changed underneath (an external rename, another process) between the
248    /// row being drawn and the keypress landing — which is flashed rather
249    /// than left silent, since otherwise the keypress would just vanish.
250    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        // Only unbound keys reach a modal dialog, and the combo layer drops
276        // ALT — so an Alt+d chord meant for something else would arrive
277        // here as a bare `d` and unpin without confirmation. Chorded keys
278        // are not this dialog's; swallow them (it is modal) and do nothing.
279        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    /// Click a row to select it, click the selected row to open it; the
313    /// wheel moves the selection; footer chips run their key; a press
314    /// outside the popup closes it. Modal: every mouse event is consumed.
315    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        // body rows + borders(2) + footer(1)
346        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    /// 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}