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