feat: savable queries
5 files changed, +278 -20
+10-0src/app.rs
| @@ -262,6 +262,7 @@ impl JvApp { | ||
| 262 | 262 | if let Some(loaded) = &app.current_session { |
| 263 | 263 | app.applied_threshold = loaded.session.jaccard_threshold; |
| 264 | 264 | app.browser_view.load_focus_list(&loaded.session.focus_list); |
| 265 | + app.browser_view.load_saved_queries(&loaded.session.saved_queries); | |
| 265 | 266 | } |
| 266 | 267 | tracing::info!("UI loaded"); |
| 267 | 268 | app |
| @@ -355,6 +356,7 @@ impl JvApp { | ||
| 355 | 356 | let session = self.session_manager.sessions[i].clone(); |
| 356 | 357 | self.session_manager.save_last_session_id(&session.id); |
| 357 | 358 | self.browser_view.load_focus_list(&session.focus_list); |
| 359 | + self.browser_view.load_saved_queries(&session.saved_queries); | |
| 358 | 360 | self.current_session = Some(LoadedSession::new(session)); |
| 359 | 361 | self.selected_file_index = 0; |
| 360 | 362 | self.rebuild_schema(); |
| @@ -922,6 +924,14 @@ impl JvApp { | ||
| 922 | 924 | } |
| 923 | 925 | } |
| 924 | 926 | |
| 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 | + | |
| 925 | 935 | // Sync sidebar selection from browser's current file |
| 926 | 936 | if let Some(loaded) = &self.current_session { |
| 927 | 937 | 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 { | ||
| 29 | 29 | pub hidden_fields: Vec<String>, // "StructName.field_name" format |
| 30 | 30 | #[serde(default)] |
| 31 | 31 | 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" | |
| 32 | 41 | } |
| 33 | 42 | |
| 34 | 43 | fn default_jaccard() -> f32 { |
| @@ -144,6 +153,7 @@ impl Session { | ||
| 144 | 153 | enum_conversions: Vec::new(), |
| 145 | 154 | hidden_fields: Vec::new(), |
| 146 | 155 | focus_list: Vec::new(), |
| 156 | + saved_queries: Vec::new(), | |
| 147 | 157 | } |
| 148 | 158 | } |
| 149 | 159 | } |
+29-2src/views/browser.rs
| @@ -145,6 +145,33 @@ impl BrowserView { | ||
| 145 | 145 | dirty |
| 146 | 146 | } |
| 147 | 147 | |
| 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 | + | |
| 148 | 175 | pub fn invalidate(&mut self) { |
| 149 | 176 | self.cache_key = 0; |
| 150 | 177 | self.path.clear(); |
| @@ -485,7 +512,7 @@ impl BrowserView { | ||
| 485 | 512 | ui.set_width(current_width); |
| 486 | 513 | ui.set_height(col_height); |
| 487 | 514 | 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, "?"); | |
| 489 | 516 | |
| 490 | 517 | // Filter + snap selection |
| 491 | 518 | let fr = self.filter.apply( |
| @@ -548,7 +575,7 @@ impl BrowserView { | ||
| 548 | 575 | ui.set_width(remaining); |
| 549 | 576 | ui.set_height(col_height); |
| 550 | 577 | crate::widgets::miller::pane_title(ui, &right_title); |
| 551 | - self.preview_filter.show(ui, "ctrl-/ to filter"); | |
| 578 | + self.preview_filter.show(ui, "ctrl-/"); | |
| 552 | 579 | self.render_preview_column(ui, selected_child, ¤t_entries, col_height); |
| 553 | 580 | }); |
| 554 | 581 | }); |
+228-17src/widgets/miller.rs
| @@ -132,7 +132,7 @@ impl FilterMode { | ||
| 132 | 132 | match self { |
| 133 | 133 | FilterMode::Off => egui_phosphor::regular::PROHIBIT, |
| 134 | 134 | FilterMode::Fuzzy => egui_phosphor::regular::WAVES, |
| 135 | - FilterMode::Contains => egui_phosphor::regular::TEXTBOX, | |
| 135 | + FilterMode::Contains => egui_phosphor::regular::TEXT_T, | |
| 136 | 136 | FilterMode::Exact => egui_phosphor::regular::EQUALS, |
| 137 | 137 | } |
| 138 | 138 | } |
| @@ -161,6 +161,19 @@ pub struct MillerFilter { | ||
| 161 | 161 | id: &'static str, |
| 162 | 162 | focus_next: bool, |
| 163 | 163 | 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>, | |
| 164 | 177 | } |
| 165 | 178 | |
| 166 | 179 | impl MillerFilter { |
| @@ -170,29 +183,108 @@ impl MillerFilter { | ||
| 170 | 183 | id, |
| 171 | 184 | focus_next: false, |
| 172 | 185 | 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, | |
| 173 | 191 | } |
| 174 | 192 | } |
| 175 | 193 | |
| 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 | + | |
| 176 | 223 | /// Request focus on this filter's input next frame. |
| 177 | 224 | pub fn focus(&mut self) { |
| 178 | 225 | self.focus_next = true; |
| 179 | 226 | } |
| 180 | 227 | |
| 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. | |
| 182 | 229 | pub fn has_focus(&self, ui: &Ui) -> bool { |
| 230 | + if self.save_popup.open { | |
| 231 | + return true; | |
| 232 | + } | |
| 183 | 233 | let id = egui::Id::new(self.id); |
| 184 | 234 | ui.ctx().memory(|m| m.focused().map_or(false, |f| f == id)) |
| 185 | 235 | } |
| 186 | 236 | |
| 187 | 237 | /// 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 { | |
| 190 | 240 | let mut resp = MillerFilterResponse { |
| 191 | 241 | next: false, |
| 192 | 242 | prev: false, |
| 193 | 243 | accept: false, |
| 194 | 244 | }; |
| 195 | 245 | |
| 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 | + | |
| 196 | 288 | ui.horizontal(|ui| { |
| 197 | 289 | ui.label( |
| 198 | 290 | egui::RichText::new(egui_phosphor::regular::MAGNIFYING_GLASS) |
| @@ -223,7 +315,7 @@ impl MillerFilter { | ||
| 223 | 315 | .desired_width(ui.available_width() - 10.0) |
| 224 | 316 | .text_color(crate::theme::CatppuccinMocha::GREEN) |
| 225 | 317 | .hint_text( |
| 226 | - egui::RichText::new(hint) | |
| 318 | + egui::RichText::new(&hint) | |
| 227 | 319 | .color(crate::theme::CatppuccinMocha::SURFACE2) |
| 228 | 320 | .family(egui::FontFamily::Monospace) |
| 229 | 321 | ), |
| @@ -239,27 +331,146 @@ impl MillerFilter { | ||
| 239 | 331 | |
| 240 | 332 | if has_focus || lost_focus { |
| 241 | 333 | 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)) { | |
| 244 | 336 | self.mode = self.mode.next(); |
| 245 | 337 | } |
| 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 | |
| 247 | 389 | 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 | + } | |
| 249 | 395 | } |
| 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)); | |
| 256 | 400 | } |
| 257 | - // Enter (check both has_focus and lost_focus since TextEdit surrenders on Enter) | |
| 401 | + // Enter | |
| 258 | 402 | 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 | + } | |
| 260 | 406 | } |
| 261 | 407 | } |
| 262 | 408 | }); |
| 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 | + | |
| 263 | 474 | ui.add_space(2.0); |
| 264 | 475 | |
| 265 | 476 | resp |