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#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum SaveHint {
17 Update(String),
20 Overwrite(String),
23 SaveNew,
25 SaveNewAsQuery(String),
28 Pending,
32}
33
34pub struct SaveSearchDialog {
35 pub query: String,
37 name: SingleLineInput,
40 provenance: Option<String>,
42 source: SaveSource,
45 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 pub fn set_existing_names(&mut self, names: Vec<String>) {
71 self.existing = Some(names);
72 }
73
74 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 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 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), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
171 .split(inner);
172
173 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 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 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 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 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"); assert_eq!(query, "#todo");
304 }
305
306 #[test]
307 fn empty_name_fallback_trims_the_query() {
308 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"); assert_eq!(query, "#todo "); }
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"); assert_eq!(query, "#todo and #urgent");
326 }
327
328 #[test]
329 fn hint_updates_when_name_matches_provenance_even_before_names_load() {
330 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 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 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 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 d.set_existing_names(vec!["#todo".into()]);
399 assert_eq!(d.hint(), SaveHint::Overwrite("#todo".into()));
400 }
401}