irongit

feat: savable queries

huncholanehuncholaneauthored
parent 2c7da3fcommit bc0fb3971bbe2e8b9f724eb830a54eedf6496847Browse files

5 files changed, +278 -20

+10-0src/app.rs
@@ -262,6 +262,7 @@ impl JvApp {
262262 if let Some(loaded) = &app.current_session {
263263 app.applied_threshold = loaded.session.jaccard_threshold;
264264 app.browser_view.load_focus_list(&loaded.session.focus_list);
265+ app.browser_view.load_saved_queries(&loaded.session.saved_queries);
265266 }
266267 tracing::info!("UI loaded");
267268 app
@@ -355,6 +356,7 @@ impl JvApp {
355356 let session = self.session_manager.sessions[i].clone();
356357 self.session_manager.save_last_session_id(&session.id);
357358 self.browser_view.load_focus_list(&session.focus_list);
359+ self.browser_view.load_saved_queries(&session.saved_queries);
358360 self.current_session = Some(LoadedSession::new(session));
359361 self.selected_file_index = 0;
360362 self.rebuild_schema();
@@ -922,6 +924,14 @@ impl JvApp {
922924 }
923925 }
924926
927+ // Save queries if changed
928+ if self.browser_view.take_queries_dirty() {
929+ if let Some(loaded) = &mut self.current_session {
930+ loaded.session.saved_queries = self.browser_view.export_saved_queries();
931+ self.session_manager.update_session(&loaded.session);
932+ }
933+ }
934+
925935 // Sync sidebar selection from browser's current file
926936 if let Some(loaded) = &self.current_session {
927937 if let Some(file_key) = self.browser_view.current_file_key(&loaded.parsed_files) {
+10-0src/session.rs
@@ -29,6 +29,15 @@ pub struct Session {
2929 pub hidden_fields: Vec<String>, // "StructName.field_name" format
3030 #[serde(default)]
3131 pub focus_list: Vec<Vec<String>>, // saved focus paths for jv browser
32+ #[serde(default)]
33+ pub saved_queries: Vec<SavedQuery>,
34+}
35+
36+#[derive(Debug, Clone, Serialize, Deserialize)]
37+pub struct SavedQuery {
38+ pub name: String,
39+ pub query: String,
40+ pub mode: String, // "fuzzy", "contains", "exact", "off"
3241 }
3342
3443 fn default_jaccard() -> f32 {
@@ -144,6 +153,7 @@ impl Session {
144153 enum_conversions: Vec::new(),
145154 hidden_fields: Vec::new(),
146155 focus_list: Vec::new(),
156+ saved_queries: Vec::new(),
147157 }
148158 }
149159 }
+29-2src/views/browser.rs
@@ -145,6 +145,33 @@ impl BrowserView {
145145 dirty
146146 }
147147
148+ /// Load saved queries into both filters.
149+ pub fn load_saved_queries(&mut self, queries: &[crate::session::SavedQuery]) {
150+ self.filter.load_queries(queries);
151+ self.preview_filter.load_queries(queries);
152+ }
153+
154+ /// Export saved queries (both filters are kept in sync by take_queries_dirty).
155+ pub fn export_saved_queries(&self) -> Vec<crate::session::SavedQuery> {
156+ self.filter.export_queries()
157+ }
158+
159+ /// Returns true if any filter's saved queries changed. Syncs both filters.
160+ pub fn take_queries_dirty(&mut self) -> bool {
161+ let dirty = self.filter.queries_dirty || self.preview_filter.queries_dirty;
162+ if dirty {
163+ // Keep both filters in sync
164+ if self.filter.queries_dirty {
165+ self.preview_filter.saved_queries = self.filter.saved_queries.clone();
166+ } else {
167+ self.filter.saved_queries = self.preview_filter.saved_queries.clone();
168+ }
169+ }
170+ self.filter.queries_dirty = false;
171+ self.preview_filter.queries_dirty = false;
172+ dirty
173+ }
174+
148175 pub fn invalidate(&mut self) {
149176 self.cache_key = 0;
150177 self.path.clear();
@@ -485,7 +512,7 @@ impl BrowserView {
485512 ui.set_width(current_width);
486513 ui.set_height(col_height);
487514 crate::widgets::miller::pane_title(ui, &mid_title);
488- let filter_resp = self.filter.show(ui, "? to filter");
515+ let filter_resp = self.filter.show(ui, "?");
489516
490517 // Filter + snap selection
491518 let fr = self.filter.apply(
@@ -548,7 +575,7 @@ impl BrowserView {
548575 ui.set_width(remaining);
549576 ui.set_height(col_height);
550577 crate::widgets::miller::pane_title(ui, &right_title);
551- self.preview_filter.show(ui, "ctrl-/ to filter");
578+ self.preview_filter.show(ui, "ctrl-/");
552579 self.render_preview_column(ui, selected_child, &current_entries, col_height);
553580 });
554581 });
+1-1src/views/shared_browser.rs
@@ -223,7 +223,7 @@ impl SharedBrowserView {
223223 ui.set_width(col_widths[1]);
224224 ui.set_height(col_height);
225225 render_pane_title(ui, &mid_title);
226- let filter_resp = self.filter.show(ui, "? to filter");
226+ let filter_resp = self.filter.show(ui, "?");
227227
228228 // Filter + snap selection
229229 let fr = self.filter.apply(
+228-17src/widgets/miller.rs
@@ -132,7 +132,7 @@ impl FilterMode {
132132 match self {
133133 FilterMode::Off => egui_phosphor::regular::PROHIBIT,
134134 FilterMode::Fuzzy => egui_phosphor::regular::WAVES,
135- FilterMode::Contains => egui_phosphor::regular::TEXTBOX,
135+ FilterMode::Contains => egui_phosphor::regular::TEXT_T,
136136 FilterMode::Exact => egui_phosphor::regular::EQUALS,
137137 }
138138 }
@@ -161,6 +161,19 @@ pub struct MillerFilter {
161161 id: &'static str,
162162 focus_next: bool,
163163 pub mode: FilterMode,
164+ // Saved query state
165+ pub current_name: Option<String>,
166+ pub saved_queries: Vec<(String, String, FilterMode)>, // (name, query, mode)
167+ save_popup: SavePopupState,
168+ load_index: Option<usize>, // current position when cycling saved queries
169+ pub queries_dirty: bool,
170+}
171+
172+#[derive(Default)]
173+struct SavePopupState {
174+ open: bool,
175+ name_input: String,
176+ overwrite_idx: Option<usize>,
164177 }
165178
166179 impl MillerFilter {
@@ -170,29 +183,108 @@ impl MillerFilter {
170183 id,
171184 focus_next: false,
172185 mode: FilterMode::Fuzzy,
186+ current_name: None,
187+ saved_queries: Vec::new(),
188+ save_popup: SavePopupState::default(),
189+ load_index: None,
190+ queries_dirty: false,
173191 }
174192 }
175193
194+ /// Load saved queries from session data.
195+ pub fn load_queries(&mut self, queries: &[crate::session::SavedQuery]) {
196+ self.saved_queries = queries.iter().map(|sq| {
197+ let mode = match sq.mode.as_str() {
198+ "fuzzy" => FilterMode::Fuzzy,
199+ "contains" => FilterMode::Contains,
200+ "exact" => FilterMode::Exact,
201+ _ => FilterMode::Fuzzy,
202+ };
203+ (sq.name.clone(), sq.query.clone(), mode)
204+ }).collect();
205+ }
206+
207+ /// Export saved queries for session persistence.
208+ pub fn export_queries(&self) -> Vec<crate::session::SavedQuery> {
209+ self.saved_queries.iter().map(|(name, query, mode)| {
210+ crate::session::SavedQuery {
211+ name: name.clone(),
212+ query: query.clone(),
213+ mode: match mode {
214+ FilterMode::Off => "off".to_string(),
215+ FilterMode::Fuzzy => "fuzzy".to_string(),
216+ FilterMode::Contains => "contains".to_string(),
217+ FilterMode::Exact => "exact".to_string(),
218+ },
219+ }
220+ }).collect()
221+ }
222+
176223 /// Request focus on this filter's input next frame.
177224 pub fn focus(&mut self) {
178225 self.focus_next = true;
179226 }
180227
181- /// Returns true if this filter's text input currently has focus.
228+ /// Returns true if this filter's text input or any popup has focus.
182229 pub fn has_focus(&self, ui: &Ui) -> bool {
230+ if self.save_popup.open {
231+ return true;
232+ }
183233 let id = egui::Id::new(self.id);
184234 ui.ctx().memory(|m| m.focused().map_or(false, |f| f == id))
185235 }
186236
187237 /// Render the filter bar. Always visible.
188- /// `hint` is the placeholder text (e.g. "? to filter" or "ctrl-/ to filter").
189- pub fn show(&mut self, ui: &mut Ui, hint: &str) -> MillerFilterResponse {
238+ /// `shortcut` is shown in the placeholder (e.g. "?" or "ctrl-/").
239+ pub fn show(&mut self, ui: &mut Ui, shortcut: &str) -> MillerFilterResponse {
190240 let mut resp = MillerFilterResponse {
191241 next: false,
192242 prev: false,
193243 accept: false,
194244 };
195245
246+ // Query name label — always visible, shortcuts only when focused
247+ let filter_focused = {
248+ let id = egui::Id::new(self.id);
249+ ui.ctx().memory(|m| m.focused().map_or(false, |f| f == id))
250+ } || self.save_popup.open || false;
251+
252+ let name_color = if self.current_name.is_some() {
253+ crate::theme::CatppuccinMocha::MAUVE
254+ } else {
255+ crate::theme::CatppuccinMocha::SURFACE2
256+ };
257+ ui.horizontal(|ui| {
258+ ui.label(
259+ egui::RichText::new(egui_phosphor::regular::BOOKMARK_SIMPLE)
260+ .color(name_color)
261+ .size(10.0),
262+ );
263+ let name_text = if let Some(name) = &self.current_name {
264+ if filter_focused {
265+ format!("{} (ctrl-s save, ctrl-S save as{})",
266+ name,
267+ if self.saved_queries.is_empty() { "" } else { ", ctrl-p/n cycle" })
268+ } else {
269+ name.clone()
270+ }
271+ } else if filter_focused {
272+ format!("unnamed query (ctrl-s save{})",
273+ if self.saved_queries.is_empty() { "" } else { ", ctrl-p/n cycle" })
274+ } else {
275+ "unnamed query".to_string()
276+ };
277+ ui.label(
278+ egui::RichText::new(&name_text)
279+ .color(name_color)
280+ .family(egui::FontFamily::Monospace)
281+ .size(10.0),
282+ );
283+ });
284+
285+ // Build dynamic hint
286+ let hint = format!("{} to focus | ctrl-space mode", shortcut);
287+
196288 ui.horizontal(|ui| {
197289 ui.label(
198290 egui::RichText::new(egui_phosphor::regular::MAGNIFYING_GLASS)
@@ -223,7 +315,7 @@ impl MillerFilter {
223315 .desired_width(ui.available_width() - 10.0)
224316 .text_color(crate::theme::CatppuccinMocha::GREEN)
225317 .hint_text(
226- egui::RichText::new(hint)
318+ egui::RichText::new(&hint)
227319 .color(crate::theme::CatppuccinMocha::SURFACE2)
228320 .family(egui::FontFamily::Monospace)
229321 ),
@@ -239,27 +331,146 @@ impl MillerFilter {
239331
240332 if has_focus || lost_focus {
241333 if has_focus {
242- // Ctrl-F: cycle filter mode
243- if ui.input_mut(|i| i.consume_key(egui::Modifiers::CTRL, egui::Key::F)) {
334+ // Ctrl-Space: cycle filter mode
335+ if ui.input_mut(|i| i.consume_key(egui::Modifiers::CTRL, egui::Key::Space)) {
244336 self.mode = self.mode.next();
245337 }
246- // Escape: unfocus (don't clear)
338+ // Ctrl-S / Ctrl-Shift-S: save
339+ let shift_held = ui.input(|i| i.modifiers.shift);
340+ let ctrl_s = ui.input_mut(|i| i.consume_key(egui::Modifiers::CTRL, egui::Key::S));
341+ if ctrl_s && shift_held {
342+ // Ctrl-Shift-S: save as (always opens popup)
343+ self.save_popup.open = true;
344+ self.save_popup.name_input.clear();
345+ } else if ctrl_s {
346+ if self.current_name.is_some() {
347+ // Overwrite existing
348+ let name = self.current_name.clone().unwrap();
349+ if let Some(idx) = self.saved_queries.iter().position(|(n, _, _)| *n == name) {
350+ self.saved_queries[idx].1 = self.query.clone();
351+ self.saved_queries[idx].2 = self.mode;
352+ }
353+ self.queries_dirty = true;
354+ } else {
355+ // No name yet — open save popup
356+ self.save_popup.open = true;
357+ self.save_popup.name_input.clear();
358+ }
359+ }
360+ // Ctrl-P: cycle to next saved query
361+ if ui.input_mut(|i| i.consume_key(egui::Modifiers::CTRL, egui::Key::P)) {
362+ if !self.saved_queries.is_empty() {
363+ let next = match self.load_index {
364+ Some(idx) => (idx + 1) % self.saved_queries.len(),
365+ None => 0,
366+ };
367+ let (name, query, mode) = &self.saved_queries[next];
368+ self.query = query.clone();
369+ self.mode = *mode;
370+ self.current_name = Some(name.clone());
371+ self.load_index = Some(next);
372+ }
373+ }
374+ // Ctrl-N: cycle to previous saved query (reverse)
375+ if ui.input_mut(|i| i.consume_key(egui::Modifiers::CTRL, egui::Key::N)) {
376+ if !self.saved_queries.is_empty() {
377+ let prev = match self.load_index {
378+ Some(0) | None => self.saved_queries.len() - 1,
379+ Some(idx) => idx - 1,
380+ };
381+ let (name, query, mode) = &self.saved_queries[prev];
382+ self.query = query.clone();
383+ self.mode = *mode;
384+ self.current_name = Some(name.clone());
385+ self.load_index = Some(prev);
386+ }
387+ }
388+ // Escape: close popups or unfocus
247389 if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
248- r.surrender_focus();
390+ if self.save_popup.open {
391+ self.save_popup.open = false;
392+ } else {
393+ r.surrender_focus();
394+ }
249395 }
250- // Ctrl-N / ArrowDown
251- resp.next = ui.input(|i| i.key_pressed(egui::Key::ArrowDown))
252- || ui.input_mut(|i| i.consume_key(egui::Modifiers::CTRL, egui::Key::N));
253- // Ctrl-P / ArrowUp
254- resp.prev = ui.input(|i| i.key_pressed(egui::Key::ArrowUp))
255- || ui.input_mut(|i| i.consume_key(egui::Modifiers::CTRL, egui::Key::P));
396+ // ArrowDown: next match
397+ resp.next = ui.input(|i| i.key_pressed(egui::Key::ArrowDown));
398+ // ArrowUp: prev match
399+ resp.prev = ui.input(|i| i.key_pressed(egui::Key::ArrowUp));
256400 }
257- // Enter (check both has_focus and lost_focus since TextEdit surrenders on Enter)
401+ // Enter
258402 if ui.input(|i| i.key_pressed(egui::Key::Enter)) {
259- resp.accept = true;
403+ if !self.save_popup.open && !false {
404+ resp.accept = true;
405+ }
260406 }
261407 }
262408 });
409+
410+ // Save popup
411+ if self.save_popup.open {
412+ egui::Frame::new()
413+ .fill(crate::theme::CatppuccinMocha::SURFACE0)
414+ .inner_margin(8.0)
415+ .corner_radius(4.0)
416+ .stroke(egui::Stroke::new(1.0, crate::theme::CatppuccinMocha::SURFACE1))
417+ .show(ui, |ui| {
418+ ui.label(
419+ egui::RichText::new("Save query as:")
420+ .color(crate::theme::CatppuccinMocha::TEXT)
421+ .size(12.0),
422+ );
423+ let enter = ui.add(
424+ egui::TextEdit::singleline(&mut self.save_popup.name_input)
425+ .font(egui::FontId::monospace(12.0))
426+ .desired_width(ui.available_width() - 10.0)
427+ .text_color(crate::theme::CatppuccinMocha::GREEN),
428+ ).lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
429+
430+ if !self.saved_queries.is_empty() {
431+ ui.add_space(4.0);
432+ ui.label(
433+ egui::RichText::new("Or overwrite:")
434+ .color(crate::theme::CatppuccinMocha::OVERLAY0)
435+ .size(11.0),
436+ );
437+ let mut overwrite = None;
438+ for (i, (name, _, _)) in self.saved_queries.iter().enumerate() {
439+ if ui.add(
440+ egui::Button::new(
441+ egui::RichText::new(name)
442+ .color(crate::theme::CatppuccinMocha::BLUE)
443+ .family(egui::FontFamily::Monospace)
444+ .size(11.0)
445+ ).frame(false)
446+ ).clicked() {
447+ overwrite = Some(i);
448+ }
449+ }
450+ if let Some(idx) = overwrite {
451+ self.saved_queries[idx].1 = self.query.clone();
452+ self.saved_queries[idx].2 = self.mode;
453+ self.current_name = Some(self.saved_queries[idx].0.clone());
454+ self.save_popup.open = false;
455+ self.queries_dirty = true;
456+ }
457+ }
458+
459+ if enter && !self.save_popup.name_input.trim().is_empty() {
460+ let name = self.save_popup.name_input.trim().to_string();
461+ if let Some(idx) = self.saved_queries.iter().position(|(n, _, _)| *n == name) {
462+ self.saved_queries[idx].1 = self.query.clone();
463+ self.saved_queries[idx].2 = self.mode;
464+ } else {
465+ self.saved_queries.push((name.clone(), self.query.clone(), self.mode));
466+ }
467+ self.current_name = Some(name);
468+ self.save_popup.open = false;
469+ self.queries_dirty = true;
470+ }
471+ });
472+ }
473+
263474 ui.add_space(2.0);
264475
265476 resp