irongit

refactor: jq bar abstracted into widget

huncholanehuncholaneauthored
parent 8fd8c9dcommit a83eec5842795359e911c59cd546bccb381eaf77Browse files

6 files changed, +385 -874

+22-337src/views/browser.rs
@@ -41,15 +41,10 @@ pub struct BrowserView {
4141 scroll_to_selection: bool,
4242 restore_key: Option<String>,
4343 // jq bar
44- jq_input: String,
44+ jq_bar: crate::widgets::jq_bar::JqBar,
4545 jq_synced: bool,
4646 jq_result: Option<String>,
4747 jq_error: Option<String>,
48- // completions
49- completions: Vec<String>,
50- completion_index: usize,
51- show_completions: bool,
52- refocus_jq: bool,
5348 cache_key: u64,
5449 }
5550
@@ -60,14 +55,10 @@ impl BrowserView {
6055 selection: 0,
6156 scroll_to_selection: false,
6257 restore_key: None,
63- jq_input: ".".to_string(),
58+ jq_bar: crate::widgets::jq_bar::JqBar::new(),
6459 jq_synced: true,
6560 jq_result: None,
6661 jq_error: None,
67- completions: Vec::new(),
68- completion_index: 0,
69- show_completions: false,
70- refocus_jq: false,
7162 cache_key: 0,
7263 }
7364 }
@@ -194,10 +185,7 @@ impl BrowserView {
194185 ui.add_space(4.0);
195186
196187 // --- Keyboard handling (only when jq input NOT focused) ---
197- let jq_id = ui.id().with("browser_jq_input");
198- let jq_has_focus = ui.ctx().memory(|m| {
199- m.focused().map_or(false, |f| f == jq_id)
200- });
188+ let jq_has_focus = crate::widgets::jq_bar::JqBar::has_focus(ui);
201189
202190 if !jq_has_focus {
203191 let action = crate::widgets::read_miller_keys(ui, false);
@@ -286,111 +274,23 @@ impl BrowserView {
286274 }
287275
288276 fn show_jq_bar(&mut self, ui: &mut Ui, root: &serde_json::Value) {
289- let mut run_jq = false;
290- let mut accepted_completion: Option<String> = None;
291-
292- ui.horizontal(|ui| {
293- ui.label(
294- RichText::new(egui_phosphor::regular::FUNNEL)
295- .color(CatppuccinMocha::MAUVE)
296- .size(14.0),
297- );
298-
299- let jq_id = ui.id().with("browser_jq_input");
300- let response = ui.add(
301- egui::TextEdit::singleline(&mut self.jq_input)
302- .id(jq_id)
303- .font(egui::FontId::monospace(14.0))
304- .desired_width(ui.available_width() - 10.0)
305- .text_color(CatppuccinMocha::GREEN),
306- );
307-
308- if self.refocus_jq {
309- response.request_focus();
310- if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), response.id) {
311- let ccursor = egui::text::CCursor::new(self.jq_input.len());
312- state
313- .cursor
314- .set_char_range(Some(egui::text::CCursorRange::one(ccursor)));
315- state.store(ui.ctx(), response.id);
316- }
317- self.refocus_jq = false;
318- }
319-
320- let has_focus = response.has_focus();
321-
322- if has_focus {
323- // Mark as manually edited
324- if response.changed() {
325- self.jq_synced = false;
326- self.jq_result = None;
327- self.jq_error = None;
328- self.rebuild_completions(root);
329- }
277+ let resp = self.jq_bar.show(ui, root);
330278
331- let tab =
332- ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Tab));
333- let ctrl_space =
334- ui.input_mut(|i| i.consume_key(egui::Modifiers::CTRL, egui::Key::Space));
335-
336- if tab && self.show_completions && !self.completions.is_empty() {
337- accepted_completion =
338- Some(self.completions[self.completion_index].clone());
339- }
340- if tab {
341- response.request_focus();
342- }
343-
344- if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
345- self.show_completions = false;
346- self.jq_synced = true;
347- self.sync_jq_from_path();
348- self.jq_result = None;
349- self.jq_error = None;
350- response.surrender_focus();
351- }
352-
353- if ctrl_space {
354- if self.show_completions {
355- self.show_completions = false;
356- } else {
357- self.rebuild_completions(root);
358- }
359- }
360-
361- // Enter: try to navigate to path, or run as jq query
362- if response.lost_focus()
363- && ui.input(|i| i.key_pressed(egui::Key::Enter))
364- {
365- run_jq = true;
366- }
279+ if resp.changed {
280+ self.jq_synced = false;
281+ self.jq_result = None;
282+ self.jq_error = None;
283+ }
367284
368- // Navigate completions
369- if self.show_completions && !self.completions.is_empty() {
370- let down = ui.input(|i| i.key_pressed(egui::Key::ArrowDown));
371- let up = ui.input(|i| i.key_pressed(egui::Key::ArrowUp));
372- if down {
373- self.completion_index =
374- (self.completion_index + 1).min(self.completions.len() - 1);
375- }
376- if up {
377- self.completion_index = self.completion_index.saturating_sub(1);
378- }
379- if ui.input(|i| i.key_pressed(egui::Key::Enter)) {
380- accepted_completion =
381- Some(self.completions[self.completion_index].clone());
382- }
383- }
384- }
385- });
285+ if resp.escaped {
286+ self.jq_synced = true;
287+ self.sync_jq_from_path();
288+ self.jq_result = None;
289+ self.jq_error = None;
290+ }
386291
387- // Apply completion
388- if let Some(comp) = accepted_completion {
389- self.apply_completion(&comp);
390- self.show_completions = false;
391- self.refocus_jq = true;
392- // Try to navigate to the completed path
393- if let Some(path) = jq_path_to_segments(&self.jq_input) {
292+ if resp.completion_applied {
293+ if let Some(path) = jq_path_to_segments(&self.jq_bar.query) {
394294 self.path = path;
395295 self.selection = 0;
396296 self.jq_synced = true;
@@ -399,68 +299,9 @@ impl BrowserView {
399299 }
400300 }
401301
402- // Show completion popup
403- if self.show_completions && !self.completions.is_empty() {
404- let mut clicked: Option<String> = None;
405- egui::Frame::new()
406- .fill(CatppuccinMocha::SURFACE0)
407- .inner_margin(6.0)
408- .corner_radius(4.0)
409- .stroke(egui::Stroke::new(1.0, CatppuccinMocha::SURFACE1))
410- .show(ui, |ui| {
411- ui.set_max_height(200.0);
412- egui::ScrollArea::vertical()
413- .id_salt("browser_completions")
414- .show(ui, |ui| {
415- for (i, comp) in self.completions.iter().enumerate() {
416- let selected = i == self.completion_index;
417- let text_color = if selected {
418- CatppuccinMocha::BLUE
419- } else {
420- CatppuccinMocha::TEXT
421- };
422- let bg = if selected {
423- CatppuccinMocha::SURFACE1
424- } else {
425- CatppuccinMocha::SURFACE0
426- };
427- let r = ui.add(
428- egui::Label::new(
429- RichText::new(comp)
430- .color(text_color)
431- .family(egui::FontFamily::Monospace)
432- .background_color(bg),
433- )
434- .sense(egui::Sense::click()),
435- );
436- if selected {
437- r.scroll_to_me(Some(egui::Align::Center));
438- }
439- if r.clicked() {
440- clicked = Some(comp.clone());
441- }
442- }
443- });
444- });
445- if let Some(c) = clicked {
446- self.apply_completion(&c);
447- self.show_completions = false;
448- self.refocus_jq = true;
449- if let Some(path) = jq_path_to_segments(&self.jq_input) {
450- self.path = path;
451- self.selection = 0;
452- self.jq_synced = true;
453- self.jq_result = None;
454- self.jq_error = None;
455- }
456- }
457- }
458-
459- // Execute jq query on Enter
460- if run_jq {
461- self.show_completions = false;
302+ if resp.run {
462303 // First try as navigation path
463- if let Some(path) = jq_path_to_segments(&self.jq_input) {
304+ if let Some(path) = jq_path_to_segments(&self.jq_bar.query) {
464305 if resolve_path(root, &path).is_some() {
465306 self.path = path;
466307 self.selection = 0;
@@ -472,7 +313,7 @@ impl BrowserView {
472313 }
473314 }
474315 // Otherwise run as jq query
475- let result = JqEngine::execute(&self.jq_input, root);
316+ let result = JqEngine::execute(&self.jq_bar.query, root);
476317 if let Some(err) = &result.error {
477318 self.jq_error = Some(err.clone());
478319 self.jq_result = None;
@@ -482,7 +323,7 @@ impl BrowserView {
482323 }
483324 }
484325
485- // Show jq error/result inline
326+ // Show jq error inline
486327 if let Some(err) = &self.jq_error {
487328 ui.label(
488329 RichText::new(err)
@@ -1032,49 +873,16 @@ impl BrowserView {
1032873
1033874 fn sync_jq_from_path(&mut self) {
1034875 if self.jq_synced {
1035- // Skip the first path segment (file name) for jq display
1036876 let jq_path = if self.path.len() > 1 {
1037877 &self.path[1..]
1038878 } else {
1039879 &[]
1040880 };
1041- self.jq_input = path_to_jq(jq_path);
881+ self.jq_bar.query = path_to_jq(jq_path);
1042882 self.jq_result = None;
1043883 self.jq_error = None;
1044884 }
1045885 }
1046-
1047- fn rebuild_completions(&mut self, root: &serde_json::Value) {
1048- let segment = extract_current_segment(&self.jq_input);
1049- let all_paths = collect_all_paths(root);
1050-
1051- let needle = segment.trim();
1052- if needle.is_empty() || needle == "." {
1053- self.completions = all_paths;
1054- } else {
1055- let needle_lower = needle.to_lowercase();
1056- let mut scored: Vec<(i64, String)> = all_paths
1057- .into_iter()
1058- .filter_map(|path| {
1059- let score = fuzzy_score(&needle_lower, &path.to_lowercase());
1060- if score > 0 {
1061- Some((score, path))
1062- } else {
1063- None
1064- }
1065- })
1066- .collect();
1067- scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
1068- self.completions = scored.into_iter().map(|(_, p)| p).collect();
1069- }
1070- self.completion_index = 0;
1071- self.show_completions = !self.completions.is_empty();
1072- }
1073-
1074- fn apply_completion(&mut self, completion: &str) {
1075- let (before, _) = split_at_current_segment(&self.jq_input);
1076- self.jq_input = format!("{}{}", before, completion);
1077- }
1078886 }
1079887
1080888 // --- Helper functions ---
@@ -1331,129 +1139,6 @@ fn colorize_json_token(s: &str) -> egui::Color32 {
13311139
13321140 // --- Reused from jq.rs ---
13331141
1334-fn collect_all_paths(root: &serde_json::Value) -> Vec<String> {
1335- let mut all_paths = Vec::new();
1336- match root {
1337- serde_json::Value::Object(map) => {
1338- for (key, val) in map {
1339- let path = format!(".{}", key);
1340- all_paths.push(path.clone());
1341- collect_deep_paths(val, &path, 6, &mut all_paths);
1342- }
1343- }
1344- serde_json::Value::Array(arr) => {
1345- all_paths.push(".[]".to_string());
1346- if let Some(first) = arr.first() {
1347- if first.is_object() {
1348- collect_deep_paths(first, ".[]", 6, &mut all_paths);
1349- }
1350- }
1351- }
1352- _ => {}
1353- }
1354- all_paths.sort();
1355- all_paths.dedup();
1356- all_paths
1357-}
1358-
1359-fn collect_deep_paths(
1360- value: &serde_json::Value,
1361- prefix: &str,
1362- max_depth: usize,
1363- out: &mut Vec<String>,
1364-) {
1365- if max_depth == 0 {
1366- return;
1367- }
1368- match value {
1369- serde_json::Value::Object(map) => {
1370- for (key, val) in map {
1371- let path = format!("{}.{}", prefix, key);
1372- out.push(path.clone());
1373- match val {
1374- serde_json::Value::Object(_) => {
1375- collect_deep_paths(val, &path, max_depth - 1, out);
1376- }
1377- serde_json::Value::Array(arr) => {
1378- let arr_path = format!("{}[]", path);
1379- out.push(arr_path.clone());
1380- if let Some(first) = arr.first() {
1381- if first.is_object() {
1382- collect_deep_paths(first, &arr_path, max_depth - 1, out);
1383- }
1384- }
1385- }
1386- _ => {}
1387- }
1388- }
1389- }
1390- serde_json::Value::Array(arr) => {
1391- let arr_path = format!("{}[]", prefix);
1392- out.push(arr_path.clone());
1393- if let Some(first) = arr.first() {
1394- if first.is_object() {
1395- collect_deep_paths(first, &arr_path, max_depth - 1, out);
1396- }
1397- }
1398- }
1399- _ => {}
1400- }
1401-}
1402-
1403-fn fuzzy_score(needle: &str, haystack: &str) -> i64 {
1404- let needle_chars: Vec<char> = needle.chars().collect();
1405- let haystack_chars: Vec<char> = haystack.chars().collect();
1406- if needle_chars.is_empty() {
1407- return 1;
1408- }
1409- if needle_chars.len() > haystack_chars.len() {
1410- return 0;
1411- }
1412- let mut score: i64 = 0;
1413- let mut ni = 0;
1414- let mut prev_match: Option<usize> = None;
1415- for (hi, &hc) in haystack_chars.iter().enumerate() {
1416- if ni < needle_chars.len() && hc == needle_chars[ni] {
1417- score += 1;
1418- if let Some(prev) = prev_match {
1419- if hi == prev + 1 {
1420- score += 2;
1421- }
1422- }
1423- if hi > 0 && matches!(haystack_chars[hi - 1], '.' | '[' | ']') {
1424- score += 1;
1425- }
1426- prev_match = Some(hi);
1427- ni += 1;
1428- }
1429- }
1430- if ni == needle_chars.len() {
1431- score
1432- } else {
1433- 0
1434- }
1435-}
1436-
1437-fn extract_current_segment(query: &str) -> &str {
1438- let start = query
1439- .rfind(|c: char| c == '|' || c == '(' || c == ';')
1440- .map(|i| i + 1)
1441- .unwrap_or(0);
1442- query[start..].trim_start()
1443-}
1444-
1445-fn split_at_current_segment(query: &str) -> (&str, &str) {
1446- let start = query
1447- .rfind(|c: char| c == '|' || c == '(' || c == ';')
1448- .map(|i| i + 1)
1449- .unwrap_or(0);
1450- let segment_start = query[start..]
1451- .find(|c: char| !c.is_whitespace())
1452- .map(|offset| start + offset)
1453- .unwrap_or(query.len());
1454- (&query[..segment_start], &query[segment_start..])
1455-}
1456-
14571142 // --- Smart preview helpers ---
14581143
14591144 enum ImageData {
+0-529src/views/jq.rs
@@ -1,529 +0,0 @@
1-use egui::{self, RichText, Ui};
2-
3-use crate::jq_engine::JqEngine;
4-use crate::theme::CatppuccinMocha;
5-
6-struct ResultLine {
7- text: String,
8- color: egui::Color32,
9-}
10-
11-pub struct JqView {
12- pub query: String,
13- pub history: Vec<String>,
14- previous_query: Option<String>,
15- last_executed_query: String,
16- cached_result: Option<crate::jq_engine::JqResult>,
17- cached_lines: Vec<ResultLine>,
18- pub completions: Vec<String>,
19- completion_index: usize,
20- show_completions: bool,
21- refocus: bool,
22-}
23-
24-impl JqView {
25- pub fn new() -> Self {
26- Self {
27- query: ".".to_string(),
28- history: Vec::new(),
29- previous_query: None,
30- last_executed_query: String::new(),
31- cached_result: None,
32- cached_lines: Vec::new(),
33- completions: Vec::new(),
34- completion_index: 0,
35- show_completions: false,
36- refocus: false,
37- }
38- }
39-
40- pub fn invalidate(&mut self) {
41- self.cached_result = None;
42- self.cached_lines.clear();
43- }
44-
45- pub fn show(&mut self, ui: &mut Ui, value: &serde_json::Value) {
46- // Auto-execute on first show or when query changes
47- if self.cached_result.is_none() || self.query != self.last_executed_query {
48- // Save previous query for Ctrl-L swap (skip initial load)
49- if !self.last_executed_query.is_empty() && self.last_executed_query != self.query {
50- self.previous_query = Some(self.last_executed_query.clone());
51- }
52- let result = JqEngine::execute(&self.query, value);
53- self.rebuild_result_lines(&result);
54- self.cached_result = Some(result);
55- self.last_executed_query = self.query.clone();
56- }
57-
58- let mut accepted_completion: Option<String> = None;
59-
60- ui.horizontal(|ui| {
61- ui.label(RichText::new("jq").color(CatppuccinMocha::MAUVE).strong());
62-
63- let response = ui.add(
64- egui::TextEdit::singleline(&mut self.query)
65- .font(egui::FontId::monospace(14.0))
66- .desired_width(ui.available_width() - 80.0)
67- .text_color(CatppuccinMocha::GREEN),
68- );
69-
70- // Re-focus and move cursor to end after completion was applied
71- if self.refocus {
72- response.request_focus();
73- if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), response.id) {
74- let ccursor = egui::text::CCursor::new(self.query.len());
75- state.cursor.set_char_range(Some(egui::text::CCursorRange::one(ccursor)));
76- state.store(ui.ctx(), response.id);
77- }
78- self.refocus = false;
79- }
80-
81- let has_focus = response.has_focus() || response.lost_focus();
82-
83- if has_focus {
84- // Consume keybindings only when jq input has focus
85- let tab = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Tab));
86- let ctrl_space = ui.ctx().input_mut(|i| i.consume_key(egui::Modifiers::CTRL, egui::Key::Space));
87- let ctrl_period = ui.ctx().input_mut(|i| i.consume_key(egui::Modifiers::CTRL, egui::Key::Period));
88- let ctrl_h = ui.ctx().input_mut(|i| i.consume_key(egui::Modifiers::CTRL, egui::Key::H));
89- let ctrl_l = ui.ctx().input_mut(|i| i.consume_key(egui::Modifiers::CTRL, egui::Key::L));
90-
91- if tab && self.show_completions && !self.completions.is_empty() {
92- accepted_completion =
93- Some(self.completions[self.completion_index].clone());
94- }
95- if tab {
96- response.request_focus();
97- }
98-
99- if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
100- self.show_completions = false;
101- }
102-
103- // Ctrl-Space / Ctrl-Period: toggle autocompletion
104- if ctrl_space || ctrl_period {
105- if self.show_completions {
106- self.show_completions = false;
107- } else {
108- self.rebuild_completions(value);
109- }
110- }
111-
112- // Ctrl-H: delete rightmost path segment
113- if ctrl_h {
114- if let Some(pos) = self.query.rfind(|c: char| c == '.' || c == '|') {
115- self.query.truncate(pos);
116- } else {
117- self.query.clear();
118- }
119- self.show_completions = false;
120- self.refocus = true;
121- }
122-
123- // Ctrl-L: swap with previous query
124- if ctrl_l {
125- if let Some(prev) = self.previous_query.take() {
126- let current = self.query.clone();
127- self.query = prev;
128- self.previous_query = Some(current);
129- }
130- self.refocus = true;
131- }
132-
133- if self.show_completions && !self.completions.is_empty() {
134- let down = ui.input(|i| i.key_pressed(egui::Key::ArrowDown))
135- || ui.input_mut(|i| i.consume_key(egui::Modifiers::CTRL, egui::Key::N));
136- let up = ui.input(|i| i.key_pressed(egui::Key::ArrowUp))
137- || ui.input_mut(|i| i.consume_key(egui::Modifiers::CTRL, egui::Key::P));
138- if down {
139- self.completion_index = (self.completion_index + 1)
140- .min(self.completions.len() - 1);
141- }
142- if up {
143- self.completion_index = self.completion_index.saturating_sub(1);
144- }
145- if ui.input(|i| i.key_pressed(egui::Key::Enter)) {
146- accepted_completion =
147- Some(self.completions[self.completion_index].clone());
148- }
149- }
150- }
151-
152- // Auto-show completions as user types
153- if response.changed() {
154- self.rebuild_completions(value);
155- }
156-
157- if ui.button("Run").clicked() {
158- self.show_completions = false;
159- let result = JqEngine::execute(&self.query, value);
160- self.rebuild_result_lines(&result);
161- self.cached_result = Some(result);
162- self.last_executed_query = self.query.clone();
163- if !self.query.is_empty() && !self.history.contains(&self.query) {
164- self.history.push(self.query.clone());
165- }
166- }
167- });
168-
169- // Apply accepted completion
170- if let Some(comp) = accepted_completion {
171- self.apply_completion(&comp);
172- self.show_completions = false;
173- self.refocus = true;
174- let result = JqEngine::execute(&self.query, value);
175- self.rebuild_result_lines(&result);
176- self.cached_result = Some(result);
177- self.last_executed_query = self.query.clone();
178- }
179-
180- // --- Completion popup ---
181- if self.show_completions && !self.completions.is_empty() {
182- let mut clicked: Option<String> = None;
183- egui::Frame::new()
184- .fill(CatppuccinMocha::SURFACE0)
185- .inner_margin(6.0)
186- .corner_radius(4.0)
187- .stroke(egui::Stroke::new(1.0, CatppuccinMocha::SURFACE1))
188- .show(ui, |ui| {
189- ui.set_max_height(200.0);
190- egui::ScrollArea::vertical()
191- .id_salt("jq_completions")
192- .show(ui, |ui| {
193- for (i, comp) in self.completions.iter().enumerate() {
194- let selected = i == self.completion_index;
195- let text_color = if selected {
196- CatppuccinMocha::BLUE
197- } else {
198- CatppuccinMocha::TEXT
199- };
200- let bg = if selected {
201- CatppuccinMocha::SURFACE1
202- } else {
203- CatppuccinMocha::SURFACE0
204- };
205- let r = ui.add(
206- egui::Label::new(
207- RichText::new(comp)
208- .color(text_color)
209- .family(egui::FontFamily::Monospace)
210- .background_color(bg),
211- )
212- .sense(egui::Sense::click()),
213- );
214- if selected {
215- r.scroll_to_me(Some(egui::Align::Center));
216- }
217- if r.clicked() {
218- clicked = Some(comp.clone());
219- }
220- }
221- });
222- });
223-
224- if let Some(c) = clicked {
225- self.apply_completion(&c);
226- self.show_completions = false;
227- let result = JqEngine::execute(&self.query, value);
228- self.rebuild_result_lines(&result);
229- self.cached_result = Some(result);
230- self.last_executed_query = self.query.clone();
231- }
232- }
233-
234- // --- History ---
235- if !self.history.is_empty() && !self.show_completions {
236- ui.horizontal(|ui| {
237- ui.label(
238- RichText::new("History:")
239- .color(CatppuccinMocha::SUBTEXT0)
240- .small(),
241- );
242- let mut clicked_history: Option<String> = None;
243- for q in self.history.iter().rev().take(10) {
244- if ui
245- .add(
246- egui::Label::new(
247- RichText::new(q)
248- .color(CatppuccinMocha::BLUE)
249- .small()
250- .family(egui::FontFamily::Monospace),
251- )
252- .sense(egui::Sense::click()),
253- )
254- .clicked()
255- {
256- clicked_history = Some(q.clone());
257- }
258- ui.label(
259- RichText::new("·").color(CatppuccinMocha::SURFACE2).small(),
260- );
261- }
262- if let Some(q) = clicked_history {
263- self.query = q;
264- let result = JqEngine::execute(&self.query, value);
265- self.rebuild_result_lines(&result);
266- self.cached_result = Some(result);
267- self.last_executed_query = self.query.clone();
268- }
269- });
270- }
271-
272- ui.add_space(8.0);
273-
274- // --- Results ---
275- if let Some(result) = &self.cached_result {
276- if let Some(err) = &result.error {
277- ui.label(
278- RichText::new(format!("Error: {}", err))
279- .color(CatppuccinMocha::RED)
280- .family(egui::FontFamily::Monospace),
281- );
282- }
283-
284- if !self.cached_lines.is_empty() {
285- let row_height = ui.text_style_height(&egui::TextStyle::Monospace) + 2.0;
286- let num_rows = self.cached_lines.len();
287-
288- ui.label(
289- RichText::new(format!("{} lines", num_rows))
290- .color(CatppuccinMocha::OVERLAY0)
291- .small(),
292- );
293-
294- egui::ScrollArea::vertical()
295- .id_salt("jq_results")
296- .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysVisible)
297- .auto_shrink(false)
298- .show_rows(ui, row_height, num_rows, |ui, row_range| {
299- for row in row_range {
300- let line = &self.cached_lines[row];
301- ui.label(
302- RichText::new(&line.text)
303- .color(line.color)
304- .family(egui::FontFamily::Monospace),
305- );
306- }
307- });
308- }
309- }
310- }
311-
312- /// Pre-flatten jq result output into colored lines for virtual scrolling.
313- fn rebuild_result_lines(&mut self, result: &crate::jq_engine::JqResult) {
314- self.cached_lines.clear();
315- for (i, output) in result.output.iter().enumerate() {
316- if i > 0 {
317- self.cached_lines.push(ResultLine {
318- text: "───".to_string(),
319- color: CatppuccinMocha::SURFACE2,
320- });
321- }
322- if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(output) {
323- let pretty = serde_json::to_string_pretty(&parsed).unwrap_or_default();
324- for line in pretty.lines() {
325- let (color, text) = colorize_json_line(line);
326- self.cached_lines.push(ResultLine { text, color });
327- }
328- } else {
329- self.cached_lines.push(ResultLine {
330- text: output.clone(),
331- color: CatppuccinMocha::TEXT,
332- });
333- }
334- }
335- }
336-
337- /// Rebuild completions based on current query.
338- /// Collect all valid jq paths from root value.
339- fn collect_all_paths(root: &serde_json::Value) -> Vec<String> {
340- let mut all_paths = Vec::new();
341- match root {
342- serde_json::Value::Object(map) => {
343- for (key, val) in map {
344- let path = format!(".{}", key);
345- all_paths.push(path.clone());
346- collect_deep_paths(val, &path, 6, &mut all_paths);
347- }
348- }
349- serde_json::Value::Array(arr) => {
350- all_paths.push(".[]".to_string());
351- if let Some(first) = arr.first() {
352- if first.is_object() {
353- collect_deep_paths(first, ".[]", 6, &mut all_paths);
354- }
355- }
356- }
357- _ => {}
358- }
359- all_paths.sort();
360- all_paths.dedup();
361- all_paths
362- }
363-
364- /// Rebuild completions using fuzzy matching against all possible paths.
365- pub fn rebuild_completions(&mut self, root: &serde_json::Value) {
366- let segment = extract_current_segment(&self.query);
367- let all_paths = Self::collect_all_paths(root);
368-
369- let needle = segment.trim();
370- if needle.is_empty() || needle == "." {
371- // Show all paths
372- self.completions = all_paths;
373- } else {
374- // Fuzzy filter: every char in needle must appear in order in the path
375- let needle_lower = needle.to_lowercase();
376- let mut scored: Vec<(i64, String)> = all_paths
377- .into_iter()
378- .filter_map(|path| {
379- let score = fuzzy_score(&needle_lower, &path.to_lowercase());
380- if score > 0 { Some((score, path)) } else { None }
381- })
382- .collect();
383- scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
384- self.completions = scored.into_iter().map(|(_, p)| p).collect();
385- }
386-
387- self.completion_index = 0;
388- self.show_completions = !self.completions.is_empty();
389- }
390-
391- /// Replace the current segment of the query with the completion.
392- pub fn apply_completion(&mut self, completion: &str) {
393- let (before, _) = split_at_current_segment(&self.query);
394- self.query = format!("{}{}", before, completion);
395- }
396-}
397-
398-/// Recursively collect all dot-paths from a JSON value.
399-fn collect_deep_paths(
400- value: &serde_json::Value,
401- prefix: &str,
402- max_depth: usize,
403- out: &mut Vec<String>,
404-) {
405- if max_depth == 0 {
406- return;
407- }
408- match value {
409- serde_json::Value::Object(map) => {
410- for (key, val) in map {
411- let path = format!("{}.{}", prefix, key);
412- out.push(path.clone());
413- match val {
414- serde_json::Value::Object(_) => {
415- collect_deep_paths(val, &path, max_depth - 1, out);
416- }
417- serde_json::Value::Array(arr) => {
418- let arr_path = format!("{}[]", path);
419- out.push(arr_path.clone());
420- if let Some(first) = arr.first() {
421- if first.is_object() {
422- collect_deep_paths(first, &arr_path, max_depth - 1, out);
423- }
424- }
425- }
426- _ => {}
427- }
428- }
429- }
430- serde_json::Value::Array(arr) => {
431- let arr_path = format!("{}[]", prefix);
432- out.push(arr_path.clone());
433- if let Some(first) = arr.first() {
434- if first.is_object() {
435- collect_deep_paths(first, &arr_path, max_depth - 1, out);
436- }
437- }
438- }
439- _ => {}
440- }
441-}
442-
443-/// Fuzzy match: returns a score > 0 if all chars of needle appear in haystack in order.
444-/// Higher score = better match (consecutive chars, earlier positions).
445-fn fuzzy_score(needle: &str, haystack: &str) -> i64 {
446- let needle_chars: Vec<char> = needle.chars().collect();
447- let haystack_chars: Vec<char> = haystack.chars().collect();
448-
449- if needle_chars.is_empty() {
450- return 1;
451- }
452- if needle_chars.len() > haystack_chars.len() {
453- return 0;
454- }
455-
456- let mut score: i64 = 0;
457- let mut ni = 0;
458- let mut prev_match: Option<usize> = None;
459-
460- for (hi, &hc) in haystack_chars.iter().enumerate() {
461- if ni < needle_chars.len() && hc == needle_chars[ni] {
462- score += 1;
463- // Bonus for consecutive matches
464- if let Some(prev) = prev_match {
465- if hi == prev + 1 {
466- score += 2;
467- }
468- }
469- // Bonus for matching after separator (., [, ])
470- if hi > 0 && matches!(haystack_chars[hi - 1], '.' | '[' | ']') {
471- score += 1;
472- }
473- prev_match = Some(hi);
474- ni += 1;
475- }
476- }
477-
478- if ni == needle_chars.len() { score } else { 0 }
479-}
480-
481-/// Extract the current jq segment being typed (after last `|`, `(`, `;`).
482-pub fn extract_current_segment(query: &str) -> &str {
483- let start = query
484- .rfind(|c: char| c == '|' || c == '(' || c == ';')
485- .map(|i| i + 1)
486- .unwrap_or(0);
487- query[start..].trim_start()
488-}
489-
490-/// Split query into (everything before current segment, current segment).
491-pub fn split_at_current_segment(query: &str) -> (&str, &str) {
492- let start = query
493- .rfind(|c: char| c == '|' || c == '(' || c == ';')
494- .map(|i| i + 1)
495- .unwrap_or(0);
496-
497- // Include any whitespace after the delimiter in "before"
498- let segment_start = query[start..]
499- .find(|c: char| !c.is_whitespace())
500- .map(|offset| start + offset)
501- .unwrap_or(query.len());
502-
503- (&query[..segment_start], &query[segment_start..])
504-}
505-
506-fn colorize_json_line(line: &str) -> (egui::Color32, String) {
507- let trimmed = line.trim();
508- let color = if trimmed.starts_with('"') {
509- if trimmed.contains(':') {
510- CatppuccinMocha::BLUE
511- } else {
512- CatppuccinMocha::GREEN
513- }
514- } else if trimmed == "null" || trimmed == "null," {
515- CatppuccinMocha::OVERLAY0
516- } else if trimmed == "true"
517- || trimmed == "true,"
518- || trimmed == "false"
519- || trimmed == "false,"
520- {
521- CatppuccinMocha::MAUVE
522- } else if trimmed.starts_with(|c: char| c.is_ascii_digit() || c == '-') {
523- CatppuccinMocha::PEACH
524- } else {
525- CatppuccinMocha::TEXT
526- };
527-
528- (color, line.to_string())
529-}
+0-1src/views/mod.rs
@@ -1,7 +1,6 @@
11 pub mod table;
22 pub mod json;
33 pub mod code;
4-pub mod jq;
54 pub mod browser;
65 pub mod schema_diagram;
76 pub mod shared_browser;
+355-0src/widgets/jq_bar.rs
@@ -0,0 +1,355 @@
1+use egui::{self, RichText, Ui};
2+
3+use crate::theme::CatppuccinMocha;
4+
5+/// Response from the jq bar — tells the caller what happened this frame.
6+pub struct JqBarResponse {
7+ /// Enter was pressed (caller should execute or navigate)
8+ pub run: bool,
9+ /// Escape was pressed (caller should cancel/reset)
10+ pub escaped: bool,
11+ /// Query text was edited by the user
12+ pub changed: bool,
13+ /// A completion was accepted (Tab or click)
14+ pub completion_applied: bool,
15+}
16+
17+/// Reusable jq filter bar with fuzzy autocompletion.
18+pub struct JqBar {
19+ pub query: String,
20+ pub completions: Vec<String>,
21+ completion_index: usize,
22+ show_completions: bool,
23+ refocus: bool,
24+}
25+
26+impl JqBar {
27+ pub fn new() -> Self {
28+ Self {
29+ query: ".".to_string(),
30+ completions: Vec::new(),
31+ completion_index: 0,
32+ show_completions: false,
33+ refocus: false,
34+ }
35+ }
36+
37+ /// The egui Id used for the text input (for focus checks).
38+ pub fn input_id(ui: &Ui) -> egui::Id {
39+ ui.id().with("jq_bar_input")
40+ }
41+
42+ /// Returns true if the jq bar input currently has focus.
43+ pub fn has_focus(ui: &Ui) -> bool {
44+ let id = Self::input_id(ui);
45+ ui.ctx().memory(|m| m.focused().map_or(false, |f| f == id))
46+ }
47+
48+ /// Render the jq bar. Caller provides `root` for autocompletion.
49+ pub fn show(&mut self, ui: &mut Ui, root: &serde_json::Value) -> JqBarResponse {
50+ let mut response = JqBarResponse {
51+ run: false,
52+ escaped: false,
53+ changed: false,
54+ completion_applied: false,
55+ };
56+
57+ let mut accepted_completion: Option<String> = None;
58+
59+ ui.horizontal(|ui| {
60+ ui.label(
61+ RichText::new(egui_phosphor::regular::FUNNEL)
62+ .color(CatppuccinMocha::MAUVE)
63+ .size(14.0),
64+ );
65+
66+ let input_id = Self::input_id(ui);
67+ let text_response = ui.add(
68+ egui::TextEdit::singleline(&mut self.query)
69+ .id(input_id)
70+ .font(egui::FontId::monospace(14.0))
71+ .desired_width(ui.available_width() - 10.0)
72+ .text_color(CatppuccinMocha::GREEN),
73+ );
74+
75+ if self.refocus {
76+ text_response.request_focus();
77+ if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), text_response.id) {
78+ let ccursor = egui::text::CCursor::new(self.query.len());
79+ state
80+ .cursor
81+ .set_char_range(Some(egui::text::CCursorRange::one(ccursor)));
82+ state.store(ui.ctx(), text_response.id);
83+ }
84+ self.refocus = false;
85+ }
86+
87+ let has_focus = text_response.has_focus();
88+
89+ if has_focus {
90+ if text_response.changed() {
91+ response.changed = true;
92+ self.rebuild_completions(root);
93+ }
94+
95+ let tab = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Tab));
96+ let ctrl_space =
97+ ui.input_mut(|i| i.consume_key(egui::Modifiers::CTRL, egui::Key::Space));
98+
99+ if tab && self.show_completions && !self.completions.is_empty() {
100+ accepted_completion = Some(self.completions[self.completion_index].clone());
101+ }
102+ if tab {
103+ text_response.request_focus();
104+ }
105+
106+ if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
107+ self.show_completions = false;
108+ response.escaped = true;
109+ text_response.surrender_focus();
110+ }
111+
112+ if ctrl_space {
113+ if self.show_completions {
114+ self.show_completions = false;
115+ } else {
116+ self.rebuild_completions(root);
117+ }
118+ }
119+
120+ // Enter: signal caller to execute/navigate
121+ if text_response.lost_focus()
122+ && ui.input(|i| i.key_pressed(egui::Key::Enter))
123+ {
124+ response.run = true;
125+ }
126+
127+ // Navigate completions
128+ if self.show_completions && !self.completions.is_empty() {
129+ let down = ui.input(|i| i.key_pressed(egui::Key::ArrowDown));
130+ let up = ui.input(|i| i.key_pressed(egui::Key::ArrowUp));
131+ if down {
132+ self.completion_index =
133+ (self.completion_index + 1).min(self.completions.len() - 1);
134+ }
135+ if up {
136+ self.completion_index = self.completion_index.saturating_sub(1);
137+ }
138+ if ui.input(|i| i.key_pressed(egui::Key::Enter)) {
139+ accepted_completion = Some(self.completions[self.completion_index].clone());
140+ }
141+ }
142+ }
143+ });
144+
145+ // Apply completion
146+ if let Some(comp) = accepted_completion {
147+ self.apply_completion(&comp);
148+ self.show_completions = false;
149+ self.refocus = true;
150+ response.completion_applied = true;
151+ }
152+
153+ // Show completion popup
154+ if self.show_completions && !self.completions.is_empty() {
155+ let mut clicked: Option<String> = None;
156+ egui::Frame::new()
157+ .fill(CatppuccinMocha::SURFACE0)
158+ .inner_margin(6.0)
159+ .corner_radius(4.0)
160+ .stroke(egui::Stroke::new(1.0, CatppuccinMocha::SURFACE1))
161+ .show(ui, |ui| {
162+ ui.set_max_height(200.0);
163+ egui::ScrollArea::vertical()
164+ .id_salt("jq_bar_completions")
165+ .show(ui, |ui| {
166+ for (i, comp) in self.completions.iter().enumerate() {
167+ let selected = i == self.completion_index;
168+ let text_color = if selected {
169+ CatppuccinMocha::BLUE
170+ } else {
171+ CatppuccinMocha::TEXT
172+ };
173+ let bg = if selected {
174+ CatppuccinMocha::SURFACE1
175+ } else {
176+ CatppuccinMocha::SURFACE0
177+ };
178+ let r = ui.add(
179+ egui::Label::new(
180+ RichText::new(comp)
181+ .color(text_color)
182+ .family(egui::FontFamily::Monospace)
183+ .background_color(bg),
184+ )
185+ .sense(egui::Sense::click()),
186+ );
187+ if selected {
188+ r.scroll_to_me(Some(egui::Align::Center));
189+ }
190+ if r.clicked() {
191+ clicked = Some(comp.clone());
192+ }
193+ }
194+ });
195+ });
196+ if let Some(c) = clicked {
197+ self.apply_completion(&c);
198+ self.show_completions = false;
199+ self.refocus = true;
200+ response.completion_applied = true;
201+ }
202+ }
203+
204+ response
205+ }
206+
207+ pub fn rebuild_completions(&mut self, root: &serde_json::Value) {
208+ let segment = extract_current_segment(&self.query);
209+ let all_paths = collect_all_paths(root);
210+
211+ let needle = segment.trim();
212+ if needle.is_empty() || needle == "." {
213+ self.completions = all_paths;
214+ } else {
215+ let needle_lower = needle.to_lowercase();
216+ let mut scored: Vec<(i64, String)> = all_paths
217+ .into_iter()
218+ .filter_map(|path| {
219+ let score = fuzzy_score(&needle_lower, &path.to_lowercase());
220+ if score > 0 { Some((score, path)) } else { None }
221+ })
222+ .collect();
223+ scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
224+ self.completions = scored.into_iter().map(|(_, p)| p).collect();
225+ }
226+ self.completion_index = 0;
227+ self.show_completions = !self.completions.is_empty();
228+ }
229+
230+ pub fn apply_completion(&mut self, completion: &str) {
231+ let (before, _) = split_at_current_segment(&self.query);
232+ self.query = format!("{}{}", before, completion);
233+ }
234+}
235+
236+// --- Helper functions ---
237+
238+fn collect_all_paths(root: &serde_json::Value) -> Vec<String> {
239+ let mut all_paths = Vec::new();
240+ match root {
241+ serde_json::Value::Object(map) => {
242+ for (key, val) in map {
243+ let path = format!(".{}", key);
244+ all_paths.push(path.clone());
245+ collect_deep_paths(val, &path, 6, &mut all_paths);
246+ }
247+ }
248+ serde_json::Value::Array(arr) => {
249+ all_paths.push(".[]".to_string());
250+ if let Some(first) = arr.first() {
251+ if first.is_object() {
252+ collect_deep_paths(first, ".[]", 6, &mut all_paths);
253+ }
254+ }
255+ }
256+ _ => {}
257+ }
258+ all_paths.sort();
259+ all_paths.dedup();
260+ all_paths
261+}
262+
263+fn collect_deep_paths(
264+ value: &serde_json::Value,
265+ prefix: &str,
266+ max_depth: usize,
267+ out: &mut Vec<String>,
268+) {
269+ if max_depth == 0 {
270+ return;
271+ }
272+ match value {
273+ serde_json::Value::Object(map) => {
274+ for (key, val) in map {
275+ let path = format!("{}.{}", prefix, key);
276+ out.push(path.clone());
277+ match val {
278+ serde_json::Value::Object(_) => {
279+ collect_deep_paths(val, &path, max_depth - 1, out);
280+ }
281+ serde_json::Value::Array(arr) => {
282+ let arr_path = format!("{}[]", path);
283+ out.push(arr_path.clone());
284+ if let Some(first) = arr.first() {
285+ if first.is_object() {
286+ collect_deep_paths(first, &arr_path, max_depth - 1, out);
287+ }
288+ }
289+ }
290+ _ => {}
291+ }
292+ }
293+ }
294+ serde_json::Value::Array(arr) => {
295+ let arr_path = format!("{}[]", prefix);
296+ out.push(arr_path.clone());
297+ if let Some(first) = arr.first() {
298+ if first.is_object() {
299+ collect_deep_paths(first, &arr_path, max_depth - 1, out);
300+ }
301+ }
302+ }
303+ _ => {}
304+ }
305+}
306+
307+fn fuzzy_score(needle: &str, haystack: &str) -> i64 {
308+ let needle_chars: Vec<char> = needle.chars().collect();
309+ let haystack_chars: Vec<char> = haystack.chars().collect();
310+ if needle_chars.is_empty() {
311+ return 1;
312+ }
313+ if needle_chars.len() > haystack_chars.len() {
314+ return 0;
315+ }
316+ let mut score: i64 = 0;
317+ let mut ni = 0;
318+ let mut prev_match: Option<usize> = None;
319+ for (hi, &hc) in haystack_chars.iter().enumerate() {
320+ if ni < needle_chars.len() && hc == needle_chars[ni] {
321+ score += 1;
322+ if let Some(prev) = prev_match {
323+ if hi == prev + 1 {
324+ score += 2;
325+ }
326+ }
327+ if hi > 0 && matches!(haystack_chars[hi - 1], '.' | '[' | ']') {
328+ score += 1;
329+ }
330+ prev_match = Some(hi);
331+ ni += 1;
332+ }
333+ }
334+ if ni == needle_chars.len() { score } else { 0 }
335+}
336+
337+pub fn extract_current_segment(query: &str) -> &str {
338+ let start = query
339+ .rfind(|c: char| c == '|' || c == '(' || c == ';')
340+ .map(|i| i + 1)
341+ .unwrap_or(0);
342+ query[start..].trim_start()
343+}
344+
345+pub fn split_at_current_segment(query: &str) -> (&str, &str) {
346+ let start = query
347+ .rfind(|c: char| c == '|' || c == '(' || c == ';')
348+ .map(|i| i + 1)
349+ .unwrap_or(0);
350+ let segment_start = query[start..]
351+ .find(|c: char| !c.is_whitespace())
352+ .map(|offset| start + offset)
353+ .unwrap_or(query.len());
354+ (&query[..segment_start], &query[segment_start..])
355+}
+1-0src/widgets/mod.rs
@@ -1,5 +1,6 @@
11 pub mod cache_guard;
22 pub mod hover_row;
3+pub mod jq_bar;
34 pub mod miller;
45
56 pub use cache_guard::{CacheGuard, hash_key};
+7-7tests/jq_view_tests.rs
@@ -1,4 +1,4 @@
1-use jv::views::jq::{extract_current_segment, split_at_current_segment, JqView};
1+use jv::widgets::jq_bar::{extract_current_segment, split_at_current_segment, JqBar};
22
33 #[test]
44 fn test_extract_segment_simple() {
@@ -28,7 +28,7 @@ fn test_split_at_segment_simple() {
2828 #[test]
2929 fn test_completions_from_root_dot() {
3030 let root: serde_json::Value = serde_json::json!({"users": [], "metadata": {}});
31- let mut view = JqView::new();
31+ let mut view = JqBar::new();
3232 view.query = ".".to_string();
3333 view.rebuild_completions(&root);
3434 assert!(!view.completions.is_empty(), "rebuild: completions should not be empty for '.'");
@@ -38,7 +38,7 @@ fn test_completions_from_root_dot() {
3838 #[test]
3939 fn test_completions_from_empty() {
4040 let root: serde_json::Value = serde_json::json!({"foo": 1, "bar": 2});
41- let mut view = JqView::new();
41+ let mut view = JqBar::new();
4242 view.query = String::new();
4343 view.rebuild_completions(&root);
4444 assert!(!view.completions.is_empty(), "rebuild: completions should not be empty for empty query");
@@ -47,7 +47,7 @@ fn test_completions_from_empty() {
4747 #[test]
4848 fn test_apply_completion_from_dot() {
4949 let root: serde_json::Value = serde_json::json!({"users": [{"id": 1}]});
50- let mut view = JqView::new();
50+ let mut view = JqBar::new();
5151 view.query = ".".to_string();
5252 view.rebuild_completions(&root);
5353 assert!(!view.completions.is_empty());
@@ -59,7 +59,7 @@ fn test_apply_completion_from_dot() {
5959 #[test]
6060 fn test_completions_all_start_with_dot() {
6161 let root: serde_json::Value = serde_json::json!({"users": [{"id": 1}], "metadata": {"version": "1.0"}});
62- let mut view = JqView::new();
62+ let mut view = JqBar::new();
6363 view.query = ".".to_string();
6464 view.rebuild_completions(&root);
6565 assert!(!view.completions.is_empty());
@@ -71,7 +71,7 @@ fn test_completions_all_start_with_dot() {
7171 #[test]
7272 fn test_completions_array_root() {
7373 let root: serde_json::Value = serde_json::json!([{"alternate2": "val", "name": "test"}]);
74- let mut view = JqView::new();
74+ let mut view = JqBar::new();
7575 view.query = ".".to_string();
7676 view.rebuild_completions(&root);
7777 assert!(!view.completions.is_empty());
@@ -85,7 +85,7 @@ fn test_completions_array_root() {
8585 #[test]
8686 fn test_fuzzy_search() {
8787 let root: serde_json::Value = serde_json::json!({"users": [{"id": 1, "username": "test"}], "metadata": {}});
88- let mut view = JqView::new();
88+ let mut view = JqBar::new();
8989 view.query = ".usrn".to_string(); // fuzzy for "username"
9090 view.rebuild_completions(&root);
9191 assert!(view.completions.iter().any(|c| c.contains("username")),