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