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