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#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum SaveHint {
16 Update(String),
19 Overwrite(String),
22 SaveNew,
24 SaveNewAsQuery(String),
27 Pending,
31}
32
33pub struct SaveSearchDialog {
34 pub query: String,
36 name: SingleLineInput,
39 provenance: Option<String>,
41 source: SaveSource,
44 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 pub fn set_existing_names(&mut self, names: Vec<String>) {
68 self.existing = Some(names);
69 }
70
71 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 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), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
159 .split(inner);
160
161 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 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 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 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"); assert_eq!(query, "#todo");
270 }
271
272 #[test]
273 fn empty_name_fallback_trims_the_query() {
274 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"); assert_eq!(query, "#todo "); }
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"); assert_eq!(query, "#todo and #urgent");
292 }
293
294 #[test]
295 fn hint_updates_when_name_matches_provenance_even_before_names_load() {
296 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 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 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 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 d.set_existing_names(vec!["#todo".into()]);
365 assert_eq!(d.hint(), SaveHint::Overwrite("#todo".into()));
366 }
367}