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. Every
5//! change is written as it is made and the list reloads through
6//! [`OverlayData::PinnedNotesLoaded`]; there is no commit or cancel step.
7//! A pin whose note is missing on disk is kept, drawn dimmed with
8//! "(missing)", and refuses to open with a flash.
9
10use 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;
25/// Body rows: the cap, so the popup never resizes as pins come and go.
26const BODY_ROWS: u16 = kimun_core::PINNED_NOTES_CAP as u16;
27
28pub struct PinnedNotesDialog {
29    vault: Arc<NoteVault>,
30    rows: Vec<PinnedRow>,
31    /// Cursor row; always `< rows.len()` unless the list is empty.
32    pub(crate) selected: usize,
33    /// `false` until the first load lands, so an empty vault is not drawn
34    /// as "no pinned notes" for the frame before the read completes.
35    loaded: bool,
36    /// `true` while a reorder or unpin write started by this dialog, and
37    /// the reload that follows it, are in flight. Core serializes the
38    /// writes themselves (one lock per pin file) and anchors a move on the
39    /// note's path, so overlapping writes cannot corrupt the order any
40    /// more — but each write is followed by its own reload, and two reloads
41    /// racing can land out of order, leaving the rows one edit behind the
42    /// disk. One write-and-reload at a time (ordinary key repeat is enough
43    /// to start a second) keeps the rows in step. Cleared when this
44    /// dialog's own reload lands (success or failure — see
45    /// `handle_loaded`); no other event may clear it.
46    persist_pending: bool,
47}
48
49impl PinnedNotesDialog {
50    /// Build the dialog and kick off the first load.
51    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    /// Read the list and check each note's existence, then deliver it as
73    /// [`OverlayData::PinnedNotesLoaded`] — `Err` when the read fails. Used
74    /// for the first load and after every edit; the overlay host drops the
75    /// event if the dialog has closed meanwhile.
76    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    /// A load or reload landed: `Ok` replaces the rows, `Err` flashes the
103    /// message. Either way the dialog's own bookkeeping settles — `loaded`
104    /// so a failed *first* load renders the empty-state placeholder instead
105    /// of a blank body, and `persist_pending` so a failed *reload* after a
106    /// write doesn't leave `J`/`K`/`d` refusing forever.
107    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    /// Replace the rows (a load landed). The cursor is clamped so it never
119    /// points past the end after an unpin. Also clears `persist_pending`:
120    /// a reload is exactly what a pending write was waiting for.
121    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    /// Open the row at `index` (0-based): OpenPath, or a flash when there is
133    /// no such pin or its note is missing. Does not send `CloseOverlay`
134    /// itself — the editor's `OpenPath` handler (`try_open_path`) already
135    /// dismisses the active overlay unconditionally, so sending it here
136    /// too would just be a redundant second close.
137    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    /// Run a persisting write, then always reload — on success so the list
162    /// reflects the edit, on failure so the screen falls back to whatever
163    /// is actually on disk instead of leaving an optimistic edit standing
164    /// (the error itself reaches the user as a flash before the reload).
165    /// Refuses to start while a previous write from this dialog is still
166    /// in flight (see `persist_pending`); callers check this themselves so
167    /// they can skip their own optimistic local edit too, not just the
168    /// write.
169    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    /// Move the selected row by `delta` (−1 up, +1 down), persist, reload.
186    /// The cursor follows the row so a second press keeps moving it — but
187    /// only once the previous move's write has landed; see
188    /// `persist_pending`. The write is anchored on the row's path, not its
189    /// index, so if the list changed underneath (an external edit, another
190    /// process) the note the user selected still moves — or, when it is no
191    /// longer pinned, nothing does and the reload shows why.
192    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    /// Unpin the selected row, persist, reload. Refuses while a previous
224    /// write is still in flight; see `persist_pending`. `unpin_note` returns
225    /// `Ok(false)` when the path was no longer in the list — the list
226    /// changed underneath (an external rename, another process) between the
227    /// row being drawn and the keypress landing — which is flashed rather
228    /// than left silent, since otherwise the keypress would just vanish.
229    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        // Only unbound keys reach a modal dialog, and the combo layer drops
255        // ALT — so an Alt+d chord meant for something else would arrive
256        // here as a bare `d` and unpin without confirmation. Chorded keys
257        // are not this dialog's; swallow them (it is modal) and do nothing.
258        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        // body rows + borders(2) + footer(1)
301        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    /// A dialog over a vault where `names` exist and are pinned, in order,
414    /// with its rows already reflecting them.
415    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    /// Wait for the reload that follows a write; panics on a load failure.
429    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        // The digit path and the Enter path both funnel through `open_row`,
512        // but nothing stops a future refactor from special-casing the digit
513        // arm — so the refusal is exercised on both paths, not just Enter's.
514        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        // The move + reload run on a spawned task; wait for the reload event.
560        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    /// Only unbound keys reach the dialog, and the combo layer drops ALT —
607    /// so a chord like Alt+d would otherwise land here as a bare `d` and
608    /// unpin (irreversibly, position lost) with no confirmation.
609    #[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    /// The rows the user is looking at can be stale: another process (or a
633    /// sync merge) unpinned the first note after the dialog loaded. `J` on
634    /// that row must not move some *other* note by index — it targets the
635    /// note the user selected, which is gone, so nothing moves and the
636    /// reload shows the real list.
637    #[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        // The list changes underneath: a.md is unpinned outside the dialog.
641        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    /// `unpin_note` returns `Ok(false)` when the row's path is no longer in
653    /// the stored list — e.g. the list changed underneath between the row
654    /// being drawn and the keypress landing. The dialog still reloads (there
655    /// is nothing to persist), but the keypress must not just vanish: it
656    /// flashes instead.
657    #[tokio::test(flavor = "multi_thread")]
658    async fn unpin_selected_flashes_when_the_row_is_already_gone() {
659        // Nothing is pinned in the vault, so the dialog's row for "a.md" is
660        // stale by construction.
661        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        // Without the `persist_pending` guard, the second `J` below would
688        // run its own optimistic swap immediately (both key presses happen
689        // synchronously, before the first press's spawned write has had a
690        // chance to run) and start a second write-and-reload whose reload
691        // can land before the first's, leaving the rows one edit behind
692        // the disk. With the guard, the second press is a no-op: the cursor
693        // stays where the first press left it, and only one write (and
694        // therefore one reload) happens.
695        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        // Deliver the reload, exactly as `dialogs/mod.rs` would: this is
718        // what clears the guard.
719        d.set_rows(loaded);
720        assert!(!d.persist_pending, "the reload cleared the guard");
721
722        // No second write means no second reload: nothing else should ever
723        // arrive on this channel.
724        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    /// A failed reload must settle the dialog (flash, clear the in-flight
733    /// guard) or `J`/`K`/`d` would refuse forever.
734    #[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}