use std::collections::{BTreeSet, HashMap, HashSet};
#[derive(Debug, Clone)]
pub enum XmlTreeNode {
Element {
name: String,
attributes: Vec<(String, String)>,
children: Vec<Self>,
},
Text(String),
}
pub fn xml_tree_from_str(xml: &str) -> Option<XmlTreeNode> {
use quick_xml::Reader;
use quick_xml::events::Event;
let mut reader = Reader::from_str(xml);
let mut stack: Vec<XmlTreeNode> = Vec::new();
stack.push(XmlTreeNode::Element {
name: "root".to_string(),
attributes: Vec::new(),
children: Vec::new(),
});
loop {
match reader.read_event() {
Ok(Event::Start(ref e)) => {
let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
let attributes = e
.attributes()
.filter_map(|a| {
a.ok().map(|attr| {
(
String::from_utf8_lossy(attr.key.as_ref()).to_string(),
String::from_utf8_lossy(&attr.value).to_string(),
)
})
})
.collect();
stack.push(XmlTreeNode::Element {
name,
attributes,
children: Vec::new(),
});
}
Ok(Event::End(_)) => {
if stack.len() > 1 {
let child = stack.pop()?;
if let Some(XmlTreeNode::Element { children, .. }) = stack.last_mut() {
children.push(child);
}
}
}
Ok(Event::Empty(ref e)) => {
let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
let attributes = e
.attributes()
.filter_map(|a| {
a.ok().map(|attr| {
(
String::from_utf8_lossy(attr.key.as_ref()).to_string(),
String::from_utf8_lossy(&attr.value).to_string(),
)
})
})
.collect();
if let Some(XmlTreeNode::Element { children, .. }) = stack.last_mut() {
children.push(XmlTreeNode::Element {
name,
attributes,
children: Vec::new(),
});
}
}
Ok(Event::Text(ref e)) => {
let text = e.decode().unwrap_or_default().trim().to_string();
if !text.is_empty()
&& let Some(XmlTreeNode::Element { children, .. }) = stack.last_mut()
{
children.push(XmlTreeNode::Text(text));
}
}
Ok(Event::Eof) => break,
Err(_) => return None,
_ => {}
}
}
stack.pop()
}
pub fn flatten_xml_tree(
node: &XmlTreeNode,
parent_path: &str,
depth: usize,
expanded: &HashSet<String>,
items: &mut Vec<crate::tui::shared::source::FlatJsonItem>,
is_last_sibling: bool,
ancestors_last: &[bool],
) {
match node {
XmlTreeNode::Element {
name,
attributes,
children,
..
} => {
let node_id = if parent_path.is_empty() {
name.clone()
} else {
format!("{parent_path}.{name}")
};
let is_expanded = expanded.contains(&node_id);
let has_children = !children.is_empty();
let attr_preview = if attributes.is_empty() {
String::new()
} else {
attributes
.iter()
.map(|(k, v)| format!("{k}=\"{v}\""))
.collect::<Vec<_>>()
.join(" ")
};
let child_count_label = if has_children {
format!("<> ({} children)", children.len())
} else if !attr_preview.is_empty() {
String::new()
} else {
"</>".to_string()
};
items.push(crate::tui::shared::source::FlatJsonItem {
node_id: node_id.clone(),
depth,
display_key: format!("<{name}>"),
value_preview: attr_preview,
value_type: None,
is_expandable: has_children,
is_expanded,
child_count_label,
preview: String::new(),
is_last_sibling,
ancestors_last: ancestors_last.to_vec(),
});
if is_expanded {
let mut current_ancestors = ancestors_last.to_vec();
current_ancestors.push(is_last_sibling);
for (i, child) in children.iter().enumerate() {
let child_is_last = i == children.len() - 1;
flatten_xml_tree(
child,
&node_id,
depth + 1,
expanded,
items,
child_is_last,
¤t_ancestors,
);
}
}
}
XmlTreeNode::Text(text) => {
let node_id = if parent_path.is_empty() {
"#text".to_string()
} else {
format!("{parent_path}.#text")
};
items.push(crate::tui::shared::source::FlatJsonItem {
node_id,
depth,
display_key: String::new(),
value_preview: format!("\"{text}\""),
value_type: Some(JsonValueType::String),
is_expandable: false,
is_expanded: false,
child_count_label: String::new(),
preview: String::new(),
is_last_sibling,
ancestors_last: ancestors_last.to_vec(),
});
}
}
}
fn count_xml_nodes(node: &XmlTreeNode) -> usize {
match node {
XmlTreeNode::Element { children, .. } => {
1 + children.iter().map(count_xml_nodes).sum::<usize>()
}
XmlTreeNode::Text(_) => 1,
}
}
fn pretty_print_xml(node: &XmlTreeNode, depth: usize) -> Vec<String> {
let indent = " ".repeat(depth);
match node {
XmlTreeNode::Element {
name,
attributes,
children,
..
} => {
let attrs = if attributes.is_empty() {
String::new()
} else {
attributes
.iter()
.map(|(k, v)| format!(" {k}=\"{v}\""))
.collect::<String>()
};
let mut lines = Vec::new();
if children.is_empty() {
lines.push(format!("{indent}<{name}{attrs}/>"));
} else if children.len() == 1 && matches!(&children[0], XmlTreeNode::Text(_)) {
let text = match &children[0] {
XmlTreeNode::Text(t) => t.as_str(),
_ => "",
};
lines.push(format!("{indent}<{name}{attrs}>{text}</{name}>"));
} else {
lines.push(format!("{indent}<{name}{attrs}>"));
for child in children {
lines.extend(pretty_print_xml(child, depth + 1));
}
lines.push(format!("{indent}</{name}>"));
}
lines
}
XmlTreeNode::Text(text) => {
vec![format!("{indent}{text}")]
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SourceChangeStatus {
Added,
Removed,
Modified,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SourceViewMode {
#[default]
Tree,
Raw,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JsonValueType {
String,
Number,
Boolean,
Null,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SourceSortMode {
#[default]
None,
KeyAsc,
KeyDesc,
}
impl SourceSortMode {
pub const fn next(self) -> Self {
match self {
Self::None => Self::KeyAsc,
Self::KeyAsc => Self::KeyDesc,
Self::KeyDesc => Self::None,
}
}
pub const fn label(self) -> &'static str {
match self {
Self::None => "",
Self::KeyAsc => "[A-Z]",
Self::KeyDesc => "[Z-A]",
}
}
}
#[derive(Debug, Clone)]
pub enum JsonTreeNode {
Object {
key: String,
index: Option<usize>,
children: Vec<Self>,
},
Array {
key: String,
index: Option<usize>,
children: Vec<Self>,
len: usize,
},
Leaf {
key: String,
index: Option<usize>,
value: String,
value_type: JsonValueType,
},
}
impl JsonTreeNode {
pub fn from_value(key: String, index: Option<usize>, value: &serde_json::Value) -> Self {
match value {
serde_json::Value::Object(map) => {
let children = map
.iter()
.map(|(k, v)| Self::from_value(k.clone(), None, v))
.collect();
Self::Object {
key,
index,
children,
}
}
serde_json::Value::Array(arr) => {
let children = arr
.iter()
.enumerate()
.map(|(i, v)| Self::from_value(String::new(), Some(i), v))
.collect();
Self::Array {
key,
index,
children,
len: arr.len(),
}
}
serde_json::Value::String(s) => Self::Leaf {
key,
index,
value: format!("\"{}\"", truncate_value(s, 120)),
value_type: JsonValueType::String,
},
serde_json::Value::Number(n) => Self::Leaf {
key,
index,
value: n.to_string(),
value_type: JsonValueType::Number,
},
serde_json::Value::Bool(b) => Self::Leaf {
key,
index,
value: b.to_string(),
value_type: JsonValueType::Boolean,
},
serde_json::Value::Null => Self::Leaf {
key,
index,
value: "null".to_string(),
value_type: JsonValueType::Null,
},
}
}
pub fn node_id(&self, parent_path: &str) -> String {
let key_part = match self {
Self::Object { key, index, .. }
| Self::Array { key, index, .. }
| Self::Leaf { key, index, .. } => index
.as_ref()
.map_or_else(|| key.clone(), |i| format!("[{i}]")),
};
if parent_path.is_empty() {
key_part
} else {
format!("{parent_path}.{key_part}")
}
}
pub const fn is_expandable(&self) -> bool {
matches!(self, Self::Object { .. } | Self::Array { .. })
}
pub fn children(&self) -> Option<&[Self]> {
match self {
Self::Object { children, .. } | Self::Array { children, .. } => Some(children),
Self::Leaf { .. } => None,
}
}
pub fn child_count_label(&self) -> String {
match self {
Self::Object { children, .. } => {
format!("{{}} ({} keys)", children.len())
}
Self::Array { len, .. } => {
format!("[] ({len} items)")
}
Self::Leaf { .. } => String::new(),
}
}
pub fn display_key(&self) -> String {
match self {
Self::Object { key, index, .. }
| Self::Array { key, index, .. }
| Self::Leaf { key, index, .. } => index
.as_ref()
.map_or_else(|| key.clone(), |i| format!("[{i}]")),
}
}
pub fn key(&self) -> &str {
match self {
Self::Object { key, .. } | Self::Array { key, .. } | Self::Leaf { key, .. } => key,
}
}
pub fn preview_label(&self, parent_key: &str) -> String {
let Self::Object { children, .. } = self else {
return String::new();
};
match parent_key {
"components" => self.preview_component(children),
"dependencies" => self.preview_dependency(children),
"vulnerabilities" => self.preview_vulnerability(children),
"licenses" | "evidence" => self.preview_license(children),
"externalReferences" => self.preview_ext_ref(children),
"services" => self.preview_named(children),
"tools" => self.preview_named(children),
"compositions" => self.preview_composition(children),
"formulation" => self.preview_named(children),
"packages" => self.preview_spdx_package(children),
"relationships" => self.preview_spdx_relationship(children),
"hasExtractedLicensingInfos" => self.preview_spdx_license(children),
_ => self.preview_first_string(children),
}
}
fn preview_component(&self, children: &[Self]) -> String {
let name = find_leaf_str(children, "name");
let version = find_leaf_str(children, "version");
let comp_type = find_leaf_str(children, "type");
match (name, version) {
(Some(n), Some(v)) => {
let suffix = comp_type.map_or(String::new(), |t| format!(" ({t})"));
format!("{n}@{v}{suffix}")
}
(Some(n), None) => {
let suffix = comp_type.map_or(String::new(), |t| format!(" ({t})"));
format!("{n}{suffix}")
}
_ => String::new(),
}
}
fn preview_dependency(&self, children: &[Self]) -> String {
let ref_name = find_leaf_str(children, "ref");
let depends_on = children.iter().find(|c| c.key() == "dependsOn");
let dep_count = match depends_on {
Some(Self::Array { len, .. }) => Some(*len),
_ => None,
};
match (ref_name, dep_count) {
(Some(r), Some(n)) => {
let short = truncate_value(&r, 40);
format!("{short} \u{2192} {n} deps")
}
(Some(r), None) => {
let short = truncate_value(&r, 50);
format!("{short} \u{2192} 0 deps")
}
_ => String::new(),
}
}
fn preview_vulnerability(&self, children: &[Self]) -> String {
let id = find_leaf_str(children, "id");
let severity = find_leaf_str(children, "severity").or_else(|| {
children
.iter()
.find(|c| c.key() == "ratings")
.and_then(|arr| arr.children())
.and_then(|ratings| ratings.first())
.and_then(|r| r.children())
.and_then(|fields| find_leaf_str(fields, "severity"))
});
match (id, severity) {
(Some(i), Some(s)) => format!("{i} ({s})"),
(Some(i), None) => i,
_ => String::new(),
}
}
fn preview_license(&self, children: &[Self]) -> String {
let expression = find_leaf_str(children, "expression");
if let Some(e) = expression {
return e;
}
children
.iter()
.find(|c| c.key() == "license")
.and_then(|lic| lic.children())
.and_then(|fields| {
find_leaf_str(fields, "id").or_else(|| find_leaf_str(fields, "name"))
})
.unwrap_or_default()
}
fn preview_ext_ref(&self, children: &[Self]) -> String {
let ref_type = find_leaf_str(children, "type");
let url = find_leaf_str(children, "url");
match (ref_type, url) {
(Some(t), Some(u)) => format!("{t}: {}", truncate_value(&u, 40)),
(Some(t), None) => t,
(None, Some(u)) => truncate_value(&u, 50),
_ => String::new(),
}
}
fn preview_named(&self, children: &[Self]) -> String {
find_leaf_str(children, "name")
.or_else(|| find_leaf_str(children, "vendor"))
.unwrap_or_default()
}
fn preview_composition(&self, children: &[Self]) -> String {
find_leaf_str(children, "aggregate").unwrap_or_default()
}
fn preview_spdx_package(&self, children: &[Self]) -> String {
let name = find_leaf_str(children, "name");
let version = find_leaf_str(children, "versionInfo");
match (name, version) {
(Some(n), Some(v)) => format!("{n}@{v}"),
(Some(n), None) => n,
_ => String::new(),
}
}
fn preview_spdx_relationship(&self, children: &[Self]) -> String {
let rel_type = find_leaf_str(children, "relationshipType");
let element = find_leaf_str(children, "spdxElementId");
let related = find_leaf_str(children, "relatedSpdxElement");
match (element, rel_type, related) {
(Some(e), Some(t), Some(r)) => {
format!(
"{} {} {}",
truncate_value(&e, 20),
t,
truncate_value(&r, 20)
)
}
(_, Some(t), _) => t,
_ => String::new(),
}
}
fn preview_spdx_license(&self, children: &[Self]) -> String {
find_leaf_str(children, "licenseId")
.or_else(|| find_leaf_str(children, "name"))
.unwrap_or_default()
}
fn preview_first_string(&self, children: &[Self]) -> String {
for child in children {
if let Self::Leaf {
value,
value_type: JsonValueType::String,
..
} = child
{
let s = value.trim_matches('"');
if !s.is_empty() {
return truncate_value(s, 50);
}
}
}
String::new()
}
}
fn truncate_value(s: &str, max_len: usize) -> String {
crate::tui::widgets::truncate_str(s, max_len)
}
fn find_leaf_str(children: &[JsonTreeNode], key: &str) -> Option<String> {
children.iter().find_map(|c| {
if let JsonTreeNode::Leaf {
key: k,
value,
value_type: JsonValueType::String,
..
} = c
&& k == key
{
Some(value.trim_matches('"').to_string())
} else {
None
}
})
}
fn count_tree_nodes(node: &JsonTreeNode) -> usize {
let mut count = 1;
if let Some(children) = node.children() {
for child in children {
count += count_tree_nodes(child);
}
}
count
}
enum RawMapEntry {
Object(String),
Array(String, usize),
}
fn build_raw_line_mapping(raw_lines: &[String]) -> Vec<String> {
let mut result = Vec::with_capacity(raw_lines.len());
let mut stack: Vec<RawMapEntry> = Vec::new();
for line in raw_lines {
let trimmed = line.trim();
let content = trimmed.trim_end_matches(',');
if content.is_empty() {
result.push(stack_to_node_id(&stack));
continue;
}
if let Some((key, value_part)) = parse_json_kv(content) {
match value_part {
"{" => {
stack.push(RawMapEntry::Object(key));
result.push(stack_to_node_id(&stack));
}
"[" => {
stack.push(RawMapEntry::Array(key, 0));
result.push(stack_to_node_id(&stack));
}
_ => {
let parent = stack_to_node_id(&stack);
result.push(if parent.is_empty() {
key
} else {
format!("{parent}.{key}")
});
}
}
} else if content == "{" || content == "[" {
if stack.is_empty() {
if content == "[" {
stack.push(RawMapEntry::Array("root".to_string(), 0));
} else {
stack.push(RawMapEntry::Object("root".to_string()));
}
} else {
let idx = take_next_array_index(&mut stack);
if content == "[" {
stack.push(RawMapEntry::Array(format!("[{idx}]"), 0));
} else {
stack.push(RawMapEntry::Object(format!("[{idx}]")));
}
}
result.push(stack_to_node_id(&stack));
} else if content == "}" || content == "]" {
result.push(stack_to_node_id(&stack));
stack.pop();
} else {
let idx = take_next_array_index(&mut stack);
let parent = stack_to_node_id(&stack);
result.push(format!("{parent}.[{idx}]"));
}
}
result
}
fn compute_bracket_pairs(raw_lines: &[String]) -> (HashMap<usize, usize>, HashMap<usize, usize>) {
let mut forward = HashMap::new();
let mut reverse = HashMap::new();
let mut stack: Vec<usize> = Vec::new();
for (i, line) in raw_lines.iter().enumerate() {
let trimmed = line.trim().trim_end_matches(',');
if trimmed.ends_with('{') || trimmed.ends_with('[') {
stack.push(i);
} else if (trimmed == "}" || trimmed == "]")
&& let Some(open) = stack.pop()
{
forward.insert(open, i);
reverse.insert(i, open);
}
}
(forward, reverse)
}
fn stack_to_node_id(stack: &[RawMapEntry]) -> String {
stack
.iter()
.map(|e| match e {
RawMapEntry::Object(s) | RawMapEntry::Array(s, _) => s.as_str(),
})
.collect::<Vec<_>>()
.join(".")
}
fn take_next_array_index(stack: &mut [RawMapEntry]) -> usize {
if let Some(RawMapEntry::Array(_, idx)) = stack.last_mut() {
let current = *idx;
*idx += 1;
current
} else {
0
}
}
fn parse_json_kv(s: &str) -> Option<(String, &str)> {
if !s.starts_with('"') {
return None;
}
let bytes = s.as_bytes();
let mut i = 1;
while i < bytes.len() {
if bytes[i] == b'\\' {
i += 2;
continue;
}
if bytes[i] == b'"' {
break;
}
i += 1;
}
if i >= bytes.len() {
return None;
}
let key = s[1..i].to_string();
s[i + 1..].strip_prefix(": ").map(|rest| (key, rest))
}
#[derive(Debug, Clone)]
pub struct SourcePanelState {
pub view_mode: SourceViewMode,
pub expanded: HashSet<String>,
pub selected: usize,
pub scroll_offset: usize,
pub visible_count: usize,
pub json_tree: Option<JsonTreeNode>,
pub xml_tree: Option<XmlTreeNode>,
pub raw_lines: Vec<String>,
pub raw_line_node_ids: Vec<String>,
pub total_node_count: usize,
pub tree_selected: usize,
pub tree_scroll_offset: usize,
pub raw_selected: usize,
pub raw_scroll_offset: usize,
pub search_query: String,
pub search_active: bool,
pub search_matches: Vec<usize>,
pub search_current: usize,
pub map_selected: usize,
pub map_scroll_offset: usize,
pub change_annotations: HashMap<String, SourceChangeStatus>,
pub viewport_height: usize,
pub h_scroll_offset: usize,
pub cached_flat_items: Vec<crate::tui::shared::source::FlatJsonItem>,
pub flat_cache_valid: bool,
pub show_line_numbers: bool,
pub word_wrap: bool,
pub bookmarks: BTreeSet<usize>,
pub change_indices: Vec<usize>,
pub current_change_idx: Option<usize>,
pub search_regex_mode: bool,
pub compiled_regex: Option<regex::Regex>,
pub filter_type: Option<JsonValueType>,
pub sort_mode: SourceSortMode,
pub bracket_pairs: HashMap<usize, usize>,
pub bracket_pairs_reverse: HashMap<usize, usize>,
pub folded_lines: HashSet<usize>,
pub show_indent_guides: bool,
pub link_labels: HashMap<usize, String>,
pub compact_mode: bool,
pub version_diffs: HashMap<String, (String, String)>,
pub collapse_unchanged: bool,
pub collapse_threshold: usize,
}
impl SourcePanelState {
pub fn new(raw_content: &str) -> Self {
let (json_tree, xml_tree, raw_lines) =
if let Ok(value) = serde_json::from_str::<serde_json::Value>(raw_content) {
let tree = JsonTreeNode::from_value("root".to_string(), None, &value);
let pretty = serde_json::to_string_pretty(&value)
.unwrap_or_else(|_| raw_content.to_string());
let lines: Vec<String> = pretty
.lines()
.map(std::string::ToString::to_string)
.collect();
(Some(tree), None, lines)
} else if raw_content.trim_start().starts_with('<') {
if let Some(xml) = xml_tree_from_str(raw_content) {
let lines = pretty_print_xml(&xml, 0);
(None, Some(xml), lines)
} else {
let lines: Vec<String> = raw_content
.lines()
.map(std::string::ToString::to_string)
.collect();
(None, None, lines)
}
} else {
let lines: Vec<String> = raw_content
.lines()
.map(std::string::ToString::to_string)
.collect();
(None, None, lines)
};
let has_tree = json_tree.is_some() || xml_tree.is_some();
let mut expanded = HashSet::new();
if has_tree {
expanded.insert("root".to_string());
}
let total_node_count = if let Some(ref jt) = json_tree {
count_tree_nodes(jt)
} else if let Some(ref xt) = xml_tree {
count_xml_nodes(xt)
} else {
0
};
let raw_line_node_ids = if json_tree.is_some() {
build_raw_line_mapping(&raw_lines)
} else {
Vec::new()
};
let (bracket_pairs, bracket_pairs_reverse) = if json_tree.is_some() {
compute_bracket_pairs(&raw_lines)
} else {
(HashMap::new(), HashMap::new())
};
Self {
view_mode: if has_tree {
SourceViewMode::Tree
} else {
SourceViewMode::Raw
},
expanded,
selected: 0,
scroll_offset: 0,
visible_count: 0,
json_tree,
xml_tree,
raw_lines,
raw_line_node_ids,
total_node_count,
tree_selected: 0,
tree_scroll_offset: 0,
raw_selected: 0,
raw_scroll_offset: 0,
search_query: String::new(),
search_active: false,
search_matches: Vec::new(),
search_current: 0,
map_selected: 0,
map_scroll_offset: 0,
change_annotations: HashMap::new(),
viewport_height: 20,
h_scroll_offset: 0,
cached_flat_items: Vec::new(),
flat_cache_valid: false,
show_line_numbers: false,
word_wrap: false,
bookmarks: BTreeSet::new(),
change_indices: Vec::new(),
current_change_idx: None,
search_regex_mode: false,
compiled_regex: None,
filter_type: None,
sort_mode: SourceSortMode::None,
bracket_pairs,
bracket_pairs_reverse,
folded_lines: HashSet::new(),
show_indent_guides: true,
link_labels: HashMap::new(),
compact_mode: false,
version_diffs: HashMap::new(),
collapse_unchanged: false,
collapse_threshold: 3,
}
}
pub fn invalidate_flat_cache(&mut self) {
self.flat_cache_valid = false;
self.change_indices.clear();
self.current_change_idx = None;
}
pub fn ensure_flat_cache(&mut self) {
if self.flat_cache_valid {
return;
}
self.cached_flat_items.clear();
if let Some(ref tree) = self.json_tree {
crate::tui::shared::source::flatten_json_tree(
tree,
"",
0,
&self.expanded,
&mut self.cached_flat_items,
true,
&[],
self.sort_mode,
"",
);
} else if let Some(ref xml) = self.xml_tree {
flatten_xml_tree(
xml,
"",
0,
&self.expanded,
&mut self.cached_flat_items,
true,
&[],
);
}
if let Some(filter_type) = self.filter_type {
self.cached_flat_items
.retain(|item| item.is_expandable || item.value_type == Some(filter_type));
}
self.collapse_unchanged_regions();
self.flat_cache_valid = true;
}
fn collapse_unchanged_regions(&mut self) {
if !self.collapse_unchanged || self.change_annotations.is_empty() {
return;
}
let items = &self.cached_flat_items;
let mut result: Vec<crate::tui::shared::source::FlatJsonItem> =
Vec::with_capacity(items.len());
let mut run_start: Option<usize> = None;
let mut run_count: usize = 0;
for i in 0..items.len() {
let item = &items[i];
let has_annotation = self.find_annotation(&item.node_id).is_some();
let is_structural = item.depth <= 1;
if has_annotation || is_structural {
if run_count > self.collapse_threshold {
if let Some(start) = run_start {
result.push(items[start].clone());
let collapsed_count = run_count.saturating_sub(2);
result.push(crate::tui::shared::source::FlatJsonItem {
node_id: format!("__collapsed_{start}"),
depth: 3,
display_key: format!(
"\u{00b7}\u{00b7}\u{00b7} {collapsed_count} unchanged items \u{00b7}\u{00b7}\u{00b7}"
),
value_preview: String::new(),
value_type: None,
is_expandable: false,
is_expanded: false,
child_count_label: String::new(),
preview: String::new(),
is_last_sibling: false,
ancestors_last: vec![],
});
if run_count > 1 {
result.push(items[i - 1].clone());
}
}
} else if let Some(start) = run_start {
for item in items.iter().take(i).skip(start) {
result.push(item.clone());
}
}
result.push(item.clone());
run_start = None;
run_count = 0;
} else {
if run_start.is_none() {
run_start = Some(i);
}
run_count += 1;
}
}
if run_count > self.collapse_threshold {
if let Some(start) = run_start {
result.push(items[start].clone());
let collapsed_count = run_count.saturating_sub(1);
result.push(crate::tui::shared::source::FlatJsonItem {
node_id: format!("__collapsed_{start}"),
depth: 3,
display_key: format!(
"\u{00b7}\u{00b7}\u{00b7} {collapsed_count} unchanged items \u{00b7}\u{00b7}\u{00b7}"
),
value_preview: String::new(),
value_type: None,
is_expandable: false,
is_expanded: false,
child_count_label: String::new(),
preview: String::new(),
is_last_sibling: false,
ancestors_last: vec![],
});
}
} else if let Some(start) = run_start {
for item in items.iter().skip(start) {
result.push(item.clone());
}
}
self.cached_flat_items = result;
}
pub fn toggle_compact_mode(&mut self) {
self.compact_mode = !self.compact_mode;
}
pub fn prepare_source_render(&mut self, viewport_height: usize) {
self.ensure_flat_cache();
match self.view_mode {
SourceViewMode::Tree => {
let item_count = self.cached_flat_items.len();
self.visible_count = item_count;
if self.selected >= item_count && item_count > 0 {
self.selected = item_count - 1;
}
if viewport_height > 0 {
if self.selected >= self.scroll_offset + viewport_height {
self.scroll_offset = self.selected.saturating_sub(viewport_height - 1);
} else if self.selected < self.scroll_offset {
self.scroll_offset = self.selected;
}
}
self.viewport_height = viewport_height;
}
SourceViewMode::Raw => {
self.visible_count = self.raw_lines.len();
if self.selected >= self.raw_lines.len() && !self.raw_lines.is_empty() {
self.selected = self.raw_lines.len() - 1;
}
if self.folded_lines.is_empty() && viewport_height > 0 {
if self.selected >= self.scroll_offset + viewport_height {
self.scroll_offset = self.selected.saturating_sub(viewport_height - 1);
} else if self.selected < self.scroll_offset {
self.scroll_offset = self.selected;
}
}
self.viewport_height = viewport_height;
}
}
}
pub fn toggle_view_mode(&mut self) {
self.h_scroll_offset = 0;
match self.view_mode {
SourceViewMode::Tree => {
self.tree_selected = self.selected;
self.tree_scroll_offset = self.scroll_offset;
}
SourceViewMode::Raw => {
self.raw_selected = self.selected;
self.raw_scroll_offset = self.scroll_offset;
}
}
let synced = self.compute_synced_position();
let has_tree = self.json_tree.is_some() || self.xml_tree.is_some();
let new_mode = match self.view_mode {
SourceViewMode::Tree => SourceViewMode::Raw,
SourceViewMode::Raw => {
if has_tree {
SourceViewMode::Tree
} else {
return;
}
}
};
self.view_mode = new_mode;
if let Some((sel, scroll)) = synced {
self.selected = sel;
self.scroll_offset = scroll;
} else {
match self.view_mode {
SourceViewMode::Tree => {
self.selected = self.tree_selected;
self.scroll_offset = self.tree_scroll_offset;
}
SourceViewMode::Raw => {
self.selected = self.raw_selected;
self.scroll_offset = self.raw_scroll_offset;
}
}
}
}
fn compute_synced_position(&mut self) -> Option<(usize, usize)> {
match self.view_mode {
SourceViewMode::Tree => self.sync_tree_to_raw(),
SourceViewMode::Raw => self.sync_raw_to_tree(),
}
}
fn sync_tree_to_raw(&mut self) -> Option<(usize, usize)> {
self.ensure_flat_cache();
let node_id = self
.cached_flat_items
.get(self.selected)
.map(|item| item.node_id.clone())?;
let raw_idx = self
.raw_line_node_ids
.iter()
.position(|id| *id == node_id)?;
Some((raw_idx, raw_idx.saturating_sub(5)))
}
fn sync_raw_to_tree(&mut self) -> Option<(usize, usize)> {
let node_id = self.raw_line_node_ids.get(self.selected)?.clone();
if node_id.is_empty() {
return None;
}
let parts: Vec<&str> = node_id.split('.').collect();
let mut changed = false;
for len in 1..parts.len() {
let ancestor = parts[..len].join(".");
if !self.expanded.contains(&ancestor) {
self.expanded.insert(ancestor);
changed = true;
}
}
if changed {
self.invalidate_flat_cache();
}
self.ensure_flat_cache();
for len in (1..=parts.len()).rev() {
let candidate = parts[..len].join(".");
if let Some(idx) = self
.cached_flat_items
.iter()
.position(|item| item.node_id == candidate)
{
return Some((idx, idx.saturating_sub(5)));
}
}
None
}
pub fn toggle_expand(&mut self, node_id: &str) {
if self.expanded.contains(node_id) {
self.expanded.remove(node_id);
} else {
self.expanded.insert(node_id.to_string());
}
self.invalidate_flat_cache();
}
pub fn expand_all(&mut self) {
if let Some(ref tree) = self.json_tree {
expand_all_recursive(tree, "", &mut self.expanded);
} else if let Some(ref xml) = self.xml_tree {
expand_all_xml_recursive(xml, "", &mut self.expanded);
}
self.invalidate_flat_cache();
}
pub fn collapse_all(&mut self) {
self.expanded.clear();
self.expanded.insert("root".to_string());
self.selected = 0;
self.scroll_offset = 0;
self.map_scroll_offset = 0;
self.invalidate_flat_cache();
}
pub fn toggle_fold(&mut self) {
let line = self.selected;
let open_line = if self.bracket_pairs.contains_key(&line) {
line
} else if let Some(&open) = self.bracket_pairs_reverse.get(&line) {
open
} else {
return;
};
if self.folded_lines.contains(&open_line) {
self.folded_lines.remove(&open_line);
} else {
self.folded_lines.insert(open_line);
}
}
pub fn unfold_all(&mut self) {
self.folded_lines.clear();
}
pub fn fold_all_top_level(&mut self) {
for &open in self.bracket_pairs.keys() {
if let Some(line) = self.raw_lines.get(open) {
let indent = line.len() - line.trim_start().len();
if indent <= 2 {
self.folded_lines.insert(open);
}
}
}
}
pub fn matching_bracket(&self, line: usize) -> Option<usize> {
self.bracket_pairs
.get(&line)
.or_else(|| self.bracket_pairs_reverse.get(&line))
.copied()
}
pub fn jump_to_matching_bracket(&mut self) {
if let Some(target) = self.matching_bracket(self.selected) {
self.selected = target;
if let Some(&open) = self.bracket_pairs_reverse.get(&target) {
self.folded_lines.remove(&open);
}
if self.folded_lines.contains(&target) {
self.folded_lines.remove(&target);
}
}
}
pub fn is_line_folded(&self, line: usize) -> bool {
for &open in &self.folded_lines {
if let Some(&close) = self.bracket_pairs.get(&open)
&& line > open
&& line <= close
{
return true;
}
}
false
}
pub fn next_visible_line(&self, line: usize) -> usize {
let max = self.raw_lines.len().saturating_sub(1);
let mut next = line + 1;
while next <= max && self.is_line_folded(next) {
next += 1;
}
next.min(max)
}
pub fn prev_visible_line(&self, line: usize) -> usize {
if line == 0 {
return 0;
}
let mut prev = line - 1;
while prev > 0 && self.is_line_folded(prev) {
prev -= 1;
}
prev
}
pub fn select_next(&mut self) {
let max = self.effective_count();
if max > 0 && self.selected < max.saturating_sub(1) {
if self.view_mode == SourceViewMode::Raw && !self.folded_lines.is_empty() {
self.selected = self.next_visible_line(self.selected);
} else {
self.selected += 1;
}
}
}
pub fn select_prev(&mut self) {
if self.view_mode == SourceViewMode::Raw && !self.folded_lines.is_empty() {
self.selected = self.prev_visible_line(self.selected);
} else {
self.selected = self.selected.saturating_sub(1);
}
}
pub const fn select_first(&mut self) {
self.selected = 0;
self.scroll_offset = 0;
}
pub fn select_last(&mut self) {
let max = self.effective_count();
if max > 0 {
self.selected = max.saturating_sub(1);
}
}
pub fn page_down(&mut self) {
let max = self.effective_count();
let target = (self.selected + self.viewport_height).min(max.saturating_sub(1));
if self.view_mode == SourceViewMode::Raw && !self.folded_lines.is_empty() {
self.selected = target;
while self.selected > 0 && self.is_line_folded(self.selected) {
self.selected = self.next_visible_line(self.selected);
}
} else {
self.selected = target;
}
}
pub fn page_up(&mut self) {
let target = self.selected.saturating_sub(self.viewport_height);
if self.view_mode == SourceViewMode::Raw && !self.folded_lines.is_empty() {
self.selected = target;
while self.selected > 0 && self.is_line_folded(self.selected) {
self.selected = self.prev_visible_line(self.selected);
}
} else {
self.selected = target;
}
}
fn effective_count(&self) -> usize {
if self.visible_count > 0 {
self.visible_count
} else {
self.raw_lines.len()
}
}
pub fn scroll_left(&mut self) {
self.h_scroll_offset = self.h_scroll_offset.saturating_sub(4);
}
pub fn scroll_right(&mut self) {
self.h_scroll_offset += 4;
}
pub fn expand_to_depth(&mut self, max_depth: usize) {
self.expanded.clear();
if let Some(ref tree) = self.json_tree {
expand_to_depth_recursive(tree, "", 0, max_depth, &mut self.expanded);
} else if let Some(ref xml) = self.xml_tree {
expand_to_depth_xml_recursive(xml, "", 0, max_depth, &mut self.expanded);
}
self.invalidate_flat_cache();
}
pub fn find_annotation(&self, node_id: &str) -> Option<SourceChangeStatus> {
if let Some(status) = self.change_annotations.get(node_id) {
return Some(*status);
}
let parts: Vec<&str> = node_id.split('.').collect();
for len in (1..parts.len()).rev() {
let ancestor = parts[..len].join(".");
if let Some(status) = self.change_annotations.get(&ancestor) {
return Some(*status);
}
}
None
}
pub fn start_search(&mut self) {
self.search_active = true;
self.search_query.clear();
self.search_matches.clear();
self.search_current = 0;
}
pub const fn stop_search(&mut self) {
self.search_active = false;
}
pub fn search_push_char(&mut self, c: char) {
self.search_query.push(c);
self.update_compiled_regex();
self.execute_search();
}
pub fn search_pop_char(&mut self) {
self.search_query.pop();
self.update_compiled_regex();
self.execute_search();
}
pub fn next_search_match(&mut self) {
if !self.search_matches.is_empty() {
self.search_current = (self.search_current + 1) % self.search_matches.len();
self.selected = self.search_matches[self.search_current];
}
}
pub fn prev_search_match(&mut self) {
if !self.search_matches.is_empty() {
self.search_current = if self.search_current == 0 {
self.search_matches.len() - 1
} else {
self.search_current - 1
};
self.selected = self.search_matches[self.search_current];
}
}
pub fn execute_search(&mut self) {
self.search_matches.clear();
self.search_current = 0;
if self.search_query.len() < 2 {
return;
}
if self.search_regex_mode {
if let Some(re) = self.compiled_regex.clone() {
match self.view_mode {
SourceViewMode::Tree => {
self.ensure_flat_cache();
for (i, item) in self.cached_flat_items.iter().enumerate() {
if re.is_match(&item.display_key) || re.is_match(&item.value_preview) {
self.search_matches.push(i);
}
}
}
SourceViewMode::Raw => {
for (i, line) in self.raw_lines.iter().enumerate() {
if re.is_match(line) {
self.search_matches.push(i);
}
}
}
}
}
} else {
let query = self.search_query.to_lowercase();
match self.view_mode {
SourceViewMode::Tree => {
self.ensure_flat_cache();
for (i, item) in self.cached_flat_items.iter().enumerate() {
if item.display_key.to_lowercase().contains(&query)
|| item.value_preview.to_lowercase().contains(&query)
{
self.search_matches.push(i);
}
}
}
SourceViewMode::Raw => {
for (i, line) in self.raw_lines.iter().enumerate() {
if line.to_lowercase().contains(&query) {
self.search_matches.push(i);
}
}
}
}
}
if !self.search_matches.is_empty() {
self.selected = self.search_matches[0];
}
}
pub fn toggle_line_numbers(&mut self) {
self.show_line_numbers = !self.show_line_numbers;
}
pub fn toggle_word_wrap(&mut self) {
self.word_wrap = !self.word_wrap;
if self.word_wrap {
self.h_scroll_offset = 0;
}
}
pub fn toggle_bookmark(&mut self) {
if !self.bookmarks.remove(&self.selected) {
self.bookmarks.insert(self.selected);
}
}
pub fn next_bookmark(&mut self) {
if self.bookmarks.is_empty() {
return;
}
if let Some(&next) = self.bookmarks.range((self.selected + 1)..).next() {
self.selected = next;
} else {
if let Some(&first) = self.bookmarks.iter().next() {
self.selected = first;
}
}
}
pub fn prev_bookmark(&mut self) {
if self.bookmarks.is_empty() {
return;
}
if let Some(&prev) = self.bookmarks.range(..self.selected).next_back() {
self.selected = prev;
} else {
if let Some(&last) = self.bookmarks.iter().next_back() {
self.selected = last;
}
}
}
pub fn build_change_indices(&mut self) {
self.change_indices.clear();
self.current_change_idx = None;
if self.change_annotations.is_empty() {
return;
}
match self.view_mode {
SourceViewMode::Tree => {
self.ensure_flat_cache();
for (i, item) in self.cached_flat_items.iter().enumerate() {
if self.find_annotation(&item.node_id).is_some() {
self.change_indices.push(i);
}
}
}
SourceViewMode::Raw => {
for (i, node_id) in self.raw_line_node_ids.iter().enumerate() {
if !node_id.is_empty() && self.find_annotation(node_id).is_some() {
self.change_indices.push(i);
}
}
}
}
self.change_indices.dedup();
}
pub fn next_change(&mut self) {
if self.change_indices.is_empty() {
self.build_change_indices();
}
if self.change_indices.is_empty() {
return;
}
let idx = match self.current_change_idx {
Some(i) => {
if i + 1 < self.change_indices.len() {
i + 1
} else {
0
}
}
None => {
self.change_indices
.iter()
.position(|&ci| ci >= self.selected)
.unwrap_or(0)
}
};
self.current_change_idx = Some(idx);
self.selected = self.change_indices[idx];
}
pub fn prev_change(&mut self) {
if self.change_indices.is_empty() {
self.build_change_indices();
}
if self.change_indices.is_empty() {
return;
}
let idx = match self.current_change_idx {
Some(i) => {
if i > 0 {
i - 1
} else {
self.change_indices.len() - 1
}
}
None => self
.change_indices
.iter()
.rposition(|&ci| ci <= self.selected)
.unwrap_or(self.change_indices.len() - 1),
};
self.current_change_idx = Some(idx);
self.selected = self.change_indices[idx];
}
pub fn toggle_search_regex(&mut self) {
self.search_regex_mode = !self.search_regex_mode;
self.update_compiled_regex();
self.execute_search();
}
fn update_compiled_regex(&mut self) {
if self.search_regex_mode && !self.search_query.is_empty() {
self.compiled_regex = regex::RegexBuilder::new(&self.search_query)
.case_insensitive(true)
.build()
.ok();
} else {
self.compiled_regex = None;
}
}
pub fn cycle_filter_type(&mut self) {
self.filter_type = match self.filter_type {
None => Some(JsonValueType::String),
Some(JsonValueType::String) => Some(JsonValueType::Number),
Some(JsonValueType::Number) => Some(JsonValueType::Boolean),
Some(JsonValueType::Boolean) => None,
Some(JsonValueType::Null) => None,
};
self.invalidate_flat_cache();
}
pub fn cycle_sort(&mut self) {
self.sort_mode = self.sort_mode.next();
self.invalidate_flat_cache();
}
pub fn filter_label(&self) -> &'static str {
match self.filter_type {
None => "",
Some(JsonValueType::String) => "[Str]",
Some(JsonValueType::Number) => "[Num]",
Some(JsonValueType::Boolean) => "[Bool]",
Some(JsonValueType::Null) => "[Null]",
}
}
pub fn get_full_content(&self) -> String {
self.raw_lines.join("\n")
}
pub fn change_status_at_index(&self, idx: usize) -> Option<SourceChangeStatus> {
if self.change_annotations.is_empty() {
return None;
}
match self.view_mode {
SourceViewMode::Tree => self
.cached_flat_items
.get(idx)
.and_then(|item| self.find_annotation(&item.node_id)),
SourceViewMode::Raw => self
.raw_line_node_ids
.get(idx)
.and_then(|node_id| self.find_annotation(node_id)),
}
}
}
fn expand_all_recursive(node: &JsonTreeNode, path: &str, expanded: &mut HashSet<String>) {
let id = node.node_id(path);
if node.is_expandable() {
expanded.insert(id.clone());
if let Some(children) = node.children() {
for child in children {
expand_all_recursive(child, &id, expanded);
}
}
}
}
fn expand_all_xml_recursive(node: &XmlTreeNode, path: &str, expanded: &mut HashSet<String>) {
if let XmlTreeNode::Element { name, children, .. } = node {
let id = if path.is_empty() {
name.clone()
} else {
format!("{path}.{name}")
};
if !children.is_empty() {
expanded.insert(id.clone());
for child in children {
expand_all_xml_recursive(child, &id, expanded);
}
}
}
}
fn expand_to_depth_xml_recursive(
node: &XmlTreeNode,
path: &str,
depth: usize,
max_depth: usize,
expanded: &mut HashSet<String>,
) {
if let XmlTreeNode::Element { name, children, .. } = node {
let id = if path.is_empty() {
name.clone()
} else {
format!("{path}.{name}")
};
if !children.is_empty() && depth < max_depth {
expanded.insert(id.clone());
for child in children {
expand_to_depth_xml_recursive(child, &id, depth + 1, max_depth, expanded);
}
}
}
}
fn expand_to_depth_recursive(
node: &JsonTreeNode,
path: &str,
depth: usize,
max_depth: usize,
expanded: &mut HashSet<String>,
) {
let id = node.node_id(path);
if node.is_expandable() && depth < max_depth {
expanded.insert(id.clone());
if let Some(children) = node.children() {
for child in children {
expand_to_depth_recursive(child, &id, depth + 1, max_depth, expanded);
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SourceSide {
Old,
New,
}
#[derive(Debug, Clone)]
pub struct SourceDiffState {
pub old_panel: SourcePanelState,
pub new_panel: SourcePanelState,
pub active_side: SourceSide,
pub sync_mode: super::ScrollSyncMode,
pub show_detail: bool,
pub detail_scroll: usize,
pub align_enabled: bool,
pub(crate) alignment_applied: bool,
}
impl SourceDiffState {
pub fn new(old_raw: &str, new_raw: &str) -> Self {
Self {
old_panel: SourcePanelState::new(old_raw),
new_panel: SourcePanelState::new(new_raw),
active_side: SourceSide::New,
sync_mode: super::ScrollSyncMode::Locked,
show_detail: false,
detail_scroll: 0,
align_enabled: true,
alignment_applied: false,
}
}
pub const fn active_panel(&self) -> &SourcePanelState {
match self.active_side {
SourceSide::Old => &self.old_panel,
SourceSide::New => &self.new_panel,
}
}
pub const fn active_panel_mut(&mut self) -> &mut SourcePanelState {
match self.active_side {
SourceSide::Old => &mut self.old_panel,
SourceSide::New => &mut self.new_panel,
}
}
pub const fn inactive_panel_mut(&mut self) -> &mut SourcePanelState {
match self.active_side {
SourceSide::Old => &mut self.new_panel,
SourceSide::New => &mut self.old_panel,
}
}
pub const fn is_synced(&self) -> bool {
matches!(self.sync_mode, super::ScrollSyncMode::Locked)
}
pub const fn toggle_sync(&mut self) {
self.sync_mode = match self.sync_mode {
super::ScrollSyncMode::Independent => super::ScrollSyncMode::Locked,
super::ScrollSyncMode::Locked => super::ScrollSyncMode::Independent,
};
}
pub fn toggle_side(&mut self) {
if self.is_synced() {
self.sync_target_to_active();
}
self.active_side = match self.active_side {
SourceSide::Old => SourceSide::New,
SourceSide::New => SourceSide::Old,
};
}
fn sync_target_to_active(&mut self) {
let target_node_id = {
let active = match self.active_side {
SourceSide::Old => &mut self.old_panel,
SourceSide::New => &mut self.new_panel,
};
if active.view_mode != SourceViewMode::Tree {
return;
}
active.ensure_flat_cache();
active
.cached_flat_items
.get(active.selected)
.map(|item| item.node_id.clone())
};
let Some(node_id) = target_node_id else {
return;
};
let inactive = match self.active_side {
SourceSide::Old => &mut self.new_panel,
SourceSide::New => &mut self.old_panel,
};
if inactive.view_mode != SourceViewMode::Tree {
return;
}
let parts: Vec<&str> = node_id.split('.').collect();
for len in (1..=parts.len()).rev() {
let candidate = parts[..len].join(".");
for ancestor_len in 1..len {
let ancestor = parts[..ancestor_len].join(".");
if !inactive.expanded.contains(&ancestor) {
inactive.expanded.insert(ancestor);
inactive.invalidate_flat_cache();
}
}
inactive.ensure_flat_cache();
if let Some(idx) = inactive
.cached_flat_items
.iter()
.position(|item| item.node_id == candidate)
{
inactive.selected = idx;
inactive.scroll_offset = idx.saturating_sub(5);
return;
}
}
}
pub fn populate_annotations(&mut self, diff: &crate::diff::DiffResult) {
let old_comp_indices = build_component_index(&self.old_panel);
let new_comp_indices = build_component_index(&self.new_panel);
for comp in &diff.components.removed {
if let Some(&idx) = old_comp_indices.get(&comp.name) {
let path = format!("root.components.[{idx}]");
self.old_panel
.change_annotations
.insert(path, SourceChangeStatus::Removed);
}
}
for comp in &diff.components.added {
if let Some(&idx) = new_comp_indices.get(&comp.name) {
let path = format!("root.components.[{idx}]");
self.new_panel
.change_annotations
.insert(path, SourceChangeStatus::Added);
}
}
for comp in &diff.components.modified {
if let Some(&idx) = old_comp_indices.get(&comp.name) {
let path = format!("root.components.[{idx}]");
self.old_panel
.change_annotations
.insert(path, SourceChangeStatus::Modified);
}
if let Some(&idx) = new_comp_indices.get(&comp.name) {
let path = format!("root.components.[{idx}]");
self.new_panel
.change_annotations
.insert(path, SourceChangeStatus::Modified);
}
if let (Some(old_v), Some(new_v)) = (&comp.old_version, &comp.new_version)
&& old_v != new_v
{
if let Some(&idx) = old_comp_indices.get(&comp.name) {
let path = format!("root.components.[{idx}]");
self.old_panel
.version_diffs
.insert(path, (old_v.clone(), new_v.clone()));
}
if let Some(&idx) = new_comp_indices.get(&comp.name) {
let path = format!("root.components.[{idx}]");
self.new_panel
.version_diffs
.insert(path, (old_v.clone(), new_v.clone()));
}
}
}
}
pub fn annotation_counts(panel: &SourcePanelState) -> (usize, usize, usize) {
let mut added = 0;
let mut removed = 0;
let mut modified = 0;
for status in panel.change_annotations.values() {
match status {
SourceChangeStatus::Added => added += 1,
SourceChangeStatus::Removed => removed += 1,
SourceChangeStatus::Modified => modified += 1,
}
}
(added, removed, modified)
}
pub fn select_next(&mut self) {
self.active_panel_mut().select_next();
if self.is_synced() {
self.inactive_panel_mut().select_next();
}
}
pub fn select_prev(&mut self) {
self.active_panel_mut().select_prev();
if self.is_synced() {
self.inactive_panel_mut().select_prev();
}
}
pub fn select_first(&mut self) {
self.active_panel_mut().select_first();
if self.is_synced() {
self.inactive_panel_mut().select_first();
}
}
pub fn select_last(&mut self) {
self.active_panel_mut().select_last();
if self.is_synced() {
self.inactive_panel_mut().select_last();
}
}
pub fn page_up(&mut self) {
self.active_panel_mut().page_up();
if self.is_synced() {
self.inactive_panel_mut().page_up();
}
}
pub fn page_down(&mut self) {
self.active_panel_mut().page_down();
if self.is_synced() {
self.inactive_panel_mut().page_down();
}
}
pub fn toggle_detail(&mut self) {
self.show_detail = !self.show_detail;
self.detail_scroll = 0;
}
pub fn get_selected_detail(&mut self) -> Option<String> {
let panel = self.active_panel_mut();
panel.ensure_flat_cache();
let item = panel.cached_flat_items.get(panel.selected)?;
let mut lines = Vec::new();
lines.push(format!("Path: {}", item.node_id));
lines.push(format!("Key: {}", item.display_key));
if !item.value_preview.is_empty() {
lines.push(format!("Value: {}", item.value_preview));
}
if let Some(vt) = item.value_type {
lines.push(format!("Type: {vt:?}"));
}
if item.is_expandable {
lines.push(format!("Children: {}", item.child_count_label));
}
lines.push(format!("Depth: {}", item.depth));
if let Some(status) = panel.find_annotation(&item.node_id) {
lines.push(format!("Change: {status:?}"));
}
Some(lines.join("\n"))
}
pub fn toggle_align(&mut self) {
self.align_enabled = !self.align_enabled;
self.alignment_applied = false;
self.old_panel.invalidate_flat_cache();
self.new_panel.invalidate_flat_cache();
}
pub fn align_component_panels(&mut self) {
if !self.align_enabled || self.old_panel.change_annotations.is_empty() {
return;
}
if self.alignment_applied {
return;
}
self.old_panel.ensure_flat_cache();
self.new_panel.ensure_flat_cache();
let old_comp_entries: Vec<(usize, String, Option<SourceChangeStatus>)> = self
.old_panel
.cached_flat_items
.iter()
.enumerate()
.filter(|(_, item)| item.depth == 2 && item.node_id.starts_with("root.components.["))
.map(|(i, item)| {
let status = self
.old_panel
.change_annotations
.get(&item.node_id)
.copied();
(i, item.node_id.clone(), status)
})
.collect();
let new_comp_entries: Vec<(usize, String, Option<SourceChangeStatus>)> = self
.new_panel
.cached_flat_items
.iter()
.enumerate()
.filter(|(_, item)| item.depth == 2 && item.node_id.starts_with("root.components.["))
.map(|(i, item)| {
let status = self
.new_panel
.change_annotations
.get(&item.node_id)
.copied();
(i, item.node_id.clone(), status)
})
.collect();
if old_comp_entries.is_empty() && new_comp_entries.is_empty() {
return;
}
let old_comp_spans =
compute_component_spans(&self.old_panel.cached_flat_items, &old_comp_entries);
let new_comp_spans =
compute_component_spans(&self.new_panel.cached_flat_items, &new_comp_entries);
let mut old_insertions: Vec<(usize, usize)> = Vec::new(); let mut new_insertions: Vec<(usize, usize)> = Vec::new();
let mut old_idx = 0;
let mut new_idx = 0;
while old_idx < old_comp_entries.len() || new_idx < new_comp_entries.len() {
if old_idx < old_comp_entries.len()
&& matches!(
old_comp_entries[old_idx].2,
Some(SourceChangeStatus::Removed)
)
{
let span = old_comp_spans[old_idx];
let insert_pos = if new_idx < new_comp_entries.len() {
new_comp_entries[new_idx].0
} else {
self.new_panel.cached_flat_items.len()
};
new_insertions.push((insert_pos, span));
old_idx += 1;
continue;
}
if new_idx < new_comp_entries.len()
&& matches!(new_comp_entries[new_idx].2, Some(SourceChangeStatus::Added))
{
let span = new_comp_spans[new_idx];
let insert_pos = if old_idx < old_comp_entries.len() {
old_comp_entries[old_idx].0
} else {
self.old_panel.cached_flat_items.len()
};
old_insertions.push((insert_pos, span));
new_idx += 1;
continue;
}
if old_idx < old_comp_entries.len() {
old_idx += 1;
}
if new_idx < new_comp_entries.len() {
new_idx += 1;
}
}
insert_gap_items(&mut self.old_panel.cached_flat_items, &old_insertions);
insert_gap_items(&mut self.new_panel.cached_flat_items, &new_insertions);
self.old_panel.visible_count = self.old_panel.cached_flat_items.len();
self.new_panel.visible_count = self.new_panel.cached_flat_items.len();
self.alignment_applied = true;
}
}
fn compute_component_spans(
items: &[crate::tui::shared::source::FlatJsonItem],
comp_entries: &[(usize, String, Option<SourceChangeStatus>)],
) -> Vec<usize> {
comp_entries
.iter()
.enumerate()
.map(|(ci, (start_idx, _, _))| {
let next_start = if ci + 1 < comp_entries.len() {
comp_entries[ci + 1].0
} else {
items
.iter()
.enumerate()
.skip(start_idx + 1)
.find(|(_, item)| {
item.depth <= 1
|| (item.depth == 2 && !item.node_id.starts_with("root.components.["))
})
.map_or(items.len(), |(i, _)| i)
};
next_start - start_idx
})
.collect()
}
fn insert_gap_items(
items: &mut Vec<crate::tui::shared::source::FlatJsonItem>,
insertions: &[(usize, usize)],
) {
let mut sorted: Vec<(usize, usize)> = insertions.to_vec();
sorted.sort_by(|a, b| b.0.cmp(&a.0));
for (pos, count) in sorted {
let insert_pos = pos.min(items.len());
let gap_items: Vec<crate::tui::shared::source::FlatJsonItem> = (0..count)
.map(|i| crate::tui::shared::source::FlatJsonItem {
node_id: format!("__gap_{insert_pos}_{i}"),
depth: 2,
display_key: "\u{00b7}\u{00b7}\u{00b7}\u{00b7}\u{00b7}".to_string(),
value_preview: String::new(),
value_type: None,
is_expandable: false,
is_expanded: false,
child_count_label: String::new(),
preview: String::new(),
is_last_sibling: false,
ancestors_last: vec![],
})
.collect();
items.splice(insert_pos..insert_pos, gap_items);
}
}
fn build_component_index(panel: &SourcePanelState) -> HashMap<String, usize> {
let mut map = HashMap::new();
let Some(ref tree) = panel.json_tree else {
return map;
};
let Some(children) = tree.children() else {
return map;
};
for child in children {
if let JsonTreeNode::Array { key, children, .. } = child
&& key == "components"
{
for (idx, comp_node) in children.iter().enumerate() {
if let Some(name) = extract_component_name(comp_node) {
map.insert(name, idx);
}
}
}
}
map
}
fn extract_component_name(node: &JsonTreeNode) -> Option<String> {
if let JsonTreeNode::Object { children, .. } = node {
for child in children {
if let JsonTreeNode::Leaf { key, value, .. } = child
&& key == "name"
{
let v = value.trim_matches('"');
return Some(v.to_string());
}
}
}
None
}