Skip to main content

kimun_notes/components/dialogs/
save_search_dialog.rs

1use ratatui::Frame;
2use ratatui::layout::{Constraint, Direction, Layout, Rect};
3use ratatui::style::Style;
4use ratatui::widgets::Paragraph;
5
6use crate::components::event_state::EventState;
7use crate::components::events::{AppEvent, AppTx, InputEvent, SaveSource, SavedSearchFlow};
8use crate::components::hint_row::HintRow;
9use crate::components::panel::{ModalSpec, modal_chrome};
10use crate::components::single_line_input::{InputOutcome, SingleLineInput};
11use crate::settings::themes::Theme;
12
13/// What submitting the dialog will do with the current name field — drives
14/// the live hint line so an overwrite is never silent.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum SaveHint {
17    /// The name matches the saved search the query came from (the breadcrumb
18    /// provenance): submitting updates it in place.
19    Update(String),
20    /// The name matches a different existing saved search: submitting
21    /// replaces that search's query. Rendered as a warning.
22    Overwrite(String),
23    /// A fresh name: submitting creates a new saved search.
24    SaveNew,
25    /// The name field is empty: submitting saves a new search named after the
26    /// query itself (the query-as-name fallback).
27    SaveNewAsQuery(String),
28    /// The existing names have not loaded yet, so save-new vs overwrite is
29    /// unknown (the provenance Update case never waits — it is checked
30    /// synchronously). Submitting still saves.
31    Pending,
32}
33
34pub struct SaveSearchDialog {
35    /// The query being saved (read-only context).
36    pub query: String,
37    /// User-supplied name for the saved search, pre-filled with the
38    /// breadcrumb provenance when the query came from a saved search.
39    name: SingleLineInput,
40    /// The saved-search name the query came from (breadcrumb provenance).
41    provenance: Option<String>,
42    /// The surface the query was sourced from; echoed on submit so the
43    /// editor re-pins by identity rather than by comparing query text.
44    source: SaveSource,
45    /// Existing saved-search names, loaded asynchronously after open (see
46    /// [`OverlayData::SavedSearchNamesLoaded`](crate::components::events::OverlayData::SavedSearchNamesLoaded)).
47    /// `None` until the load lands —
48    /// the hint shows [`SaveHint::Pending`] rather than guessing "save new".
49    existing: Option<Vec<String>>,
50    hints: HintRow,
51}
52
53impl SaveSearchDialog {
54    pub fn new(query: String, provenance: Option<String>, source: SaveSource) -> Self {
55        let name = match &provenance {
56            Some(p) => SingleLineInput::with_value(p.clone()),
57            None => SingleLineInput::new(),
58        };
59        Self {
60            query,
61            name,
62            provenance,
63            source,
64            existing: None,
65            hints: super::confirm_hints("Save"),
66        }
67    }
68
69    /// Supply the vault's existing saved-search names (async load result).
70    pub fn set_existing_names(&mut self, names: Vec<String>) {
71        self.existing = Some(names);
72    }
73
74    /// The name a submit would save under: the typed name, or the trimmed
75    /// query when the field is empty (the query-as-name fallback).
76    fn effective_name(&self) -> &str {
77        let typed = self.name.value().trim();
78        if typed.is_empty() {
79            self.query.trim()
80        } else {
81            typed
82        }
83    }
84
85    /// What submitting right now would do. Name matching delegates to core's
86    /// `saved_search_name_matches` — the same rule `save_search` applies on
87    /// write, so the hint can never disagree with the actual save outcome.
88    pub fn hint(&self) -> SaveHint {
89        let matches = kimun_core::saved_search_name_matches;
90        let effective = self.effective_name();
91        if let Some(p) = &self.provenance
92            && matches(p, effective)
93        {
94            return SaveHint::Update(p.clone());
95        }
96        let Some(existing) = &self.existing else {
97            return SaveHint::Pending;
98        };
99        if let Some(name) = existing.iter().find(|n| matches(n, effective)) {
100            return SaveHint::Overwrite(name.clone());
101        }
102        if self.name.value().trim().is_empty() {
103            SaveHint::SaveNewAsQuery(self.query.clone())
104        } else {
105            SaveHint::SaveNew
106        }
107    }
108
109    pub fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
110        let key = match event {
111            InputEvent::Key(key) => key,
112            // A click on a hint chip runs its key. Modal: every mouse event
113            // is consumed.
114            InputEvent::Mouse(m) => {
115                if let Some(key) = self.hints.hit(m) {
116                    self.handle_input(&InputEvent::Key(key), tx);
117                }
118                return EventState::Consumed;
119            }
120            InputEvent::Paste(_) => return EventState::NotConsumed,
121        };
122        match self.name.handle_key(key) {
123            InputOutcome::Submit => {
124                tx.send(AppEvent::SavedSearch(SavedSearchFlow::Confirmed {
125                    name: self.effective_name().to_string(),
126                    query: self.query.clone(),
127                    source: self.source,
128                }))
129                .ok();
130                tx.send(AppEvent::CloseOverlay).ok();
131                EventState::Consumed
132            }
133            InputOutcome::Cancel => {
134                tx.send(AppEvent::CloseOverlay).ok();
135                EventState::Consumed
136            }
137            InputOutcome::Changed | InputOutcome::Consumed => EventState::Consumed,
138            InputOutcome::NotConsumed => EventState::NotConsumed,
139        }
140    }
141
142    pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
143        let popup_area = super::fixed_centered_rect(62, 9, rect);
144
145        let fg = theme.fg.to_ratatui();
146        let gray = theme.gray.to_ratatui();
147        let bg = theme.bg_panel.to_ratatui();
148
149        let inner = modal_chrome(
150            f,
151            popup_area,
152            theme,
153            ModalSpec {
154                title: Some(" Save search "),
155                border: Some(Style::default().fg(gray)),
156                ..Default::default()
157            },
158        );
159
160        let rows = Layout::default()
161            .direction(Direction::Vertical)
162            .constraints([
163                Constraint::Length(1), // 0: spacer
164                Constraint::Length(1), // 1: query (read-only context)
165                Constraint::Length(1), // 2: separator
166                Constraint::Length(1), // 3: name input
167                Constraint::Length(1), // 4: spacer
168                Constraint::Length(1), // 5: hint
169                Constraint::Min(0),    // 6: remainder
170            ])
171            .split(inner);
172
173        // Row 1: read-only query context in muted style.
174        f.render_widget(
175            Paragraph::new(format!("  Query: {}", self.query))
176                .style(Style::default().fg(gray).bg(bg)),
177            rows[1],
178        );
179
180        super::render_separator(f, rows[2], gray, bg);
181
182        // Row 3: name input with a "Name: " prefix.
183        let prefix = "  Name: ";
184        let prefix_len = prefix.len() as u16;
185        f.render_widget(
186            Paragraph::new(prefix).style(Style::default().fg(gray).bg(bg)),
187            rows[3],
188        );
189        self.name
190            .render(f, rows[3], Style::default().fg(fg).bg(bg), prefix_len, true);
191
192        // Row 5: live hint — what Enter will do with the current name.
193        // Pending renders dimmed (enter_active = false) until names load.
194        let (action, warn, pending) = match self.hint() {
195            SaveHint::Update(name) => (format!("Update '{name}'"), false, false),
196            SaveHint::Overwrite(name) => (format!("Overwrite '{name}'"), true, false),
197            SaveHint::SaveNew => ("Save new".to_string(), false, false),
198            SaveHint::SaveNewAsQuery(query) => (format!("Save new: '{query}'"), false, false),
199            SaveHint::Pending => ("Save".to_string(), false, true),
200        };
201        let enter_fg = if warn { theme.yellow.to_ratatui() } else { fg };
202        self.hints.set_label(0, "Enter", &action);
203        // Enter saves even while the existing names are still loading, so
204        // the chip stays clickable while it looks pending.
205        super::render_confirm_hints(f, rows[5], &mut self.hints, !pending, true, enter_fg, theme);
206    }
207}
208
209impl super::Dialog for SaveSearchDialog {
210    fn key(
211        &mut self,
212        key: ratatui::crossterm::event::KeyEvent,
213        tx: &crate::components::events::AppTx,
214    ) -> crate::components::event_state::EventState {
215        self.handle_input(&InputEvent::Key(key), tx)
216    }
217
218    fn mouse(
219        &mut self,
220        m: &ratatui::crossterm::event::MouseEvent,
221        tx: &crate::components::events::AppTx,
222    ) -> crate::components::event_state::EventState {
223        self.handle_input(&InputEvent::Mouse(*m), tx)
224    }
225
226    fn draw(
227        &mut self,
228        f: &mut ratatui::Frame,
229        rect: ratatui::layout::Rect,
230        theme: &crate::settings::themes::Theme,
231    ) {
232        self.render(f, rect, theme, true)
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use crate::components::events::{AppEvent, InputEvent};
240    use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
241    use tokio::sync::mpsc::unbounded_channel;
242
243    fn key(code: KeyCode) -> InputEvent {
244        InputEvent::Key(KeyEvent::new(code, KeyModifiers::NONE))
245    }
246
247    fn dialog(query: &str, provenance: Option<&str>) -> SaveSearchDialog {
248        SaveSearchDialog::new(
249            query.to_string(),
250            provenance.map(str::to_string),
251            SaveSource::QueryPanel,
252        )
253    }
254
255    /// Drain the channel and return the `SaveSearchConfirmed` payload, if any.
256    fn confirmed(
257        rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
258    ) -> Option<(String, String, SaveSource)> {
259        let mut found = None;
260        while let Ok(e) = rx.try_recv() {
261            if let AppEvent::SavedSearch(SavedSearchFlow::Confirmed {
262                name,
263                query,
264                source,
265            }) = e
266            {
267                found = Some((name, query, source));
268            }
269        }
270        found
271    }
272
273    #[test]
274    fn submit_emits_save_event_with_typed_name() {
275        let mut d = dialog("<{note}", None);
276        let (tx, mut rx) = unbounded_channel();
277        for ch in ['l', 'i', 'n', 'k', 's'] {
278            d.handle_input(&key(KeyCode::Char(ch)), &tx);
279        }
280        d.handle_input(&key(KeyCode::Enter), &tx);
281        let (name, query, source) = confirmed(&mut rx).expect("SaveSearchConfirmed emitted");
282        assert_eq!(name, "links");
283        assert_eq!(query, "<{note}");
284        assert_eq!(source, SaveSource::QueryPanel);
285    }
286
287    #[test]
288    fn submit_carries_the_dialog_source_through() {
289        let mut d = SaveSearchDialog::new("#todo".to_string(), None, SaveSource::NoteBrowser);
290        let (tx, mut rx) = unbounded_channel();
291        d.handle_input(&key(KeyCode::Enter), &tx);
292        let (_, _, source) = confirmed(&mut rx).expect("emitted");
293        assert_eq!(source, SaveSource::NoteBrowser);
294    }
295
296    #[test]
297    fn submit_empty_name_falls_back_to_query() {
298        let mut d = dialog("#todo", None);
299        let (tx, mut rx) = unbounded_channel();
300        d.handle_input(&key(KeyCode::Enter), &tx);
301        let (name, query, _) = confirmed(&mut rx).expect("emitted");
302        assert_eq!(name, "#todo"); // empty → query used as name
303        assert_eq!(query, "#todo");
304    }
305
306    #[test]
307    fn empty_name_fallback_trims_the_query() {
308        // The typed branch trims, so the fallback must too — otherwise a
309        // padded query saves under a whitespace-padded, unmatchable name.
310        let mut d = dialog("#todo ", None);
311        let (tx, mut rx) = unbounded_channel();
312        d.handle_input(&key(KeyCode::Enter), &tx);
313        let (name, query, _) = confirmed(&mut rx).expect("emitted");
314        assert_eq!(name, "#todo"); // trimmed
315        assert_eq!(query, "#todo "); // query itself stays verbatim
316    }
317
318    #[test]
319    fn provenance_prefills_name_so_plain_enter_updates() {
320        let mut d = dialog("#todo and #urgent", Some("todo"));
321        let (tx, mut rx) = unbounded_channel();
322        d.handle_input(&key(KeyCode::Enter), &tx);
323        let (name, query, _) = confirmed(&mut rx).expect("emitted");
324        assert_eq!(name, "todo"); // provenance pre-filled, untouched
325        assert_eq!(query, "#todo and #urgent");
326    }
327
328    #[test]
329    fn hint_updates_when_name_matches_provenance_even_before_names_load() {
330        // The provenance is passed synchronously, so the Update hint must
331        // not wait for the async existing-names load.
332        let d = dialog("#todo", Some("todo"));
333        assert_eq!(d.hint(), SaveHint::Update("todo".into()));
334    }
335
336    #[test]
337    fn hint_is_pending_until_names_load() {
338        // Before the async load lands, the dialog cannot distinguish a fresh
339        // name from an overwrite — it must say neither, not "Save new".
340        let mut d = dialog("#todo", None);
341        let (tx, _rx) = unbounded_channel();
342        d.handle_input(&key(KeyCode::Char('x')), &tx);
343        assert_eq!(d.hint(), SaveHint::Pending);
344        d.set_existing_names(vec![]);
345        assert_eq!(d.hint(), SaveHint::SaveNew);
346    }
347
348    #[test]
349    fn hint_matches_existing_names_case_insensitively() {
350        let mut d = dialog("#todo", None);
351        d.set_existing_names(vec!["Todo".into()]);
352        let (tx, _rx) = unbounded_channel();
353        for ch in ['t', 'O', 'd', 'O'] {
354            d.handle_input(&key(KeyCode::Char(ch)), &tx);
355        }
356        // Same rule core uses on save: ASCII case-insensitive name match.
357        assert_eq!(d.hint(), SaveHint::Overwrite("Todo".into()));
358    }
359
360    #[test]
361    fn hint_overwrites_when_name_matches_another_existing_search() {
362        let mut d = dialog("#todo", Some("todo"));
363        d.set_existing_names(vec!["todo".into(), "other".into()]);
364        let (tx, _rx) = unbounded_channel();
365        // Clear the pre-filled "todo" and type "other".
366        for _ in 0..4 {
367            d.handle_input(&key(KeyCode::Backspace), &tx);
368        }
369        for ch in ['o', 't', 'h', 'e', 'r'] {
370            d.handle_input(&key(KeyCode::Char(ch)), &tx);
371        }
372        assert_eq!(d.hint(), SaveHint::Overwrite("other".into()));
373    }
374
375    #[test]
376    fn hint_saves_new_for_a_fresh_name() {
377        let mut d = dialog("#todo", None);
378        d.set_existing_names(vec!["other".into()]);
379        let (tx, _rx) = unbounded_channel();
380        for ch in ['f', 'r', 'e', 's', 'h'] {
381            d.handle_input(&key(KeyCode::Char(ch)), &tx);
382        }
383        assert_eq!(d.hint(), SaveHint::SaveNew);
384    }
385
386    #[test]
387    fn hint_empty_name_shows_query_as_name_fallback() {
388        let mut d = dialog("#todo", None);
389        d.set_existing_names(vec![]);
390        assert_eq!(d.hint(), SaveHint::SaveNewAsQuery("#todo".into()));
391    }
392
393    #[test]
394    fn hint_empty_name_with_colliding_query_warns_overwrite() {
395        let mut d = dialog("#todo", None);
396        // A saved search literally named "#todo" exists; the query-as-name
397        // fallback would overwrite it.
398        d.set_existing_names(vec!["#todo".into()]);
399        assert_eq!(d.hint(), SaveHint::Overwrite("#todo".into()));
400    }
401}