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#[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>>,
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 pub fn set_existing_names(&mut self, names: Vec<String>) {
67 self.existing = Some(names);
68 }
69
70 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 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), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
158 .split(inner);
159
160 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 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 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 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"); assert_eq!(query, "#todo");
269 }
270
271 #[test]
272 fn empty_name_fallback_trims_the_query() {
273 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"); assert_eq!(query, "#todo "); }
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"); assert_eq!(query, "#todo and #urgent");
291 }
292
293 #[test]
294 fn hint_updates_when_name_matches_provenance_even_before_names_load() {
295 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 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 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 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 d.set_existing_names(vec!["#todo".into()]);
364 assert_eq!(d.hint(), SaveHint::Overwrite("#todo".into()));
365 }
366}