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#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum SaveHint {
15 Update(String),
18 Overwrite(String),
21 SaveNew,
23 SaveNewAsQuery(String),
26 Pending,
30}
31
32pub struct SaveSearchDialog {
33 pub query: String,
35 name: SingleLineInput,
38 provenance: Option<String>,
40 source: SaveSource,
43 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 pub fn set_existing_names(&mut self, names: Vec<String>) {
66 self.existing = Some(names);
67 }
68
69 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 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), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
156 .split(inner);
157
158 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 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 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 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"); assert_eq!(query, "#todo");
271 }
272
273 #[test]
274 fn empty_name_fallback_trims_the_query() {
275 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"); assert_eq!(query, "#todo "); }
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"); assert_eq!(query, "#todo and #urgent");
293 }
294
295 #[test]
296 fn hint_updates_when_name_matches_provenance_even_before_names_load() {
297 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 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 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 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 d.set_existing_names(vec!["#todo".into()]);
366 assert_eq!(d.hint(), SaveHint::Overwrite("#todo".into()));
367 }
368}