fix: focus view stars and data
2 files changed, +96 -37
+6-1src/app.rs
| @@ -715,8 +715,13 @@ impl JvApp { | ||
| 715 | 715 | // Mode switcher |
| 716 | 716 | ui.horizontal(|ui| { |
| 717 | 717 | ui.spacing_mut().item_spacing.x = 4.0; |
| 718 | + let jv_icon = if self.browser_view.focus_mode { | |
| 719 | + regular::STAR | |
| 720 | + } else { | |
| 721 | + regular::BROWSERS | |
| 722 | + }; | |
| 718 | 723 | for (mode, label, icon) in [ |
| 719 | - (AppMode::Jv, "jv", regular::BROWSERS), | |
| 724 | + (AppMode::Jv, "jv", jv_icon), | |
| 720 | 725 | (AppMode::Groups, "Groups", regular::TREE_STRUCTURE), |
| 721 | 726 | (AppMode::Schema, "Schema", regular::GRAPH), |
| 722 | 727 | (AppMode::Code, "Code", regular::CODE), |
+90-36src/views/browser.rs
| @@ -53,7 +53,7 @@ pub struct BrowserView { | ||
| 53 | 53 | cache_key: u64, |
| 54 | 54 | // Focus list: full paths to pinned entries (e.g. ["activity_Aug.responseData.status"]) |
| 55 | 55 | focused: Vec<Vec<PathSegment>>, |
| 56 | - focus_mode: bool, | |
| 56 | + pub focus_mode: bool, | |
| 57 | 57 | focus_dirty: bool, |
| 58 | 58 | } |
| 59 | 59 | |
| @@ -177,10 +177,7 @@ impl BrowserView { | ||
| 177 | 177 | let (current_entries, parent_entries) = if in_focus_root { |
| 178 | 178 | // Focus mode root: show pinned items as the root list |
| 179 | 179 | let entries: Vec<Entry> = self.focused.iter().map(|fp| { |
| 180 | - let label = fp.iter().map(|seg| match seg { | |
| 181 | - PathSegment::Key(k) => k.clone(), | |
| 182 | - PathSegment::Index(i) => format!("[{}]", i), | |
| 183 | - }).collect::<Vec<_>>().join("."); | |
| 180 | + let label = focus_path_label(fp); | |
| 184 | 181 | let value = resolve_from_files(files, fp); |
| 185 | 182 | let (icon, color) = value.map(type_icon_color).unwrap_or(("{}", CatppuccinMocha::OVERLAY0)); |
| 186 | 183 | let preview = value.map(value_preview).unwrap_or_default(); |
| @@ -238,19 +235,9 @@ impl BrowserView { | ||
| 238 | 235 | } |
| 239 | 236 | |
| 240 | 237 | let selected_child = if in_focus_root { |
| 241 | - // Focus root — find the focused path matching the selected entry's label | |
| 242 | - current_entries.get(self.selection) | |
| 243 | - .and_then(|entry| { | |
| 244 | - self.focused.iter() | |
| 245 | - .find(|fp| { | |
| 246 | - let label = fp.iter().map(|seg| match seg { | |
| 247 | - PathSegment::Key(k) => k.clone(), | |
| 248 | - PathSegment::Index(i) => format!("[{}]", i), | |
| 249 | - }).collect::<Vec<_>>().join("."); | |
| 250 | - label == entry.label | |
| 251 | - }) | |
| 252 | - .and_then(|fp| resolve_from_files(files, fp)) | |
| 253 | - }) | |
| 238 | + // Focus root — entry index maps directly to self.focused index | |
| 239 | + self.focused.get(self.selection) | |
| 240 | + .and_then(|fp| resolve_from_files(files, fp)) | |
| 254 | 241 | } else if let Some(cv) = current_value { |
| 255 | 242 | // Inside a file — look up child by key/index |
| 256 | 243 | current_entries.get(self.selection) |
| @@ -317,15 +304,14 @@ impl BrowserView { | ||
| 317 | 304 | if entry.is_container { |
| 318 | 305 | self.filter.query.clear(); |
| 319 | 306 | if in_focus_root { |
| 320 | - // Enter a focused item: set path to its full path | |
| 321 | - if let Some(fp) = self.focused.get(self.selection) { | |
| 322 | - self.path = fp.clone(); | |
| 307 | + if let Some(fp) = self.focused.get(self.selection).cloned() { | |
| 308 | + self.path = fp; | |
| 323 | 309 | self.selection = 0; |
| 324 | 310 | self.jq_synced = true; |
| 325 | 311 | self.sync_jq_from_path(); |
| 326 | 312 | } |
| 327 | 313 | } else if self.path.is_empty() { |
| 328 | - self.path.push(PathSegment::Key(entry.label.clone())); | |
| 314 | + self.path.push(label_to_segment(&entry.label)); | |
| 329 | 315 | self.selection = 0; |
| 330 | 316 | self.jq_synced = true; |
| 331 | 317 | self.sync_jq_from_path(); |
| @@ -358,11 +344,22 @@ impl BrowserView { | ||
| 358 | 344 | // 'f' toggles focus on selected entry (stores full path) |
| 359 | 345 | if ui.input(|i| i.key_pressed(egui::Key::F) && !i.modifiers.shift) { |
| 360 | 346 | if let Some(entry) = current_entries.get(self.selection) { |
| 361 | - let mut full_path = self.path.clone(); | |
| 362 | - full_path.push(PathSegment::Key(entry.label.clone())); | |
| 363 | - if let Some(pos) = self.focused.iter().position(|p| *p == full_path) { | |
| 364 | - self.focused.remove(pos); | |
| 347 | + // Find by label match (works at focus root) or by path (works at any depth) | |
| 348 | + let pos = if in_focus_root { | |
| 349 | + // At focus root, selection index = focused index | |
| 350 | + Some(self.selection) | |
| 365 | 351 | } else { |
| 352 | + // At depth, match by full path | |
| 353 | + let mut check = self.path.clone(); | |
| 354 | + check.push(label_to_segment(&entry.label)); | |
| 355 | + self.focused.iter().position(|fp| *fp == check) | |
| 356 | + }; | |
| 357 | + if let Some(pos) = pos { | |
| 358 | + self.focused.remove(pos); | |
| 359 | + } else if !in_focus_root { | |
| 360 | + // Only add when NOT at focus root (focus root entries are already focused) | |
| 361 | + let mut full_path = self.path.clone(); | |
| 362 | + full_path.push(label_to_segment(&entry.label)); | |
| 366 | 363 | self.focused.push(full_path); |
| 367 | 364 | } |
| 368 | 365 | self.focus_dirty = true; |
| @@ -466,14 +463,14 @@ impl BrowserView { | ||
| 466 | 463 | if entry.is_container { |
| 467 | 464 | self.filter.query.clear(); |
| 468 | 465 | if in_focus_root { |
| 469 | - if let Some(fp) = self.focused.get(self.selection) { | |
| 470 | - self.path = fp.clone(); | |
| 466 | + if let Some(fp) = self.focused.get(self.selection).cloned() { | |
| 467 | + self.path = fp; | |
| 471 | 468 | self.selection = 0; |
| 472 | 469 | self.jq_synced = true; |
| 473 | 470 | self.sync_jq_from_path(); |
| 474 | 471 | } |
| 475 | 472 | } else if self.path.is_empty() { |
| 476 | - self.path.push(PathSegment::Key(entry.label.clone())); | |
| 473 | + self.path.push(label_to_segment(&entry.label)); | |
| 477 | 474 | self.selection = 0; |
| 478 | 475 | self.jq_synced = true; |
| 479 | 476 | self.sync_jq_from_path(); |
| @@ -517,14 +514,14 @@ impl BrowserView { | ||
| 517 | 514 | if let Some(entry) = current_entries.get(idx) { |
| 518 | 515 | if entry.is_container { |
| 519 | 516 | if in_focus_root { |
| 520 | - if let Some(fp) = self.focused.get(idx) { | |
| 521 | - self.path = fp.clone(); | |
| 517 | + if let Some(fp) = self.focused.get(idx).cloned() { | |
| 518 | + self.path = fp; | |
| 522 | 519 | self.selection = 0; |
| 523 | 520 | self.jq_synced = true; |
| 524 | 521 | self.sync_jq_from_path(); |
| 525 | 522 | } |
| 526 | 523 | } else if self.path.is_empty() { |
| 527 | - self.path.push(PathSegment::Key(entry.label.clone())); | |
| 524 | + self.path.push(label_to_segment(&entry.label)); | |
| 528 | 525 | self.selection = 0; |
| 529 | 526 | self.jq_synced = true; |
| 530 | 527 | self.sync_jq_from_path(); |
| @@ -718,6 +715,7 @@ impl BrowserView { | ||
| 718 | 715 | let mut dbl_clicked: Option<usize> = None; |
| 719 | 716 | let current_path = self.path.clone(); |
| 720 | 717 | let focused = self.focused.clone(); |
| 718 | + let is_focus_root = self.focus_mode && self.path.is_empty(); | |
| 721 | 719 | |
| 722 | 720 | // Map original selection index → filtered list position |
| 723 | 721 | let filtered_pos = entries.iter().position(|(orig, _)| *orig == self.selection) |
| @@ -789,9 +787,14 @@ impl BrowserView { | ||
| 789 | 787 | ui.ctx().data_mut(|d| d.insert_temp(copy_btn_id, icon_rect)); |
| 790 | 788 | |
| 791 | 789 | // Focus indicator |
| 792 | - let mut entry_path = current_path.clone(); | |
| 793 | - entry_path.push(PathSegment::Key(entry.label.clone())); | |
| 794 | - if focused.iter().any(|p| *p == entry_path) { | |
| 790 | + let is_focused = if is_focus_root { | |
| 791 | + true // all entries at focus root are focused | |
| 792 | + } else { | |
| 793 | + let mut entry_path = current_path.clone(); | |
| 794 | + entry_path.push(label_to_segment(&entry.label)); | |
| 795 | + focused.iter().any(|p| *p == entry_path) | |
| 796 | + }; | |
| 797 | + if is_focused { | |
| 795 | 798 | ui.label( |
| 796 | 799 | RichText::new(egui_phosphor::regular::STAR) |
| 797 | 800 | .color(CatppuccinMocha::YELLOW) |
| @@ -1152,7 +1155,7 @@ impl BrowserView { | ||
| 1152 | 1155 | } |
| 1153 | 1156 | match current { |
| 1154 | 1157 | serde_json::Value::Object(_) => { |
| 1155 | - self.path.push(PathSegment::Key(entry.label.clone())); | |
| 1158 | + self.path.push(label_to_segment(&entry.label)); | |
| 1156 | 1159 | } |
| 1157 | 1160 | serde_json::Value::Array(_) => { |
| 1158 | 1161 | self.path.push(PathSegment::Index(self.selection)); |
| @@ -1200,6 +1203,57 @@ impl BrowserView { | ||
| 1200 | 1203 | |
| 1201 | 1204 | /// Resolve a path starting from the file list. path[0] is a file display name, |
| 1202 | 1205 | /// remaining segments navigate into that file's JSON value. No cloning. |
| 1206 | +/// Full dot-joined label for a focused path. | |
| 1207 | +fn focus_path_full(fp: &[PathSegment]) -> String { | |
| 1208 | + fp.iter().map(|seg| match seg { | |
| 1209 | + PathSegment::Key(k) => k.clone(), | |
| 1210 | + PathSegment::Index(i) => format!("[{}]", i), | |
| 1211 | + }).collect::<Vec<_>>().join(".") | |
| 1212 | +} | |
| 1213 | + | |
| 1214 | +/// Short label for a focused path: "filename...lastKey[idx]" | |
| 1215 | +fn focus_path_label(fp: &[PathSegment]) -> String { | |
| 1216 | + if fp.is_empty() { | |
| 1217 | + return String::new(); | |
| 1218 | + } | |
| 1219 | + if fp.len() <= 2 { | |
| 1220 | + return focus_path_full(fp); | |
| 1221 | + } | |
| 1222 | + | |
| 1223 | + let first = match &fp[0] { | |
| 1224 | + PathSegment::Key(k) => k.clone(), | |
| 1225 | + PathSegment::Index(i) => format!("[{}]", i), | |
| 1226 | + }; | |
| 1227 | + | |
| 1228 | + // Build the tail: last key + any trailing indices | |
| 1229 | + let mut tail_parts = Vec::new(); | |
| 1230 | + for seg in fp.iter().rev() { | |
| 1231 | + match seg { | |
| 1232 | + PathSegment::Index(i) => tail_parts.push(format!("[{}]", i)), | |
| 1233 | + PathSegment::Key(k) => { | |
| 1234 | + tail_parts.push(k.clone()); | |
| 1235 | + break; | |
| 1236 | + } | |
| 1237 | + } | |
| 1238 | + } | |
| 1239 | + tail_parts.reverse(); | |
| 1240 | + let tail = tail_parts.join("."); | |
| 1241 | + | |
| 1242 | + format!("{}...{}", first, tail) | |
| 1243 | +} | |
| 1244 | + | |
| 1245 | +/// Convert an entry label to the correct PathSegment. | |
| 1246 | +/// Array items have labels like "[0]", "[1]" → Index(n). | |
| 1247 | +/// Everything else → Key(label). | |
| 1248 | +fn label_to_segment(label: &str) -> PathSegment { | |
| 1249 | + if let Some(inner) = label.strip_prefix('[').and_then(|s| s.strip_suffix(']')) { | |
| 1250 | + if let Ok(i) = inner.parse::<usize>() { | |
| 1251 | + return PathSegment::Index(i); | |
| 1252 | + } | |
| 1253 | + } | |
| 1254 | + PathSegment::Key(label.to_string()) | |
| 1255 | +} | |
| 1256 | + | |
| 1203 | 1257 | fn resolve_from_files<'a>( |
| 1204 | 1258 | files: &'a [(String, serde_json::Value)], |
| 1205 | 1259 | path: &[PathSegment], |