From 32040fb8e82c8b9ddc0ec5dd86c90256cd7f45a3 Mon Sep 17 00:00:00 2001 From: Albert Najjar Date: Fri, 27 Feb 2026 16:28:26 -0500 Subject: [PATCH 1/2] Remove codestory-graph crate --- .codex/environments/environment.toml | 6 + AGENTS.md | 2 +- Cargo.lock | 23 +- Cargo.toml | 2 - README.md | 1 - crates/codestory-bench/Cargo.toml | 5 - .../codestory-bench/benches/graph_layout.rs | 37 - crates/codestory-graph/Cargo.toml | 17 - .../proptest-regressions/layout.txt | 8 - .../proptest-regressions/uml_types.txt | 8 - crates/codestory-graph/src/bundling.rs | 107 - crates/codestory-graph/src/converter.rs | 349 -- crates/codestory-graph/src/edge_router.rs | 750 ----- crates/codestory-graph/src/graph.rs | 398 --- crates/codestory-graph/src/hit_tester.rs | 820 ----- crates/codestory-graph/src/layout.rs | 891 ------ crates/codestory-graph/src/lib.rs | 25 - crates/codestory-graph/src/node_graph.rs | 58 - crates/codestory-graph/src/style.rs | 1253 -------- crates/codestory-graph/src/uml_types.rs | 2846 ----------------- 20 files changed, 11 insertions(+), 7595 deletions(-) create mode 100644 .codex/environments/environment.toml delete mode 100644 crates/codestory-bench/benches/graph_layout.rs delete mode 100644 crates/codestory-graph/Cargo.toml delete mode 100644 crates/codestory-graph/proptest-regressions/layout.txt delete mode 100644 crates/codestory-graph/proptest-regressions/uml_types.txt delete mode 100644 crates/codestory-graph/src/bundling.rs delete mode 100644 crates/codestory-graph/src/converter.rs delete mode 100644 crates/codestory-graph/src/edge_router.rs delete mode 100644 crates/codestory-graph/src/graph.rs delete mode 100644 crates/codestory-graph/src/hit_tester.rs delete mode 100644 crates/codestory-graph/src/layout.rs delete mode 100644 crates/codestory-graph/src/lib.rs delete mode 100644 crates/codestory-graph/src/node_graph.rs delete mode 100644 crates/codestory-graph/src/style.rs delete mode 100644 crates/codestory-graph/src/uml_types.rs diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml new file mode 100644 index 0000000..922aab8 --- /dev/null +++ b/.codex/environments/environment.toml @@ -0,0 +1,6 @@ +# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY +version = 1 +name = "codestory" + +[setup] +script = "cargo check" diff --git a/AGENTS.md b/AGENTS.md index 1886539..158ae4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ ## Project Structure & Module Organization - Rust workspace is defined in `Cargo.toml`; crates live under `crates/`. - Runtime stack: `crates/codestory-server` (Axum API + SSE + static SPA hosting) and `codestory-ui` (Vite + React + TypeScript). -- Core crates: `codestory-core`, `codestory-events`, `codestory-storage`, `codestory-index`, `codestory-search`, `codestory-graph`, `codestory-app`, `codestory-api`, `codestory-project`, `codestory-cli`. +- Core crates: `codestory-core`, `codestory-events`, `codestory-storage`, `codestory-index`, `codestory-search`, `codestory-app`, `codestory-api`, `codestory-project`, `codestory-cli`. - Runtime artifacts: `codestory.db`, `codestory_ui.json`; build outputs in `target/` and `codestory-ui/dist/`. ## Architecture Overview diff --git a/Cargo.lock b/Cargo.lock index c33268f..15ca779 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -442,11 +442,10 @@ dependencies = [ name = "codestory-bench" version = "0.1.0" dependencies = [ - "anyhow", - "codestory-core", - "codestory-events", - "codestory-graph", - "codestory-index", + "anyhow", + "codestory-core", + "codestory-events", + "codestory-index", "codestory-project", "codestory-storage", "criterion", @@ -501,20 +500,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "codestory-graph" -version = "0.1.0" -dependencies = [ - "anyhow", - "codestory-core", - "codestory-events", - "proptest", - "rayon", - "serde", - "serde_json", - "tracing", -] - [[package]] name = "codestory-index" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 875b67b..8064039 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,6 @@ members = [ "crates/codestory-storage", "crates/codestory-index", "crates/codestory-search", - "crates/codestory-graph", "crates/codestory-server", "crates/codestory-cli", "crates/codestory-events", @@ -23,7 +22,6 @@ codestory-app = { path = "crates/codestory-app" } codestory-storage = { path = "crates/codestory-storage" } codestory-index = { path = "crates/codestory-index" } codestory-search = { path = "crates/codestory-search" } -codestory-graph = { path = "crates/codestory-graph" } codestory-events = { path = "crates/codestory-events" } codestory-project = { path = "crates/codestory-project" } codestory-bench = { path = "crates/codestory-bench" } diff --git a/README.md b/README.md index 01a78f7..aef9ff1 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,6 @@ cargo run -p codestory-server -- --types-only --types-out codestory-ui/src/gener - `codestory-index`: tree-sitter + semantic resolution indexing pipeline - `codestory-storage`: SQLite schema and query layer - `codestory-search`: search primitives over indexed data - - `codestory-graph`: graph shaping/layout helpers - `codestory-api`: API DTOs and identifiers shared with frontend - `codestory-app`: headless orchestrator - `codestory-server`: Axum API + SSE + optional static file serving diff --git a/crates/codestory-bench/Cargo.toml b/crates/codestory-bench/Cargo.toml index 06326c1..2e4a40d 100644 --- a/crates/codestory-bench/Cargo.toml +++ b/crates/codestory-bench/Cargo.toml @@ -8,7 +8,6 @@ publish = false codestory-core = { workspace = true } codestory-index = { workspace = true } codestory-storage = { workspace = true } -codestory-graph = { workspace = true } criterion = { workspace = true } tempfile = { workspace = true } anyhow = { workspace = true } @@ -20,10 +19,6 @@ codestory-project = { workspace = true } name = "indexing" harness = false -[[bench]] -name = "graph_layout" -harness = false - [[bench]] name = "graph_fidelity" harness = false diff --git a/crates/codestory-bench/benches/graph_layout.rs b/crates/codestory-bench/benches/graph_layout.rs deleted file mode 100644 index cdba863..0000000 --- a/crates/codestory-bench/benches/graph_layout.rs +++ /dev/null @@ -1,37 +0,0 @@ -use std::hint::black_box; - -use codestory_core::{Node, NodeId, NodeKind}; -use codestory_graph::graph::GraphModel; -use codestory_graph::layout::{Layouter, NestingLayouter}; -use criterion::{Criterion, criterion_group, criterion_main}; - -fn bench_nesting_layout_1000_nodes(c: &mut Criterion) { - let mut model = GraphModel::new(); - let node_count = 1000; - - // Add 1k nodes - for i in 0..node_count { - model.add_node(Node { - id: NodeId(i as i64), - kind: NodeKind::CLASS, - serialized_name: format!("Node_{}", i), - ..Default::default() - }); - } - - let layouter = NestingLayouter { - inner_padding: NestingLayouter::DEFAULT_INNER_PADDING, - child_spacing: NestingLayouter::DEFAULT_CHILD_SPACING, - direction: codestory_core::LayoutDirection::Vertical, - }; - - c.bench_function("nesting_layout_1000_nodes", |b| { - b.iter(|| { - let (positions, sizes) = layouter.execute(black_box(&model)); - black_box((positions, sizes)); - }) - }); -} - -criterion_group!(benches, bench_nesting_layout_1000_nodes); -criterion_main!(benches); diff --git a/crates/codestory-graph/Cargo.toml b/crates/codestory-graph/Cargo.toml deleted file mode 100644 index 59ddd7b..0000000 --- a/crates/codestory-graph/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "codestory-graph" -version = "0.1.0" -edition = "2024" - -[dependencies] -codestory-core = { workspace = true } -codestory-events = { workspace = true } - -anyhow = { workspace = true } -tracing = { workspace = true } -serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } -rayon = { workspace = true } - -[dev-dependencies] -proptest = { workspace = true } diff --git a/crates/codestory-graph/proptest-regressions/layout.txt b/crates/codestory-graph/proptest-regressions/layout.txt deleted file mode 100644 index bf1c3f5..0000000 --- a/crates/codestory-graph/proptest-regressions/layout.txt +++ /dev/null @@ -1,8 +0,0 @@ -# Seeds for failure cases proptest has generated in the past. It is -# automatically read and these particular cases re-run before any -# novel cases are generated. -# -# It is recommended to check this file in to source control so that -# everyone who runs the test benefits from these saved cases. -cc c65556866bb567b83d8b195596f07600989b62c6e840f2993a81dc9d1865fabb # shrinks to model = GraphModel { graph: Graph { nodes: [DummyNode { id: NodeId(0), node_kind: FUNCTION, name: "N0", position: Vec2 { x: 0.0, y: 0.0 }, size: Vec2 { x: 100.0, y: 30.0 }, visible: true, active: false, focused: false, expanded: false, children: [], parent: None, bundle_info: None, bundled_nodes: [], group_type: None, group_layout: GRID }, DummyNode { id: NodeId(1), node_kind: FUNCTION, name: "N1", position: Vec2 { x: 0.0, y: 0.0 }, size: Vec2 { x: 100.0, y: 30.0 }, visible: true, active: false, focused: false, expanded: false, children: [], parent: None, bundle_info: None, bundled_nodes: [], group_type: None, group_layout: GRID }], edges: [] }, node_map: {NodeId(1): NodeIndex(1), NodeId(0): NodeIndex(0)}, root: None } -cc 49ac7dd762ed62d053b55a53cef9bb002b763fe5f2436a26d65ac4a45cbe3528 # shrinks to model = GraphModel { graph: Graph { nodes: [DummyNode { id: NodeId(0), node_kind: FUNCTION, name: "N0", position: Vec2 { x: 0.0, y: 0.0 }, size: Vec2 { x: 100.0, y: 30.0 }, visible: true, active: false, focused: false, expanded: false, children: [], parent: None, bundle_info: None, bundled_nodes: [], group_type: None, group_layout: GRID }, DummyNode { id: NodeId(1), node_kind: FUNCTION, name: "N1", position: Vec2 { x: 0.0, y: 0.0 }, size: Vec2 { x: 100.0, y: 30.0 }, visible: true, active: false, focused: false, expanded: false, children: [], parent: None, bundle_info: None, bundled_nodes: [], group_type: None, group_layout: GRID }], edges: [] }, node_map: {NodeId(0): NodeIndex(0), NodeId(1): NodeIndex(1)}, root: None } diff --git a/crates/codestory-graph/proptest-regressions/uml_types.txt b/crates/codestory-graph/proptest-regressions/uml_types.txt deleted file mode 100644 index a2d889b..0000000 --- a/crates/codestory-graph/proptest-regressions/uml_types.txt +++ /dev/null @@ -1,8 +0,0 @@ -# Seeds for failure cases proptest has generated in the past. It is -# automatically read and these particular cases re-run before any -# novel cases are generated. -# -# It is recommended to check this file in to source control so that -# everyone who runs the test benefits from these saved cases. -cc 41f70f0fb323721d5bf42202b10546a4742c8bd1e5547cfd7aa7b1bf069abd66 # shrinks to (node, _section_info) = (UmlNode { id: NodeId(1), kind: CLASS, label: "TestClass", parent_id: None, is_indexed: true, visibility_sections: [VisibilitySection { kind: Public, members: [], is_collapsed: false }], is_collapsed: false, collapsed_sections: {}, computed_rect: Rect { min: Vec2 { x: 0.0, y: 0.0 }, max: Vec2 { x: 0.0, y: 0.0 } }, member_rects: {}, bundle_info: None }, [(0, false)]) -cc 6d57531f6d1db78267a7e1fa692ce7fcd9880bc712c722f92c9bc91f5e61e7b1 # shrinks to node_positions = [(0.0, 726.13104), (0.0, -903.6724)], viewport_width = 200.0, viewport_height = 238.73259 diff --git a/crates/codestory-graph/src/bundling.rs b/crates/codestory-graph/src/bundling.rs deleted file mode 100644 index b86e834..0000000 --- a/crates/codestory-graph/src/bundling.rs +++ /dev/null @@ -1,107 +0,0 @@ -use crate::graph::{DummyNode, GraphModel, GroupLayout, Vec2}; -use codestory_core::{BundleInfo, NodeId, NodeKind}; -use std::collections::HashMap; - -pub struct NodeBundler { - pub threshold: usize, -} - -impl NodeBundler { - pub fn new(threshold: usize) -> Self { - Self { threshold } - } - - pub fn execute(&self, model: &mut GraphModel) { - let mut bundles_to_create = Vec::new(); - let mut next_bundle_id = -1000; - - // 1. Identify bundles - for node_idx in model.graph.node_indices() { - let node = &model.graph[node_idx]; - - // Only bundle children of expanded nodes or just structural children? - // Usually we bundle regardless of expansion, but visuals hide it. - // But here we are modifying the structure. - - if node.children.is_empty() { - continue; - } - - let mut groups: HashMap> = HashMap::new(); - for &child_id in &node.children { - if let Some(child_node) = model.get_node(child_id) { - // Bundle all types if they happen in large groups within a parent - // This matches Sourcetrail's behavior for "Classes", "Structs", etc. - if !child_node - .bundle_info - .as_ref() - .map(|b| b.is_bundle) - .unwrap_or(false) - { - groups - .entry(child_node.node_kind) - .or_default() - .push(child_id); - } - } - } - - for (kind, children) in groups { - if children.len() > self.threshold { - bundles_to_create.push((node.id, kind, children, next_bundle_id)); - next_bundle_id -= 1; - } - } - } - - // 2. Apply mutations - for (parent_id, kind, children_ids, bundle_id_raw) in bundles_to_create { - let bundle_id = NodeId(bundle_id_raw); - let bundle_name = format!("{}s", format!("{:?}", kind).to_lowercase()); // e.g. "methods" - - // Create bundle node - let bundle_node = DummyNode { - id: bundle_id, - node_kind: kind, - name: bundle_name, - position: Vec2::default(), - size: Vec2::new(150.0, 30.0), // Initial size - visible: true, - active: false, - focused: false, - expanded: false, // Collapsed by default - children: children_ids.clone(), - parent: Some(parent_id), - bundle_info: Some(BundleInfo { - is_bundle: true, - bundle_id: Some(bundle_id_raw), - layout_vertical: true, - connected_count: children_ids.len(), - }), - bundled_nodes: children_ids.clone(), - group_type: None, - group_layout: GroupLayout::LIST, - }; - - // Add to graph - let bundle_idx = model.graph.add_node(bundle_node); - model.node_map.insert(bundle_id, bundle_idx); - - // Update parent: Remove individual children, add bundle - if let Some(parent_idx) = model.node_map.get(&parent_id) { - // Remove bundled children from parent's children list - // (We keep them in the graph, but re-parent them effectively) - let parent = &mut model.graph[*parent_idx]; - parent.children.retain(|c| !children_ids.contains(c)); - parent.children.push(bundle_id); - } - - // Update children: Set parent to bundle - for child_id in children_ids { - if let Some(child_node) = model.get_node_mut(child_id) { - child_node.parent = Some(bundle_id); - } - } - } - } -} diff --git a/crates/codestory-graph/src/converter.rs b/crates/codestory-graph/src/converter.rs deleted file mode 100644 index 523793d..0000000 --- a/crates/codestory-graph/src/converter.rs +++ /dev/null @@ -1,349 +0,0 @@ -use crate::graph::{DummyEdge, DummyNode}; -use crate::node_graph::{ - NodeGraph, NodeGraphEdge, NodeGraphNode, NodeGraphPin, NodeMember, PinType, -}; -use crate::uml_types::{MemberItem, UmlNode, VisibilityKind, VisibilitySection}; -use codestory_core::{EdgeKind, NodeId, NodeKind}; -use std::collections::HashMap; - -type NodeGraphPinMap = HashMap, Vec)>; -type UmlConversionResult = (Vec, Vec, NodeGraphPinMap); - -pub struct NodeGraphConverter; - -impl NodeGraphConverter { - pub fn new() -> Self { - Self - } - - fn is_structural(kind: NodeKind) -> bool { - matches!( - kind, - NodeKind::CLASS - | NodeKind::STRUCT - | NodeKind::INTERFACE - | NodeKind::UNION - | NodeKind::ENUM - | NodeKind::NAMESPACE - | NodeKind::MODULE - ) - } - - fn is_bundle(node: &DummyNode) -> bool { - node.bundle_info - .as_ref() - .map(|b| b.is_bundle) - .unwrap_or(false) - } - - fn default_pins(kind: NodeKind) -> (Vec, Vec) { - let inputs = vec![NodeGraphPin { - label: "References".to_string(), - pin_type: PinType::Standard, - }]; - - let mut outputs = vec![NodeGraphPin { - label: "Calls".to_string(), - pin_type: PinType::Standard, - }]; - - if Self::is_structural(kind) { - outputs.push(NodeGraphPin { - label: "Inherited By".to_string(), - pin_type: PinType::Inheritance, - }); - } - - (inputs, outputs) - } - - fn parent_is_bundle(node_map: &HashMap, parent_id: NodeId) -> bool { - node_map - .get(&parent_id) - .map(|n| Self::is_bundle(n)) - .unwrap_or(false) - } - - fn edge_pin(edge_kind: EdgeKind) -> (PinType, usize) { - match edge_kind { - EdgeKind::INHERITANCE | EdgeKind::OVERRIDE => (PinType::Inheritance, 1), - _ => (PinType::Standard, 0), - } - } - - fn member_visibility(node_kind: NodeKind) -> VisibilityKind { - match node_kind { - NodeKind::FUNCTION | NodeKind::METHOD | NodeKind::MACRO => VisibilityKind::Functions, - NodeKind::FIELD - | NodeKind::VARIABLE - | NodeKind::GLOBAL_VARIABLE - | NodeKind::CONSTANT - | NodeKind::ENUM_CONSTANT => VisibilityKind::Variables, - _ => VisibilityKind::Other, - } - } - - fn build_node_map(nodes: &[DummyNode]) -> HashMap { - nodes.iter().map(|node| (node.id, node)).collect() - } - - fn should_emit_host_node(node: &DummyNode) -> bool { - let is_bundle = Self::is_bundle(node); - let is_structural = Self::is_structural(node.node_kind); - if is_bundle && !is_structural { - return false; - } - if !is_bundle && !is_structural && node.parent.is_some() { - return false; - } - true - } - - fn for_each_member(nodes: &[DummyNode], node_map: &HashMap, mut f: F) - where - F: FnMut(NodeId, &DummyNode), - { - for node in nodes { - if Self::is_structural(node.node_kind) { - continue; - } - if Self::is_bundle(node) { - if let Some(parent_id) = node.parent { - for &child_id in &node.children { - if let Some(child) = node_map.get(&child_id) { - f(parent_id, child); - } - } - } - continue; - } - - if let Some(parent_id) = node.parent - && !Self::parent_is_bundle(node_map, parent_id) - { - f(parent_id, node); - } - } - } - - pub fn convert_dummies(&self, nodes: &[DummyNode], edges: &[DummyEdge]) -> NodeGraph { - let mut graph_nodes: HashMap = HashMap::new(); - let mut graph_edges = Vec::new(); - let node_map = Self::build_node_map(nodes); - let mut member_to_host: HashMap = HashMap::new(); - - // 1. Create Structural Nodes - for node in nodes { - if !Self::should_emit_host_node(node) { - continue; - } - - // Create the node - let (inputs, outputs) = Self::default_pins(node.node_kind); - - graph_nodes.insert( - node.id, - NodeGraphNode { - id: node.id, - parent_id: node.parent, - kind: node.node_kind, - label: node.name.clone(), - members: Vec::new(), - inputs, - outputs, - bundle_info: node.bundle_info.clone(), - // TODO: Set is_indexed based on actual indexing status from storage - // This requires: - // 1. Adding is_indexed BOOLEAN field to storage node table schema - // 2. Updating indexing pipeline to set is_indexed=1 when processing files - // 3. Updating Node struct in codestory-core to include is_indexed field - // 4. Passing through is_indexed from storage queries to this conversion - // For now, default to true (all nodes treated as indexed, hatching pattern disabled) - is_indexed: true, - }, - ); - } - - // 2. Process Members (populate members list and member_to_host map) - Self::for_each_member(nodes, &node_map, |parent_id, member| { - if let Some(host) = graph_nodes.get_mut(&parent_id) { - host.members.push(NodeMember { - id: member.id, - name: member.name.clone(), - kind: member.node_kind, - }); - member_to_host.insert(member.id, parent_id); - } - }); - - // 3. Convert Edges - for edge in edges { - // Resolve endpoints - let source = *member_to_host.get(&edge.source).unwrap_or(&edge.source); - let target = *member_to_host.get(&edge.target).unwrap_or(&edge.target); - - // Skip member edges (they are implicit in the list) - if edge.kind == EdgeKind::MEMBER { - continue; - } - - // Check if source/target exist in graph_nodes - if !graph_nodes.contains_key(&source) || !graph_nodes.contains_key(&target) { - continue; - } - - // Reroute self-loops? Or keep them? - // If Class A calls Class A (internal call), it's a self-loop. - // Snarl can handle self-loops. - - let (pin_type, source_output_index) = Self::edge_pin(edge.kind); - - let source_node = graph_nodes.get(&source).unwrap(); - let valid_index = if source_output_index < source_node.outputs.len() { - source_output_index - } else { - 0 - }; - - graph_edges.push(NodeGraphEdge { - id: edge.id, - source_node: source, - source_output_index: valid_index, - target_node: target, - target_input_index: 0, - edge_type: pin_type, - }); - } - - NodeGraph { - nodes: graph_nodes.into_values().collect(), - edges: graph_edges, - } - } - - /// Convert DummyNode/DummyEdge to UmlNode with pre-grouped visibility sections. - /// Also returns pin information for the adapter to store separately. - /// Returns pin information for the adapter to store separately. - pub fn convert_dummies_to_uml( - &self, - nodes: &[DummyNode], - edges: &[DummyEdge], - ) -> UmlConversionResult { - let mut uml_nodes: HashMap = HashMap::new(); - let mut graph_edges = Vec::new(); - let mut pin_info: NodeGraphPinMap = HashMap::new(); - let node_map = Self::build_node_map(nodes); - let mut member_to_host: HashMap = HashMap::new(); - - // Build set of member IDs that have outgoing edges - // A member has outgoing edges if it appears as the source of any edge - let mut members_with_outgoing_edges = std::collections::HashSet::new(); - for edge in edges { - // Check if the edge source is a member (non-structural node) - if let Some(source_node) = node_map.get(&edge.source) - && !Self::is_structural(source_node.node_kind) - { - members_with_outgoing_edges.insert(edge.source); - } - } - - // 1. Create Structural Nodes (classes, structs, etc.) - for node in nodes { - if !Self::should_emit_host_node(node) { - continue; - } - - // Create UmlNode - let mut uml_node = UmlNode::new(node.id, node.node_kind, node.name.clone()); - uml_node.parent_id = node.parent; - uml_node.is_indexed = true; // TODO: same as before - uml_node.bundle_info = node.bundle_info.as_ref().map(|bi| { - crate::uml_types::BundleInfo { - bundled_node_ids: Vec::new(), // TODO: populate if needed - count: bi.connected_count, - is_expanded: false, - } - }); - - uml_nodes.insert(node.id, uml_node); - - // Compute pins for this node - let (inputs, outputs) = Self::default_pins(node.node_kind); - - pin_info.insert(node.id, (inputs, outputs)); - } - - // 2. Group Members into Visibility Sections - Self::for_each_member(nodes, &node_map, |parent_id, member_node| { - if let Some(host) = uml_nodes.get_mut(&parent_id) { - let mut member = MemberItem::new( - member_node.id, - member_node.node_kind, - member_node.name.clone(), - ); - member - .set_has_outgoing_edges(members_with_outgoing_edges.contains(&member_node.id)); - - let visibility = Self::member_visibility(member_node.node_kind); - if let Some(section) = host - .visibility_sections - .iter_mut() - .find(|s| s.kind == visibility) - { - section.members.push(member); - } else { - host.visibility_sections - .push(VisibilitySection::with_members(visibility, vec![member])); - } - member_to_host.insert(member_node.id, parent_id); - } - }); - - // 3. Convert Edges (same logic as before) - for edge in edges { - // Resolve endpoints - let source = *member_to_host.get(&edge.source).unwrap_or(&edge.source); - let target = *member_to_host.get(&edge.target).unwrap_or(&edge.target); - - // Skip member edges - if edge.kind == EdgeKind::MEMBER { - continue; - } - - // Check if source/target exist - if !uml_nodes.contains_key(&source) || !uml_nodes.contains_key(&target) { - continue; - } - - let (pin_type, source_output_index) = Self::edge_pin(edge.kind); - - // Validate pin index - let valid_index = if let Some((_, outputs)) = pin_info.get(&source) { - if source_output_index < outputs.len() { - source_output_index - } else { - 0 - } - } else { - 0 - }; - - graph_edges.push(NodeGraphEdge { - id: edge.id, - source_node: source, - source_output_index: valid_index, - target_node: target, - target_input_index: 0, - edge_type: pin_type, - }); - } - - (uml_nodes.into_values().collect(), graph_edges, pin_info) - } -} - -impl Default for NodeGraphConverter { - fn default() -> Self { - Self::new() - } -} diff --git a/crates/codestory-graph/src/edge_router.rs b/crates/codestory-graph/src/edge_router.rs deleted file mode 100644 index 7ec451c..0000000 --- a/crates/codestory-graph/src/edge_router.rs +++ /dev/null @@ -1,750 +0,0 @@ -use crate::Vec2; -use crate::uml_types::{BundleData, Rect}; -use codestory_core::{EdgeId, EdgeKind, NodeId}; -use std::collections::HashMap; - -/// Minimum number of edges between the same source/target to trigger bundling. -/// -/// **Validates: Requirements 14.1, Property 10: Edge Bundling Threshold** -pub const BUNDLE_THRESHOLD: usize = 3; - -/// Descriptor for an edge, used as input to `bundle_edges()`. -#[derive(Debug, Clone)] -pub struct EdgeDescriptor { - pub id: EdgeId, - pub source_node: NodeId, - pub target_node: NodeId, - pub kind: EdgeKind, - pub source_label: String, - pub target_label: String, -} - -/// A group of edges that have been bundled together. -#[derive(Debug, Clone)] -pub struct EdgeBundleGroup { - pub source_node: NodeId, - pub target_node: NodeId, - pub edge_ids: Vec, - pub data: BundleData, -} - -/// Result of edge bundling: some edges are bundled, others remain individual. -#[derive(Debug, Clone)] -pub struct BundleResult { - /// Edge groups that meet the bundling threshold (3+). - pub bundles: Vec, - /// Individual edges that did not meet the threshold. - pub unbundled: Vec, -} - -/// A cubic bezier curve segment defined by four control points. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct CubicBezier { - pub start: Vec2, - pub control1: Vec2, - pub control2: Vec2, - pub end: Vec2, -} - -impl CubicBezier { - /// Sample the curve at parameter t [0, 1] - pub fn sample(&self, t: f32) -> Vec2 { - let t2 = t * t; - let t3 = t2 * t; - let mt = 1.0 - t; - let mt2 = mt * mt; - let mt3 = mt2 * mt; - - let x = self.start.x * mt3 - + 3.0 * self.control1.x * mt2 * t - + 3.0 * self.control2.x * mt * t2 - + self.end.x * t3; - let y = self.start.y * mt3 - + 3.0 * self.control1.y * mt2 * t - + 3.0 * self.control2.y * mt * t2 - + self.end.y * t3; - - Vec2::new(x, y) - } - - /// Compute the minimum distance from a point to this bezier curve. - /// - /// Uses uniform sampling along the curve to find the closest point. - /// The `num_samples` parameter controls accuracy (higher = more precise but slower). - /// - /// # Arguments - /// * `point` - The point to test against - /// * `num_samples` - Number of samples along the curve (typically 20-50) - /// - /// # Returns - /// The minimum distance from the point to any sampled point on the curve. - /// - /// **Validates: Requirements 8.1, 11.2** - pub fn point_distance(&self, point: Vec2, num_samples: usize) -> f32 { - let mut min_dist_sq = f32::INFINITY; - let samples = num_samples.max(2); - - for i in 0..=samples { - let t = i as f32 / samples as f32; - let curve_point = self.sample(t); - let dx = curve_point.x - point.x; - let dy = curve_point.y - point.y; - let dist_sq = dx * dx + dy * dy; - if dist_sq < min_dist_sq { - min_dist_sq = dist_sq; - } - } - - min_dist_sq.sqrt() - } -} - -/// Router for calculating edge paths between nodes and members. -#[derive(Debug, Clone, Copy)] -pub struct EdgeRouter { - /// Margin around nodes to avoid routing through - pub node_margin: f32, - /// Curvature factor for bezier control points (usually related to distance) - pub curvature: f32, -} - -impl Default for EdgeRouter { - fn default() -> Self { - Self { - node_margin: 20.0, - curvature: 0.5, - } - } -} - -impl EdgeRouter { - pub fn new() -> Self { - Self::default() - } - - /// Calculate a cubic bezier route between two rectangles. - /// - /// # Arguments - /// * `source_rect` - The bounding box of the source node or member. - /// * `target_rect` - The bounding box of the target node or member. - /// - /// Returns a `CubicBezier` curve. - pub fn route_edge(&self, source_rect: Rect, target_rect: Rect) -> CubicBezier { - let start = self.calculate_anchor(source_rect, target_rect.center()); - let end = self.calculate_anchor(target_rect, source_rect.center()); - - self.calculate_curve(start, end, source_rect, target_rect) - } - - /// Calculate curve points giving start and end positions and their bounding boxes - /// to determine control point direction. - fn calculate_curve( - &self, - start: Vec2, - end: Vec2, - source_rect: Rect, - target_rect: Rect, - ) -> CubicBezier { - // Prevent control points from scaling unbounded with edge length. - // If they do, edges can "swing" far outside the viewport and look comically long. - const MAX_CONTROL_LEN: f32 = 260.0; - let delta = end - start; - - // Use a directionally-biased "distance" instead of full Euclidean distance. - // This keeps curves stable when nodes are far apart vertically but close horizontally, - // and also reduces the chance of overshooting control points. - let dx = delta.x.abs(); - let dy = delta.y.abs(); - let primary_dist = dx.max(dy * 0.5); - - let control_dist = primary_dist * self.curvature; - - // Determine face directions based on anchor points relative to rect centers - let start_dir = self.get_normal_direction(start, source_rect); - let end_dir = self.get_normal_direction(end, target_rect); - - let mut curve_len = if primary_dist < self.node_margin * 2.0 { - // For very close nodes, keep control points close so we don't create loops. - control_dist - } else { - control_dist.max(self.node_margin) - }; - if curve_len.is_finite() { - curve_len = curve_len.min(MAX_CONTROL_LEN); - } else { - curve_len = self.node_margin.min(MAX_CONTROL_LEN); - } - - // control1 = start + start_dir * curve_len - let control1 = start + (start_dir * curve_len); - // control2 = end + end_dir * curve_len - let control2 = end + (end_dir * curve_len); - - CubicBezier { - start, - control1, - control2, - end, - } - } - - /// Calculate the best anchor point on the border of `rect` to connect to `target_center`. - pub fn calculate_anchor(&self, rect: Rect, target_center: Vec2) -> Vec2 { - let center = rect.center(); - let len_sq = |v: Vec2| v.x * v.x + v.y * v.y; - - let vec = target_center - center; - - if len_sq(vec) < 1.0 { - return center; - } - - // Ray casting from center to target, intersecting rect bounds. - // Rect sides: x=left, x=right, y=top, y=bottom - - let mut t_min = f32::INFINITY; - - // Helper to check standard intersection - let check_t = |t: f32, start: f32, dir: f32, min: f32, max: f32| -> Option { - if t > 0.0 { - let pos = start + t * dir; - if pos >= min && pos <= max { - return Some(t); - } - } - None - }; - - // Check X sides (left and right) - if vec.x.abs() > 0.001 { - let t_left = (rect.min.x - center.x) / vec.x; - if let Some(t) = check_t(t_left, center.y, vec.y, rect.min.y, rect.max.y) { - t_min = t_min.min(t); - } - - let t_right = (rect.max.x - center.x) / vec.x; - if let Some(t) = check_t(t_right, center.y, vec.y, rect.min.y, rect.max.y) { - t_min = t_min.min(t); - } - } - - // Check Y sides (top and bottom) - if vec.y.abs() > 0.001 { - let t_top = (rect.min.y - center.y) / vec.y; - if let Some(t) = check_t(t_top, center.x, vec.x, rect.min.x, rect.max.x) { - t_min = t_min.min(t); - } - - let t_bottom = (rect.max.y - center.y) / vec.y; - if let Some(t) = check_t(t_bottom, center.x, vec.x, rect.min.x, rect.max.x) { - t_min = t_min.min(t); - } - } - - if t_min.is_infinite() { - return center; // Should not happen ideally - } - - center + (vec * t_min) - } - - /// Bundle edges that share the same source and target nodes. - /// - /// Groups edges by their (source_node, target_node) pair. When 3 or more edges - /// share the same endpoints, they are bundled into a single `EdgeBundle`. - /// Edges below the threshold are returned as-is (unbundled). - /// - /// # Arguments - /// * `edges` - Slice of edge descriptors to consider for bundling. - /// - /// # Returns - /// A `BundleResult` containing both bundled groups and unbundled individual edges. - /// - /// **Validates: Requirements 3.5, 14.1 and Property 10: Edge Bundling Threshold** - pub fn bundle_edges(&self, edges: &[EdgeDescriptor]) -> BundleResult { - // Group edges by (source_node, target_node) - let mut groups: HashMap<(NodeId, NodeId), Vec<&EdgeDescriptor>> = HashMap::new(); - for edge in edges { - groups - .entry((edge.source_node, edge.target_node)) - .or_default() - .push(edge); - } - - let mut bundles = Vec::new(); - let mut unbundled = Vec::new(); - - for ((source_node, target_node), group) in groups { - if group.len() >= BUNDLE_THRESHOLD { - // Create a bundle - let mut edge_ids = Vec::with_capacity(group.len()); - let mut edge_kinds = Vec::with_capacity(group.len()); - let mut relationships = Vec::with_capacity(group.len()); - for edge in group { - edge_ids.push(edge.id); - edge_kinds.push(edge.kind); - relationships.push(( - edge.source_label.clone(), - edge.target_label.clone(), - edge.kind, - )); - } - - let bundle_data = BundleData::new(edge_ids.clone(), edge_kinds, relationships); - - bundles.push(EdgeBundleGroup { - source_node, - target_node, - edge_ids, - data: bundle_data, - }); - } else { - // Keep as individual edges - for edge in group { - unbundled.push(edge.clone()); - } - } - } - - BundleResult { bundles, unbundled } - } - - /// Get approximate normal direction from rect center to point on its border - fn get_normal_direction(&self, point: Vec2, rect: Rect) -> Vec2 { - // Avoid hard epsilons against border equality: we often get points that are - // microscopically inside/outside due to transform + float math, and a strict check - // can pick the wrong direction (which then produces very long curves). - let dl = (point.x - rect.min.x).abs(); - let dr = (point.x - rect.max.x).abs(); - let dt = (point.y - rect.min.y).abs(); - let db = (point.y - rect.max.y).abs(); - - if dl <= dr && dl <= dt && dl <= db { - return Vec2::new(-1.0, 0.0); - } - if dr <= dl && dr <= dt && dr <= db { - return Vec2::new(1.0, 0.0); - } - if dt <= dl && dt <= dr && dt <= db { - return Vec2::new(0.0, -1.0); - } - Vec2::new(0.0, 1.0) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::Vec2; - use crate::uml_types::Rect; - use proptest::prelude::*; - - // Helper to check if a point is approximately on a rect border - fn is_on_border(p: Vec2, r: Rect, epsilon: f32) -> bool { - let within_vertical_span = p.y >= r.min.y - epsilon && p.y <= r.max.y + epsilon; - let within_horizontal_span = p.x >= r.min.x - epsilon && p.x <= r.max.x + epsilon; - ((p.x - r.min.x).abs() < epsilon || (p.x - r.max.x).abs() < epsilon) && within_vertical_span - || ((p.y - r.min.y).abs() < epsilon || (p.y - r.max.y).abs() < epsilon) - && within_horizontal_span - } - - // Custom distance impl - fn distance(a: Vec2, b: Vec2) -> f32 { - ((a.x - b.x).powi(2) + (a.y - b.y).powi(2)).sqrt() - } - - fn rect_strategy() -> impl Strategy { - ( - 0.0f32..1000.0, - 0.0f32..1000.0, - 10.0f32..100.0, - 10.0f32..100.0, - ) - .prop_map(|(x, y, w, h)| Rect::from_pos_size(Vec2::new(x, y), Vec2::new(w, h))) - } - - proptest! { - /// Property 9: Bezier structure continuity - #[test] - fn prop_bezier_continuity( - source_rect in rect_strategy(), - target_rect in rect_strategy() - ) { - prop_assume!(distance(source_rect.center(), target_rect.center()) > 1.0); - - let router = EdgeRouter::new(); - let curve = router.route_edge(source_rect, target_rect); - - let expected_start = router.calculate_anchor(source_rect, target_rect.center()); - let expected_end = router.calculate_anchor(target_rect, source_rect.center()); - - let start_dist = distance(curve.start, expected_start); - let end_dist = distance(curve.end, expected_end); - - prop_assert!(start_dist < 0.001, "Curve start should match source anchor. Got {:?}, expected {:?}", curve.start, expected_start); - prop_assert!(end_dist < 0.001, "Curve end should match target anchor. Got {:?}, expected {:?}", curve.end, expected_end); - } - - /// Property 11: Edge Anchoring - #[test] - fn prop_anchor_positioning( - rect in rect_strategy(), - target_x in -1000.0f32..2000.0, - target_y in -1000.0f32..2000.0 - ) { - let target = Vec2::new(target_x, target_y); - let router = EdgeRouter::new(); - - // Check if point is inside - let is_inside = target.x >= rect.min.x && target.x <= rect.max.x && - target.y >= rect.min.y && target.y <= rect.max.y; - - if !is_inside { - let anchor = router.calculate_anchor(rect, target); - prop_assert!(is_on_border(anchor, rect, 0.1), "Anchor {:?} should be on border of {:?}", anchor, rect); - } - } - - /// Property 12: Collapsed Node Routing - #[test] - fn prop_collapsed_node_routing( - node_rect in rect_strategy(), - target_pos_x in 0.0f32..1000.0, - target_pos_y in 0.0f32..1000.0 - ) { - let target_pos = Vec2::new(target_pos_x, target_pos_y); - - // Member inside - let member_rect = Rect::from_pos_size( - Vec2::new(node_rect.min.x + 5.0, node_rect.min.y + 5.0), - Vec2::new(20.0, 10.0) - ); - - // Ensure member is inside - prop_assume!(member_rect.max.x < node_rect.max.x && member_rect.max.y < node_rect.max.y); - - // Ensure target is outside node - let is_inside_node = target_pos.x >= node_rect.min.x && target_pos.x <= node_rect.max.x && - target_pos.y >= node_rect.min.y && target_pos.y <= node_rect.max.y; - prop_assume!(!is_inside_node); - - let router = EdgeRouter::new(); - - // Expanded -> Member anchor - let member_anchor = router.calculate_anchor(member_rect, target_pos); - prop_assert!(is_on_border(member_anchor, member_rect, 0.1)); - - // Collapsed -> Node anchor - let node_anchor = router.calculate_anchor(node_rect, target_pos); - prop_assert!(is_on_border(node_anchor, node_rect, 0.1)); - - if !is_on_border(member_anchor, node_rect, 1.0) { - prop_assert!(distance(member_anchor, node_anchor) > 0.001); - } - } - } - - /// Strategy to generate EdgeKind values - fn edge_kind_strategy() -> impl Strategy { - prop_oneof![ - Just(EdgeKind::MEMBER), - Just(EdgeKind::TYPE_USAGE), - Just(EdgeKind::USAGE), - Just(EdgeKind::CALL), - Just(EdgeKind::INHERITANCE), - Just(EdgeKind::OVERRIDE), - Just(EdgeKind::TYPE_ARGUMENT), - Just(EdgeKind::TEMPLATE_SPECIALIZATION), - Just(EdgeKind::INCLUDE), - Just(EdgeKind::IMPORT), - Just(EdgeKind::MACRO_USAGE), - Just(EdgeKind::ANNOTATION_USAGE), - Just(EdgeKind::UNKNOWN), - ] - } - - /// Strategy to generate a vector of EdgeDescriptors between two fixed nodes - fn edge_descriptors_strategy( - min_count: usize, - max_count: usize, - ) -> impl Strategy> { - proptest::collection::vec(edge_kind_strategy(), min_count..=max_count).prop_map(|kinds| { - kinds - .into_iter() - .enumerate() - .map(|(i, kind)| EdgeDescriptor { - id: EdgeId(i as i64 + 1), - source_node: NodeId(1), - target_node: NodeId(2), - kind, - source_label: "SourceNode".to_string(), - target_label: "TargetNode".to_string(), - }) - .collect() - }) - } - - /// Strategy to generate EdgeDescriptors across multiple node pairs - fn multi_pair_edge_descriptors_strategy() -> impl Strategy> { - // Generate 1-10 edges for up to 4 node pairs - ( - proptest::collection::vec(edge_kind_strategy(), 0..=5), // pair (1,2) - proptest::collection::vec(edge_kind_strategy(), 0..=5), // pair (1,3) - proptest::collection::vec(edge_kind_strategy(), 0..=5), // pair (2,3) - proptest::collection::vec(edge_kind_strategy(), 0..=5), // pair (3,4) - ) - .prop_map(|(p12, p13, p23, p34)| { - let mut edges = Vec::new(); - let mut id = 1i64; - - let pairs = [ - (NodeId(1), NodeId(2), p12), - (NodeId(1), NodeId(3), p13), - (NodeId(2), NodeId(3), p23), - (NodeId(3), NodeId(4), p34), - ]; - - for (src, tgt, kinds) in pairs { - for kind in kinds { - edges.push(EdgeDescriptor { - id: EdgeId(id), - source_node: src, - target_node: tgt, - kind, - source_label: format!("Node({})", src.0), - target_label: format!("Node({})", tgt.0), - }); - id += 1; - } - } - - edges - }) - } - - proptest! { - /// **Validates: Property 10: Edge Bundling Threshold** - /// - /// For any set of edges, bundle_edges() SHALL only create bundles when - /// 3 or more edges share the same source and target nodes. - /// Groups with fewer than 3 edges MUST remain unbundled. - #[test] - fn prop_edge_bundling_threshold( - edges in multi_pair_edge_descriptors_strategy() - ) { - let router = EdgeRouter::new(); - let result = router.bundle_edges(&edges); - - // Count edges per (source, target) pair - let mut pair_counts: HashMap<(NodeId, NodeId), usize> = HashMap::new(); - for edge in &edges { - *pair_counts.entry((edge.source_node, edge.target_node)).or_default() += 1; - } - - // Property: Every bundle must have >= BUNDLE_THRESHOLD edges - for bundle in &result.bundles { - prop_assert!( - bundle.edge_ids.len() >= BUNDLE_THRESHOLD, - "Bundle ({:?}, {:?}) has {} edges, below threshold {}", - bundle.source_node, bundle.target_node, - bundle.edge_ids.len(), BUNDLE_THRESHOLD - ); - } - - // Property: No unbundled edge should belong to a pair with >= BUNDLE_THRESHOLD edges - for edge in &result.unbundled { - let pair_count = pair_counts[&(edge.source_node, edge.target_node)]; - prop_assert!( - pair_count < BUNDLE_THRESHOLD, - "Unbundled edge {:?} belongs to pair ({:?}, {:?}) with {} edges, should be bundled", - edge.id, edge.source_node, edge.target_node, pair_count - ); - } - - // Property: Total edge count is preserved - let total_bundled: usize = result.bundles.iter().map(|b| b.edge_ids.len()).sum(); - let total_unbundled = result.unbundled.len(); - prop_assert_eq!( - total_bundled + total_unbundled, - edges.len(), - "Total edges must be preserved: {} bundled + {} unbundled != {} input", - total_bundled, total_unbundled, edges.len() - ); - } - - /// **Validates: Property 30: Edge Bundle Count Badge** - /// - /// For any bundled edge group with N edges, the BundleData SHALL report - /// edge_count() == N and the count must match the number of edge_ids. - #[test] - fn prop_bundle_count_badge( - edges in edge_descriptors_strategy(BUNDLE_THRESHOLD, 20) - ) { - let router = EdgeRouter::new(); - let result = router.bundle_edges(&edges); - - // Since all edges go between (1,2), we should get exactly one bundle - prop_assert_eq!( - result.bundles.len(), 1, - "All edges share same endpoints, should produce exactly 1 bundle" - ); - - let bundle = &result.bundles[0]; - - // Property: edge_count matches the number of input edges - prop_assert_eq!( - bundle.data.edge_count(), edges.len(), - "Bundle count badge should show {} edges, got {}", - edges.len(), bundle.data.edge_count() - ); - - // Property: edge_ids length matches - prop_assert_eq!( - bundle.edge_ids.len(), edges.len(), - "Bundle edge_ids length should match input edge count" - ); - - // Property: relationships count matches - prop_assert_eq!( - bundle.data.relationships.len(), edges.len(), - "Bundle relationships count should match input edge count" - ); - - // Property: All input edge IDs are present in the bundle - for edge in &edges { - prop_assert!( - bundle.edge_ids.contains(&edge.id), - "Edge {:?} should be in bundle", edge.id - ); - } - } - - /// **Validates: Property 31: Edge Bundle Thickness Scaling** - /// - /// For any bundled edge group, the thickness SHALL follow logarithmic scaling: - /// min(log2(N) + base_width, max_width) where base_width = 1.0 and max_width = 6.0. - /// Thickness must always be >= base_width and <= max_width. - #[test] - fn prop_bundle_thickness_scaling( - edge_count in BUNDLE_THRESHOLD..100usize - ) { - use crate::uml_types::BundleData; - - let thickness = BundleData::calculate_thickness(edge_count); - - // Property: Thickness must be at least base_width (1.0) - prop_assert!( - thickness >= 1.0, - "Thickness {} for count {} should be >= 1.0", - thickness, edge_count - ); - - // Property: Thickness must not exceed max_width (6.0) - prop_assert!( - thickness <= 6.0, - "Thickness {} for count {} should be <= 6.0", - thickness, edge_count - ); - - // Property: Thickness should follow log2 formula - let expected = ((edge_count as f32).log2() + 1.0).min(6.0); - prop_assert!( - (thickness - expected).abs() < 0.001, - "Thickness {} should equal log2({}) + 1.0 = {}, clamped to 6.0", - thickness, edge_count, expected - ); - - // Property: Monotonicity -- more edges means >= thickness - if edge_count > BUNDLE_THRESHOLD { - let prev_thickness = BundleData::calculate_thickness(edge_count - 1); - prop_assert!( - thickness >= prev_thickness, - "Thickness should be monotonically non-decreasing: {} (count {}) < {} (count {})", - thickness, edge_count, prev_thickness, edge_count - 1 - ); - } - } - - /// **Validates: Property 32: Edge Bundle Expansion** - /// - /// When a bundle is expanded, all individual edges SHALL become visible. - /// When collapsed, only the single bundled representation is shown. - /// Toggling expansion state twice SHALL return to original state. - #[test] - fn prop_edge_bundle_expansion( - edges in edge_descriptors_strategy(BUNDLE_THRESHOLD, 20) - ) { - let router = EdgeRouter::new(); - let result = router.bundle_edges(&edges); - - prop_assert_eq!(result.bundles.len(), 1, "Should produce exactly 1 bundle"); - - let bundle = &result.bundles[0]; - - // Property: BundleData starts not expanded - prop_assert!( - !bundle.data.is_expanded, - "Bundle should start not expanded" - ); - - // Property: toggle_expanded flips state - let mut data = bundle.data.clone(); - data.toggle_expanded(); - prop_assert!( - data.is_expanded, - "Bundle should be expanded after toggle" - ); - - // Property: When expanded, all edge_ids should still be present - prop_assert_eq!( - data.edge_ids.len(), edges.len(), - "Expanded bundle should preserve all edge IDs" - ); - - // Property: All edge kinds should still be present - prop_assert_eq!( - data.edge_kinds.len(), edges.len(), - "Expanded bundle should preserve all edge kinds" - ); - - // Property: Double toggle returns to original state - data.toggle_expanded(); - prop_assert!( - !data.is_expanded, - "Bundle should be collapsed after double toggle" - ); - - // Property: set_expanded works correctly - data.set_expanded(true); - prop_assert!(data.is_expanded, "set_expanded(true) should expand"); - data.set_expanded(false); - prop_assert!(!data.is_expanded, "set_expanded(false) should collapse"); - } - } - - #[test] - fn test_normal_direction_is_stable_near_borders() { - let router = EdgeRouter::new(); - let r = Rect::from_pos_size(Vec2::new(10.0, 20.0), Vec2::new(100.0, 60.0)); - - // Points that are slightly off due to float error should still map to the expected side. - let leftish = Vec2::new(r.min.x + 0.0001, r.center().y); - let rightish = Vec2::new(r.max.x - 0.0001, r.center().y); - let topish = Vec2::new(r.center().x, r.min.y + 0.0001); - let bottomish = Vec2::new(r.center().x, r.max.y - 0.0001); - - assert_eq!( - router.get_normal_direction(leftish, r), - Vec2::new(-1.0, 0.0) - ); - assert_eq!( - router.get_normal_direction(rightish, r), - Vec2::new(1.0, 0.0) - ); - assert_eq!(router.get_normal_direction(topish, r), Vec2::new(0.0, -1.0)); - assert_eq!( - router.get_normal_direction(bottomish, r), - Vec2::new(0.0, 1.0) - ); - } -} diff --git a/crates/codestory-graph/src/graph.rs b/crates/codestory-graph/src/graph.rs deleted file mode 100644 index 717b0fb..0000000 --- a/crates/codestory-graph/src/graph.rs +++ /dev/null @@ -1,398 +0,0 @@ -use codestory_core::{BundleInfo, Edge, EdgeId, Node, NodeId}; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::fmt; -use std::ops::{Add, Index, IndexMut, Mul, Sub}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] -pub struct NodeIndex(pub usize); - -impl fmt::Display for NodeIndex { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] -pub struct EdgeIndex(pub usize); - -impl fmt::Display for EdgeIndex { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)] -pub struct Vec2 { - pub x: f32, - pub y: f32, -} - -impl Vec2 { - pub fn new(x: f32, y: f32) -> Self { - Self { x, y } - } -} - -impl Add for Vec2 { - type Output = Vec2; - - fn add(self, rhs: Self) -> Self::Output { - Vec2::new(self.x + rhs.x, self.y + rhs.y) - } -} - -impl Sub for Vec2 { - type Output = Vec2; - - fn sub(self, rhs: Self) -> Self::Output { - Vec2::new(self.x - rhs.x, self.y - rhs.y) - } -} - -impl Mul for Vec2 { - type Output = Vec2; - - fn mul(self, rhs: f32) -> Self::Output { - Vec2::new(self.x * rhs, self.y * rhs) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum GroupType { - FILE, - NAMESPACE, - INHERITANCE, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum GroupLayout { - GRID, - LIST, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DummyNode { - pub id: NodeId, - pub node_kind: codestory_core::NodeKind, - pub name: String, - - // Visual properties - pub position: Vec2, - pub size: Vec2, - pub visible: bool, - pub active: bool, - pub focused: bool, - pub expanded: bool, - - // Hierarchy - pub children: Vec, - pub parent: Option, - - // Bundling - pub bundle_info: Option, - pub bundled_nodes: Vec, - - // Grouping - pub group_type: Option, - pub group_layout: GroupLayout, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DummyEdge { - pub id: EdgeId, - pub source: NodeId, - pub target: NodeId, - pub kind: codestory_core::EdgeKind, - pub visible: bool, - pub active: bool, - pub source_idx: NodeIndex, - pub target_idx: NodeIndex, -} - -#[derive(Debug)] -pub struct Graph { - nodes: Vec, - edges: Vec, -} - -impl Default for Graph { - fn default() -> Self { - Self::new() - } -} - -impl Graph { - pub fn new() -> Self { - Self { - nodes: Vec::new(), - edges: Vec::new(), - } - } - - pub fn add_node(&mut self, node: DummyNode) -> NodeIndex { - let idx = NodeIndex(self.nodes.len()); - self.nodes.push(node); - idx - } - - pub fn add_edge( - &mut self, - source_idx: NodeIndex, - target_idx: NodeIndex, - edge: DummyEdge, - ) -> EdgeIndex { - let idx = EdgeIndex(self.edges.len()); - // Ensure edge has correct indices - let mut edge = edge; - edge.source_idx = source_idx; - edge.target_idx = target_idx; - self.edges.push(edge); - idx - } - - pub fn node_count(&self) -> usize { - self.nodes.len() - } - - pub fn edge_count(&self) -> usize { - self.edges.len() - } - - pub fn node_indices(&self) -> impl Iterator { - (0..self.nodes.len()).map(NodeIndex) - } - - pub fn edge_indices(&self) -> impl Iterator { - (0..self.edges.len()).map(EdgeIndex) - } - - pub fn edge_endpoints(&self, index: EdgeIndex) -> Option<(NodeIndex, NodeIndex)> { - self.edges - .get(index.0) - .map(|e| (e.source_idx, e.target_idx)) - } - - pub fn node_weight(&self, index: NodeIndex) -> Option<&DummyNode> { - self.nodes.get(index.0) - } - - pub fn edge_weight(&self, index: EdgeIndex) -> Option<&DummyEdge> { - self.edges.get(index.0) - } -} - -impl Index for Graph { - type Output = DummyNode; - fn index(&self, index: NodeIndex) -> &Self::Output { - &self.nodes[index.0] - } -} - -impl IndexMut for Graph { - fn index_mut(&mut self, index: NodeIndex) -> &mut Self::Output { - &mut self.nodes[index.0] - } -} - -impl Index for Graph { - type Output = DummyEdge; - fn index(&self, index: EdgeIndex) -> &Self::Output { - &self.edges[index.0] - } -} - -impl IndexMut for Graph { - fn index_mut(&mut self, index: EdgeIndex) -> &mut Self::Output { - &mut self.edges[index.0] - } -} - -#[derive(Debug)] -pub struct GraphModel { - pub graph: Graph, - pub node_map: HashMap, - pub root: Option, -} - -impl Default for GraphModel { - fn default() -> Self { - Self::new() - } -} - -impl GraphModel { - pub fn new() -> Self { - Self { - graph: Graph::new(), - node_map: HashMap::new(), - root: None, - } - } - - pub fn add_node(&mut self, node: Node) { - if !self.node_map.contains_key(&node.id) { - let dummy = DummyNode { - id: node.id, - node_kind: node.kind, - name: node.serialized_name, - position: Vec2::default(), - size: Vec2::new(100.0, 30.0), - visible: true, - active: false, - focused: false, - expanded: false, - children: Vec::new(), - parent: None, - bundle_info: None, - bundled_nodes: Vec::new(), - group_type: None, - group_layout: GroupLayout::GRID, - }; - let idx = self.graph.add_node(dummy); - self.node_map.insert(node.id, idx); - } - } - - pub fn add_edge(&mut self, edge: Edge) { - let (source_id, target_id) = edge.effective_endpoints(); - if let (Some(&src), Some(&target)) = - (self.node_map.get(&source_id), self.node_map.get(&target_id)) - { - let dummy = DummyEdge { - id: edge.id, - source: source_id, - target: target_id, - kind: edge.kind, - visible: true, - active: false, - source_idx: src, - target_idx: target, - }; - self.graph.add_edge(src, target, dummy); - } else { - // Debug logging for missing nodes - if !self.node_map.contains_key(&source_id) { - tracing::warn!( - "Dropping edge {:?} because source node {} is missing from graph model", - edge.id, - source_id.0 - ); - } - if !self.node_map.contains_key(&target_id) { - tracing::warn!( - "Dropping edge {:?} because target node {} is missing from graph model", - edge.id, - target_id.0 - ); - } - } - } - - pub fn node_count(&self) -> usize { - self.graph.node_count() - } - - pub fn edge_count(&self) -> usize { - self.graph.edge_count() - } - - pub fn get_node(&self, id: NodeId) -> Option<&DummyNode> { - self.node_map.get(&id).map(|&idx| &self.graph[idx]) - } - - pub fn get_node_mut(&mut self, id: NodeId) -> Option<&mut DummyNode> { - self.node_map.get(&id).map(|&idx| &mut self.graph[idx]) - } - - pub fn rebuild_hierarchy(&mut self) { - // Clear existing hierarchy - for index in 0..self.graph.nodes.len() { - let node_idx = NodeIndex(index); - let node = &mut self.graph[node_idx]; - node.children.clear(); - node.parent = None; - } - - for edge_index in 0..self.graph.edges.len() { - let (parent_id, child_id, is_member_edge) = { - let edge = &self.graph.edges[edge_index]; - ( - edge.source, - edge.target, - edge.kind == codestory_core::EdgeKind::MEMBER, - ) - }; - if !is_member_edge { - continue; - } - if let (Some(&p_idx), Some(&c_idx)) = - (self.node_map.get(&parent_id), self.node_map.get(&child_id)) - { - // Check if already has parent to avoid cycles/multi-parents in tree - if self.graph[c_idx].parent.is_none() { - self.graph[c_idx].parent = Some(parent_id); - self.graph[p_idx].children.push(child_id); - } - } - } - } - - pub fn expand_all(&mut self) { - for node in &mut self.graph.nodes { - node.expanded = true; - } - } - - pub fn collapse_all(&mut self) { - for node in &mut self.graph.nodes { - node.expanded = false; - } - } - - /// Extract nodes and edges after bundling/hierarchy changes - /// Note: This preserves bundle_info in DummyNode for later use - pub fn get_dummy_data(&self) -> (Vec, Vec) { - let nodes = self.graph.nodes.clone(); - let edges = self.graph.edges.clone(); - (nodes, edges) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use codestory_core::{Edge, EdgeKind, NodeId, NodeKind}; - - #[test] - fn test_graph_model() { - let mut model = GraphModel::new(); - let n1 = Node { - id: NodeId(1), - kind: NodeKind::CLASS, - serialized_name: "C1".to_string(), - ..Default::default() - }; - let n2 = Node { - id: NodeId(2), - kind: NodeKind::METHOD, - serialized_name: "M1".to_string(), - ..Default::default() - }; - - model.add_node(n1); - model.add_node(n2); - - let e1 = Edge { - id: codestory_core::EdgeId(1), - source: NodeId(1), - target: NodeId(2), - kind: EdgeKind::MEMBER, - ..Default::default() - }; - model.add_edge(e1); - - assert_eq!(model.node_count(), 2); - assert_eq!(model.edge_count(), 1); - } -} diff --git a/crates/codestory-graph/src/hit_tester.rs b/crates/codestory-graph/src/hit_tester.rs deleted file mode 100644 index ade999a..0000000 --- a/crates/codestory-graph/src/hit_tester.rs +++ /dev/null @@ -1,820 +0,0 @@ -use crate::Vec2; -use crate::edge_router::CubicBezier; -use crate::uml_types::{EdgeRoute, Rect, UmlNode}; -use codestory_core::{EdgeId, NodeId}; -use std::collections::HashMap; - -/// Result of a hit test at a given position. -/// -/// Priority order: Member > Node > EdgeBundle > Edge > None -#[derive(Debug, Clone, PartialEq)] -pub enum HitResult { - /// Nothing was hit at the tested position. - None, - /// A container node was hit (but not a specific member). - Node(NodeId), - /// A specific member within a container node was hit. - Member { node_id: NodeId, member_id: NodeId }, - /// A single edge was hit. - Edge(EdgeId), - /// An edge bundle was hit (multiple edges grouped together). - EdgeBundle { - bundle_id: usize, - edges: Vec, - }, -} - -/// Information about an edge bundle for hit testing purposes. -#[derive(Debug, Clone)] -pub struct EdgeBundleRegion { - /// Unique identifier for this bundle. - pub bundle_id: usize, - /// All edge IDs in this bundle. - pub edges: Vec, - /// The bezier curve for this bundle (used for distance testing). - pub curve: CubicBezier, -} - -/// Comprehensive hit tester for the graph visualization. -/// -/// Maintains spatial information about nodes, members, edges, and edge bundles, -/// and provides hit testing with correct priority ordering: -/// Member > Node > EdgeBundle > Edge > None -/// -/// **Validates: Requirements 11.2** -#[derive(Debug, Clone)] -pub struct HitTester { - /// Bounding rectangles for each container node. - node_rects: HashMap, - /// Bounding rectangles for members within each node. - /// Outer key is the container node ID, inner key is the member ID. - member_rects: HashMap>, - /// Bezier curves for each edge, used for distance-based hit testing. - edge_curves: HashMap, - /// Edge bundle regions for bundled edge hit testing. - edge_bundle_regions: Vec, - /// Default tolerance (in pixels) for edge hit testing. - edge_tolerance: f32, - /// Number of samples along bezier curves for distance computation. - bezier_samples: usize, -} - -impl Default for HitTester { - fn default() -> Self { - Self::new() - } -} - -impl HitTester { - /// Create a new HitTester with default settings. - pub fn new() -> Self { - Self { - node_rects: HashMap::new(), - member_rects: HashMap::new(), - edge_curves: HashMap::new(), - edge_bundle_regions: Vec::new(), - edge_tolerance: 8.0, - bezier_samples: 48, - } - } - - /// Create a new HitTester with custom edge tolerance. - pub fn with_tolerance(tolerance: f32) -> Self { - Self { - edge_tolerance: tolerance, - ..Self::new() - } - } - - /// Get the current edge hit tolerance. - pub fn edge_tolerance(&self) -> f32 { - self.edge_tolerance - } - - /// Set the edge hit tolerance. - pub fn set_edge_tolerance(&mut self, tolerance: f32) { - self.edge_tolerance = tolerance; - } - - /// Get the number of bezier samples used for distance computation. - pub fn bezier_samples(&self) -> usize { - self.bezier_samples - } - - /// Set the number of bezier samples. - pub fn set_bezier_samples(&mut self, samples: usize) { - self.bezier_samples = samples; - } - - /// Update hit regions from node data and edge routes. - /// - /// Call this after any layout change to refresh the spatial data used for hit testing. - /// - /// # Arguments - /// * `nodes` - Map of all UML nodes with their computed rects and member rects. - /// * `edges` - Slice of edge routes with computed bezier control points. - pub fn update(&mut self, nodes: &HashMap, edges: &[EdgeRoute]) { - self.node_rects.clear(); - self.member_rects.clear(); - self.edge_curves.clear(); - self.edge_bundle_regions.clear(); - - // Extract node and member rects from UmlNodes - for (node_id, node) in nodes { - self.node_rects.insert(*node_id, node.computed_rect); - - if !node.member_rects.is_empty() { - self.member_rects - .insert(*node_id, node.member_rects.clone()); - } - } - - // Extract edge curves from edge routes - for edge in edges { - let curve = Self::edge_route_to_curve(edge); - self.edge_curves.insert(edge.id, curve); - } - } - - /// Update hit regions with explicit rects (useful when UmlNode data is not available). - /// - /// # Arguments - /// * `node_rects` - Map of node IDs to their bounding rectangles. - /// * `member_rects` - Map of node IDs to their member rect maps. - /// * `edges` - Slice of edge routes. - pub fn update_from_rects( - &mut self, - node_rects: HashMap, - member_rects: HashMap>, - edges: &[EdgeRoute], - ) { - self.node_rects = node_rects; - self.member_rects = member_rects; - self.edge_curves.clear(); - self.edge_bundle_regions.clear(); - - for edge in edges { - let curve = Self::edge_route_to_curve(edge); - self.edge_curves.insert(edge.id, curve); - } - } - - /// Add an edge bundle region for hit testing. - /// - /// Call this after `update()` to register bundled edges. - pub fn add_edge_bundle(&mut self, bundle_id: usize, edges: Vec, curve: CubicBezier) { - self.edge_bundle_regions.push(EdgeBundleRegion { - bundle_id, - edges, - curve, - }); - } - - /// Perform a hit test at the given position. - /// - /// Returns a `HitResult` indicating what was hit. Priority order is: - /// 1. Member (highest priority -- most specific) - /// 2. Node - /// 3. EdgeBundle - /// 4. Edge - /// 5. None (nothing hit) - /// - /// **Validates: Requirements 11.2, Property 26** - pub fn hit_test(&self, pos: Vec2) -> HitResult { - // Priority 1: Check members first (most specific) - if let Some((node_id, member_id)) = self.hit_test_member(pos) { - return HitResult::Member { node_id, member_id }; - } - - // Priority 2: Check nodes - if let Some(node_id) = self.hit_test_node(pos) { - return HitResult::Node(node_id); - } - - // Priority 3: Check edge bundles - if let Some(region) = self.hit_test_edge_bundle(pos, self.edge_tolerance) { - return HitResult::EdgeBundle { - bundle_id: region.bundle_id, - edges: region.edges.clone(), - }; - } - - // Priority 4: Check individual edges - if let Some(edge_id) = self.hit_test_edge(pos, self.edge_tolerance) { - return HitResult::Edge(edge_id); - } - - HitResult::None - } - - /// Test if a position hits any node, returning the node ID. - pub fn hit_test_node(&self, pos: Vec2) -> Option { - // If multiple nodes overlap, return the one with the smallest area - // (most specific / innermost node) - let mut best: Option<(NodeId, f32)> = None; - - for (&node_id, rect) in &self.node_rects { - if rect.contains(pos) { - let area = rect.width() * rect.height(); - match &best { - Some((_, best_area)) if area < *best_area => { - best = Some((node_id, area)); - } - None => { - best = Some((node_id, area)); - } - _ => {} - } - } - } - - best.map(|(id, _)| id) - } - - /// Test if a position hits any member, returning the (node_id, member_id). - pub fn hit_test_member(&self, pos: Vec2) -> Option<(NodeId, NodeId)> { - for (&node_id, members) in &self.member_rects { - for (&member_id, rect) in members { - if rect.contains(pos) { - return Some((node_id, member_id)); - } - } - } - None - } - - /// Test if a position hits any edge within the given tolerance. - /// - /// Uses uniform sampling along bezier curves to find the closest edge. - /// Returns the `EdgeId` of the closest edge within tolerance, or `None`. - /// - /// # Arguments - /// * `pos` - The position to test. - /// * `tolerance` - Maximum distance (in pixels) from the curve to count as a hit. - /// - /// **Validates: Requirements 11.2** - pub fn hit_test_edge(&self, pos: Vec2, tolerance: f32) -> Option { - let mut best_id = None; - let mut best_dist = tolerance; - - for (&edge_id, curve) in &self.edge_curves { - let dist = curve.point_distance(pos, self.bezier_samples); - if dist < best_dist { - best_dist = dist; - best_id = Some(edge_id); - } - } - - best_id - } - - /// Test if a position hits any edge bundle within the given tolerance. - fn hit_test_edge_bundle(&self, pos: Vec2, tolerance: f32) -> Option<&EdgeBundleRegion> { - let mut best: Option<(usize, f32)> = None; - - for (i, region) in self.edge_bundle_regions.iter().enumerate() { - let dist = region.curve.point_distance(pos, self.bezier_samples); - if dist < tolerance { - match &best { - Some((_, best_dist)) if dist < *best_dist => { - best = Some((i, dist)); - } - None => { - best = Some((i, dist)); - } - _ => {} - } - } - } - - best.map(|(i, _)| &self.edge_bundle_regions[i]) - } - - /// Convert an `EdgeRoute` into a `CubicBezier` curve. - /// - /// If the edge route has exactly 2 control points, they are used as the - /// inner control points of the cubic bezier. Otherwise, a straight line - /// is used (control points at 1/3 and 2/3 of the line). - fn edge_route_to_curve(edge: &EdgeRoute) -> CubicBezier { - let start = edge.source.position; - let end = edge.target.position; - - if edge.control_points.len() == 2 { - CubicBezier { - start, - control1: edge.control_points[0], - control2: edge.control_points[1], - end, - } - } else { - // Straight line fallback: control points at 1/3 and 2/3 - let dx = end.x - start.x; - let dy = end.y - start.y; - CubicBezier { - start, - control1: Vec2::new(start.x + dx / 3.0, start.y + dy / 3.0), - control2: Vec2::new(start.x + 2.0 * dx / 3.0, start.y + 2.0 * dy / 3.0), - end, - } - } - } - - // -- Accessors for testing -- - - /// Get the node rects (for testing). - pub fn node_rects(&self) -> &HashMap { - &self.node_rects - } - - /// Get the member rects (for testing). - pub fn member_rects(&self) -> &HashMap> { - &self.member_rects - } - - /// Get the edge curves (for testing). - pub fn edge_curves(&self) -> &HashMap { - &self.edge_curves - } - - /// Get the edge bundle regions (for testing). - pub fn edge_bundle_regions(&self) -> &[EdgeBundleRegion] { - &self.edge_bundle_regions - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::uml_types::{AnchorSide, EdgeAnchor, UmlNode}; - use codestory_core::{EdgeKind, NodeKind}; - - fn make_rect(x: f32, y: f32, w: f32, h: f32) -> Rect { - Rect::from_pos_size(Vec2::new(x, y), Vec2::new(w, h)) - } - - fn make_edge_route( - id: i64, - src_node: i64, - src_pos: Vec2, - tgt_node: i64, - tgt_pos: Vec2, - control_points: Vec, - ) -> EdgeRoute { - EdgeRoute::with_control_points( - EdgeId(id), - EdgeAnchor::new(NodeId(src_node), src_pos, AnchorSide::Right), - EdgeAnchor::new(NodeId(tgt_node), tgt_pos, AnchorSide::Left), - EdgeKind::CALL, - control_points, - ) - } - - #[test] - fn test_hit_test_node() { - let mut tester = HitTester::new(); - let mut nodes = HashMap::new(); - - let mut node = UmlNode::new(NodeId(1), NodeKind::CLASS, "ClassA".to_string()); - node.computed_rect = make_rect(100.0, 100.0, 200.0, 150.0); - nodes.insert(NodeId(1), node); - - tester.update(&nodes, &[]); - - // Inside node - assert_eq!( - tester.hit_test(Vec2::new(150.0, 150.0)), - HitResult::Node(NodeId(1)) - ); - - // Outside node - assert_eq!(tester.hit_test(Vec2::new(50.0, 50.0)), HitResult::None); - } - - #[test] - fn test_hit_test_member() { - let mut tester = HitTester::new(); - let mut nodes = HashMap::new(); - - let mut node = UmlNode::new(NodeId(1), NodeKind::CLASS, "ClassA".to_string()); - node.computed_rect = make_rect(100.0, 100.0, 200.0, 150.0); - - // Member rect inside the node - let member_rect = make_rect(110.0, 140.0, 180.0, 20.0); - node.member_rects.insert(NodeId(10), member_rect); - nodes.insert(NodeId(1), node); - - tester.update(&nodes, &[]); - - // Hit the member - let result = tester.hit_test(Vec2::new(150.0, 145.0)); - assert_eq!( - result, - HitResult::Member { - node_id: NodeId(1), - member_id: NodeId(10), - } - ); - - // Hit the node but not the member - let result = tester.hit_test(Vec2::new(150.0, 110.0)); - assert_eq!(result, HitResult::Node(NodeId(1))); - } - - #[test] - fn test_hit_test_edge() { - let mut tester = HitTester::new(); - - // Create a straight horizontal edge from (0,0) to (100,0) - let edge = make_edge_route( - 1, - 1, - Vec2::new(0.0, 0.0), - 2, - Vec2::new(100.0, 0.0), - vec![Vec2::new(33.0, 0.0), Vec2::new(66.0, 0.0)], - ); - - tester.update(&HashMap::new(), &[edge]); - - // Near the edge (within tolerance) - let result = tester.hit_test_edge(Vec2::new(50.0, 3.0), 8.0); - assert_eq!(result, Some(EdgeId(1))); - - // Far from the edge - let result = tester.hit_test_edge(Vec2::new(50.0, 50.0), 8.0); - assert_eq!(result, None); - } - - #[test] - fn test_hit_test_edge_with_tolerance() { - let mut tester = HitTester::new(); - - let edge = make_edge_route( - 1, - 1, - Vec2::new(0.0, 0.0), - 2, - Vec2::new(100.0, 0.0), - vec![Vec2::new(33.0, 0.0), Vec2::new(66.0, 0.0)], - ); - - tester.update(&HashMap::new(), &[edge]); - - // With small tolerance -- should miss - let result = tester.hit_test_edge(Vec2::new(50.0, 5.0), 2.0); - assert_eq!(result, None); - - // With larger tolerance -- should hit - let result = tester.hit_test_edge(Vec2::new(50.0, 5.0), 8.0); - assert_eq!(result, Some(EdgeId(1))); - } - - #[test] - fn test_hit_test_edge_bundle() { - let mut tester = HitTester::new(); - tester.update(&HashMap::new(), &[]); - - let curve = CubicBezier { - start: Vec2::new(0.0, 0.0), - control1: Vec2::new(33.0, 0.0), - control2: Vec2::new(66.0, 0.0), - end: Vec2::new(100.0, 0.0), - }; - - tester.add_edge_bundle(0, vec![EdgeId(1), EdgeId(2), EdgeId(3)], curve); - - // Near the bundle - let result = tester.hit_test(Vec2::new(50.0, 3.0)); - assert!(matches!(result, HitResult::EdgeBundle { bundle_id: 0, .. })); - } - - #[test] - fn test_priority_member_over_node() { - let mut tester = HitTester::new(); - let mut nodes = HashMap::new(); - - let mut node = UmlNode::new(NodeId(1), NodeKind::CLASS, "ClassA".to_string()); - node.computed_rect = make_rect(0.0, 0.0, 200.0, 200.0); - node.member_rects - .insert(NodeId(10), make_rect(10.0, 50.0, 180.0, 20.0)); - nodes.insert(NodeId(1), node); - - tester.update(&nodes, &[]); - - // Point inside both node and member -- member should win - let result = tester.hit_test(Vec2::new(100.0, 55.0)); - assert_eq!( - result, - HitResult::Member { - node_id: NodeId(1), - member_id: NodeId(10), - } - ); - } - - #[test] - fn test_priority_node_over_edge() { - let mut tester = HitTester::new(); - let mut nodes = HashMap::new(); - - let mut node = UmlNode::new(NodeId(1), NodeKind::CLASS, "ClassA".to_string()); - node.computed_rect = make_rect(40.0, -10.0, 20.0, 20.0); - nodes.insert(NodeId(1), node); - - // Edge passes through the node area - let edge = make_edge_route( - 1, - 2, - Vec2::new(0.0, 0.0), - 3, - Vec2::new(100.0, 0.0), - vec![Vec2::new(33.0, 0.0), Vec2::new(66.0, 0.0)], - ); - - tester.update(&nodes, &[edge]); - - // Point inside the node (which also overlaps the edge) - let result = tester.hit_test(Vec2::new(50.0, 0.0)); - assert_eq!(result, HitResult::Node(NodeId(1))); - } - - #[test] - fn test_smallest_node_wins_on_overlap() { - let mut tester = HitTester::new(); - let mut nodes = HashMap::new(); - - // Large node - let mut large_node = UmlNode::new(NodeId(1), NodeKind::CLASS, "LargeClass".to_string()); - large_node.computed_rect = make_rect(0.0, 0.0, 300.0, 300.0); - nodes.insert(NodeId(1), large_node); - - // Small node inside the large one - let mut small_node = UmlNode::new(NodeId(2), NodeKind::CLASS, "SmallClass".to_string()); - small_node.computed_rect = make_rect(100.0, 100.0, 50.0, 50.0); - nodes.insert(NodeId(2), small_node); - - tester.update(&nodes, &[]); - - // Point inside both -- smaller node wins - let result = tester.hit_test(Vec2::new(120.0, 120.0)); - assert_eq!(result, HitResult::Node(NodeId(2))); - - // Point inside only the large node - let result = tester.hit_test(Vec2::new(10.0, 10.0)); - assert_eq!(result, HitResult::Node(NodeId(1))); - } - - #[test] - fn test_update_clears_previous_data() { - let mut tester = HitTester::new(); - let mut nodes = HashMap::new(); - - let mut node = UmlNode::new(NodeId(1), NodeKind::CLASS, "ClassA".to_string()); - node.computed_rect = make_rect(0.0, 0.0, 100.0, 100.0); - nodes.insert(NodeId(1), node); - - tester.update(&nodes, &[]); - assert!(tester.node_rects().contains_key(&NodeId(1))); - - // Update with empty data - tester.update(&HashMap::new(), &[]); - assert!(tester.node_rects().is_empty()); - } - - #[test] - fn test_update_from_rects() { - let mut tester = HitTester::new(); - - let mut node_rects = HashMap::new(); - node_rects.insert(NodeId(1), make_rect(0.0, 0.0, 100.0, 100.0)); - - let mut member_rects = HashMap::new(); - let mut members = HashMap::new(); - members.insert(NodeId(10), make_rect(10.0, 30.0, 80.0, 20.0)); - member_rects.insert(NodeId(1), members); - - tester.update_from_rects(node_rects, member_rects, &[]); - - let result = tester.hit_test(Vec2::new(50.0, 35.0)); - assert_eq!( - result, - HitResult::Member { - node_id: NodeId(1), - member_id: NodeId(10), - } - ); - } - - #[test] - fn test_edge_route_to_curve_with_control_points() { - let edge = make_edge_route( - 1, - 1, - Vec2::new(0.0, 0.0), - 2, - Vec2::new(100.0, 50.0), - vec![Vec2::new(30.0, 10.0), Vec2::new(70.0, 40.0)], - ); - - let curve = HitTester::edge_route_to_curve(&edge); - assert_eq!(curve.start, Vec2::new(0.0, 0.0)); - assert_eq!(curve.control1, Vec2::new(30.0, 10.0)); - assert_eq!(curve.control2, Vec2::new(70.0, 40.0)); - assert_eq!(curve.end, Vec2::new(100.0, 50.0)); - } - - #[test] - fn test_edge_route_to_curve_straight_line() { - let edge = make_edge_route( - 1, - 1, - Vec2::new(0.0, 0.0), - 2, - Vec2::new(90.0, 0.0), - vec![], // No control points - ); - - let curve = HitTester::edge_route_to_curve(&edge); - assert_eq!(curve.start, Vec2::new(0.0, 0.0)); - assert_eq!(curve.end, Vec2::new(90.0, 0.0)); - // Control points should be at 1/3 and 2/3 - assert_eq!(curve.control1, Vec2::new(30.0, 0.0)); - assert_eq!(curve.control2, Vec2::new(60.0, 0.0)); - } -} - -#[cfg(test)] -mod property_tests { - use super::*; - use crate::uml_types::UmlNode; - use codestory_core::NodeKind; - use proptest::prelude::*; - - /// Strategy for generating a rectangle with known position and size. - fn rect_strategy() -> impl Strategy { - (0.0f32..500.0, 0.0f32..500.0, 20.0f32..200.0, 20.0f32..200.0) - .prop_map(|(x, y, w, h)| Rect::from_pos_size(Vec2::new(x, y), Vec2::new(w, h))) - } - - proptest! { - /// **Validates: Property 26: Hit Test Correctness** - /// - /// For any click position P within a node's bounding box, hit_test(P) SHALL - /// return that node's NodeId. For any position P within a member's row, - /// hit_test(P) SHALL return that member's NodeId. - #[test] - fn prop_hit_test_node_correctness( - node_rect in rect_strategy() - ) { - // Ensure minimum size for valid range generation - prop_assume!(node_rect.width() > 1.0 && node_rect.height() > 1.0); - - let mut tester = HitTester::new(); - let mut nodes = HashMap::new(); - - let mut node = UmlNode::new(NodeId(1), NodeKind::CLASS, "TestClass".to_string()); - node.computed_rect = node_rect; - nodes.insert(NodeId(1), node); - - tester.update(&nodes, &[]); - - // Test a point inside the node - let center = node_rect.center(); - let result = tester.hit_test(center); - - prop_assert_eq!( - result, - HitResult::Node(NodeId(1)), - "Hit test at center {:?} of node rect {:?} should return Node(1)", - center, - node_rect - ); - } - - /// **Validates: Property 26 (member variant)** - /// - /// For any position P within a member's row, hit_test(P) SHALL return - /// that member's NodeId (as HitResult::Member). - #[test] - fn prop_hit_test_member_correctness( - node_rect in rect_strategy() - ) { - // Ensure node is large enough to contain a member - prop_assume!(node_rect.width() > 20.0 && node_rect.height() > 60.0); - - let mut tester = HitTester::new(); - let mut nodes = HashMap::new(); - - let mut node = UmlNode::new(NodeId(1), NodeKind::CLASS, "TestClass".to_string()); - node.computed_rect = node_rect; - - // Place a member rect inside the node - let member_rect = Rect::from_pos_size( - Vec2::new(node_rect.min.x + 5.0, node_rect.min.y + 35.0), - Vec2::new(node_rect.width() - 10.0, 20.0), - ); - - // Make sure member is within node - prop_assume!(member_rect.max.y <= node_rect.max.y); - - node.member_rects.insert(NodeId(10), member_rect); - nodes.insert(NodeId(1), node); - - tester.update(&nodes, &[]); - - // Test a point inside the member rect - let member_center = member_rect.center(); - let result = tester.hit_test(member_center); - - prop_assert_eq!( - result, - HitResult::Member { - node_id: NodeId(1), - member_id: NodeId(10), - }, - "Hit test at member center {:?} should return Member {{ node_id: 1, member_id: 10 }}", - member_center - ); - } - - /// **Validates: Property 26 (priority)** - /// - /// Member hits always take priority over node hits. - #[test] - fn prop_hit_test_member_priority_over_node( - node_rect in rect_strategy() - ) { - prop_assume!(node_rect.width() > 30.0 && node_rect.height() > 70.0); - - let mut tester = HitTester::new(); - let mut nodes = HashMap::new(); - - let mut node = UmlNode::new(NodeId(1), NodeKind::CLASS, "TestClass".to_string()); - node.computed_rect = node_rect; - - let member_rect = Rect::from_pos_size( - Vec2::new(node_rect.min.x + 5.0, node_rect.min.y + 40.0), - Vec2::new(node_rect.width() - 10.0, 20.0), - ); - - prop_assume!(member_rect.max.y <= node_rect.max.y); - - node.member_rects.insert(NodeId(10), member_rect); - nodes.insert(NodeId(1), node); - - tester.update(&nodes, &[]); - - // The member center is also inside the node rect. - // Member should win. - let test_point = member_rect.center(); - let result = tester.hit_test(test_point); - - prop_assert!( - matches!(result, HitResult::Member { node_id: NodeId(1), member_id: NodeId(10) }), - "Member should have priority over node. Got {:?} at {:?}", - result, - test_point - ); - } - - /// **Validates: Property 26 (outside)** - /// - /// For any position P outside all node bounding boxes, hit_test(P) SHALL NOT - /// return a Node or Member result (only Edge, EdgeBundle, or None). - #[test] - fn prop_hit_test_outside_returns_none( - node_rect in rect_strategy(), - offset_x in 10.0f32..100.0, - offset_y in 10.0f32..100.0, - quadrant in 0u8..4 - ) { - let mut tester = HitTester::new(); - let mut nodes = HashMap::new(); - - let mut node = UmlNode::new(NodeId(1), NodeKind::CLASS, "TestClass".to_string()); - node.computed_rect = node_rect; - nodes.insert(NodeId(1), node); - - // No edges, so testing should return None for outside points - tester.update(&nodes, &[]); - - // Generate a point guaranteed to be outside the node rect - let outside_point = match quadrant { - 0 => Vec2::new(node_rect.min.x - offset_x, node_rect.min.y - offset_y), - 1 => Vec2::new(node_rect.max.x + offset_x, node_rect.min.y - offset_y), - 2 => Vec2::new(node_rect.min.x - offset_x, node_rect.max.y + offset_y), - _ => Vec2::new(node_rect.max.x + offset_x, node_rect.max.y + offset_y), - }; - - let result = tester.hit_test(outside_point); - - prop_assert_eq!( - result, - HitResult::None, - "Point {:?} outside node rect {:?} should return None", - outside_point, - node_rect - ); - } - } -} diff --git a/crates/codestory-graph/src/layout.rs b/crates/codestory-graph/src/layout.rs deleted file mode 100644 index 1063475..0000000 --- a/crates/codestory-graph/src/layout.rs +++ /dev/null @@ -1,891 +0,0 @@ -use crate::graph::{GraphModel, GroupLayout, NodeIndex, Vec2}; -use codestory_core::{LayoutDirection, NodeId}; -use rayon::prelude::*; -use std::collections::{HashMap, HashSet}; - -pub trait Layouter { - fn execute( - &self, - model: &GraphModel, - ) -> (HashMap, HashMap); -} - -pub struct EdgeBundler; - -impl EdgeBundler { - pub fn bundle_edges(model: &GraphModel) -> Vec> { - let mut bundles: HashMap<(NodeIndex, NodeIndex), Vec> = - HashMap::new(); - - for edge_idx in model.graph.edge_indices() { - if let Some((source, target)) = model.graph.edge_endpoints(edge_idx) { - let edge_data = &model.graph[edge_idx]; - bundles - .entry((source, target)) - .or_default() - .push(edge_data.id); - } - } - - bundles.into_values().collect() - } -} - -/// Layout algorithm for nested hierarchical graphs with parent-child relationships. -/// -/// This layouter uses hierarchical ranking for root nodes plus a local force pass -/// to improve readability in dense neighborhoods. -pub struct NestingLayouter { - /// Padding between parent container border and its children - pub inner_padding: f32, - /// Spacing between sibling nodes - pub child_spacing: f32, - /// Layout flow direction - pub direction: LayoutDirection, -} - -#[derive(Default)] -struct RootRelations { - root_edges: Vec<(NodeIndex, NodeIndex)>, - incoming: HashMap>, - outgoing: HashMap>, -} - -impl NestingLayouter { - /// Default padding and spacing values optimized for readability - pub const DEFAULT_INNER_PADDING: f32 = 10.0; - pub const DEFAULT_CHILD_SPACING: f32 = 5.0; - const DEFAULT_NODE_WIDTH: f32 = 100.0; - const DEFAULT_NODE_HEIGHT: f32 = 30.0; - - /// Maximum nesting depth to prevent stack overflow - const MAX_NESTING_DEPTH: u32 = 100; - /// Maximum iterations for ranking convergence - const MAX_RANKING_ITERATIONS: usize = 1000; - - fn default_node_size() -> Vec2 { - Vec2::new(Self::DEFAULT_NODE_WIDTH, Self::DEFAULT_NODE_HEIGHT) - } - - fn root_nodes(model: &GraphModel) -> Vec { - model - .graph - .node_indices() - .filter(|&idx| model.graph[idx].parent.is_none()) - .collect() - } - - fn resolve_root_cached( - model: &GraphModel, - node_idx: NodeIndex, - cache: &mut HashMap, - ) -> NodeIndex { - if let Some(&cached) = cache.get(&node_idx) { - return cached; - } - - let mut trail = Vec::new(); - let mut seen = HashSet::new(); - let mut current = node_idx; - - loop { - if let Some(&cached) = cache.get(¤t) { - for idx in trail { - cache.insert(idx, cached); - } - return cached; - } - - if !seen.insert(current) { - // Fallback for malformed cyclic parent chains. - for idx in trail { - cache.insert(idx, current); - } - return current; - } - - trail.push(current); - let parent_idx = model.graph[current] - .parent - .and_then(|parent_id| model.node_map.get(&parent_id).copied()); - - match parent_idx { - Some(parent_idx) => { - current = parent_idx; - } - None => { - for idx in trail { - cache.insert(idx, current); - } - return current; - } - } - } - } - - fn build_node_roots(model: &GraphModel) -> HashMap { - let mut node_roots = HashMap::with_capacity(model.graph.node_count()); - for node_idx in model.graph.node_indices() { - let root = Self::resolve_root_cached(model, node_idx, &mut node_roots); - node_roots.insert(node_idx, root); - } - node_roots - } - - fn build_root_relations( - model: &GraphModel, - node_roots: &HashMap, - ) -> RootRelations { - let mut relations = RootRelations::default(); - - for edge_idx in model.graph.edge_indices() { - if let Some((source, target)) = model.graph.edge_endpoints(edge_idx) { - let Some(&source_root) = node_roots.get(&source) else { - continue; - }; - let Some(&target_root) = node_roots.get(&target) else { - continue; - }; - - if source_root == target_root { - continue; - } - - relations.root_edges.push((source_root, target_root)); - relations - .incoming - .entry(target_root) - .or_default() - .push(source_root); - relations - .outgoing - .entry(source_root) - .or_default() - .push(target_root); - } - } - - relations - } - - fn assign_root_ranks( - root_nodes: &[NodeIndex], - relations: &RootRelations, - ) -> HashMap { - let mut ranks = HashMap::with_capacity(root_nodes.len()); - for &node in root_nodes { - ranks.insert(node, 0); - } - - let max_iterations = (root_nodes.len() + 2).min(Self::MAX_RANKING_ITERATIONS); - let mut converged = false; - for _ in 0..max_iterations { - let mut changed = false; - for &(source_root, target_root) in &relations.root_edges { - if let (Some(&source_rank), Some(&target_rank)) = - (ranks.get(&source_root), ranks.get(&target_root)) - && target_rank <= source_rank - { - ranks.insert(target_root, source_rank + 1); - changed = true; - } - } - - if !changed { - converged = true; - break; - } - } - - if !converged { - tracing::warn!( - "Root node ranking did not converge after {} iterations", - max_iterations - ); - } - - Self::compress_ranks(&mut ranks); - ranks - } - - fn compress_ranks(ranks: &mut HashMap) { - if ranks.is_empty() { - return; - } - - let mut unique_ranks: Vec = ranks.values().copied().collect(); - unique_ranks.sort_unstable(); - unique_ranks.dedup(); - - let mut remap: HashMap = HashMap::new(); - for (i, rank) in unique_ranks.iter().enumerate() { - remap.insert(*rank, i as i32); - } - - for rank in ranks.values_mut() { - if let Some(new_rank) = remap.get(rank) { - *rank = *new_rank; - } - } - } - - fn build_layers( - model: &GraphModel, - ranks: &HashMap, - ) -> HashMap> { - let mut layers: HashMap> = HashMap::new(); - for (&node, &rank) in ranks { - layers.entry(rank).or_default().push(node); - } - - for nodes in layers.values_mut() { - nodes.sort_by(|a, b| model.graph[*a].name.cmp(&model.graph[*b].name)); - } - - layers - } - - fn sorted_ranks(layers: &HashMap>) -> Vec { - let mut sorted_ranks: Vec<_> = layers.keys().copied().collect(); - sorted_ranks.sort_unstable(); - sorted_ranks - } - - fn spacing_for_root_count(root_count: usize) -> (f32, f32, f32) { - let tiny_graph = root_count <= 4; - let small_graph = root_count <= 12; - - let barycenter_spacing = if tiny_graph { - 80.0 - } else if small_graph { - 100.0 - } else { - 150.0 - }; - let layer_spacing = if tiny_graph { - 120.0 - } else if small_graph { - 160.0 - } else { - 300.0 - }; - let node_spacing = if tiny_graph { - 60.0 - } else if small_graph { - 80.0 - } else { - 150.0 - }; - - (barycenter_spacing, layer_spacing, node_spacing) - } - - fn initialize_layer_coords( - layers: &HashMap>, - sorted_ranks: &[i32], - barycenter_spacing: f32, - ) -> HashMap { - let mut layer_coords: HashMap = HashMap::new(); - for rank in sorted_ranks { - if let Some(layer_nodes) = layers.get(rank) { - for (j, &node_idx) in layer_nodes.iter().enumerate() { - layer_coords.insert(node_idx, j as f32 * barycenter_spacing); - } - } - } - layer_coords - } - - fn order_layer_by_barycenter( - layer_nodes: &mut [NodeIndex], - layer_coords: &HashMap, - neighbors_by_root: &HashMap>, - ) { - let mut barycenters: HashMap = HashMap::new(); - - for &node_idx in layer_nodes.iter() { - let mut sum = 0.0; - let mut count = 0; - - if let Some(neighbors) = neighbors_by_root.get(&node_idx) { - for &neighbor in neighbors { - if let Some(&coord) = layer_coords.get(&neighbor) { - sum += coord; - count += 1; - } - } - } - - let barycenter = if count > 0 { - sum / count as f32 - } else { - *layer_coords.get(&node_idx).unwrap_or(&0.0) - }; - barycenters.insert(node_idx, barycenter); - } - - layer_nodes.sort_by(|a, b| { - barycenters - .get(a) - .unwrap_or(&0.0) - .partial_cmp(barycenters.get(b).unwrap_or(&0.0)) - .unwrap_or(std::cmp::Ordering::Equal) - }); - } - - fn run_barycenter_passes( - layers: &mut HashMap>, - sorted_ranks: &[i32], - layer_coords: &mut HashMap, - relations: &RootRelations, - barycenter_spacing: f32, - ) { - for _ in 0..2 { - for &rank in sorted_ranks.iter().skip(1) { - if let Some(layer_nodes) = layers.get_mut(&rank) { - Self::order_layer_by_barycenter(layer_nodes, layer_coords, &relations.incoming); - for (j, &node_idx) in layer_nodes.iter().enumerate() { - layer_coords.insert(node_idx, j as f32 * barycenter_spacing); - } - } - } - - for i in (0..sorted_ranks.len().saturating_sub(1)).rev() { - let rank = sorted_ranks[i]; - if let Some(layer_nodes) = layers.get_mut(&rank) { - Self::order_layer_by_barycenter(layer_nodes, layer_coords, &relations.outgoing); - for (j, &node_idx) in layer_nodes.iter().enumerate() { - layer_coords.insert(node_idx, j as f32 * barycenter_spacing); - } - } - } - } - } - - fn child_index(model: &GraphModel, child_id: NodeId) -> Option { - let child_idx = model.node_map.get(&child_id).copied(); - if child_idx.is_none() { - tracing::warn!("Missing node in node_map: {:?}", child_id); - } - child_idx - } - - fn compute_subtree_size( - &self, - model: &GraphModel, - sizes: &mut HashMap, - node_idx: NodeIndex, - depth: u32, - ) -> Vec2 { - // Prevent stack overflow from deeply nested or circular graphs. - if depth > Self::MAX_NESTING_DEPTH { - tracing::warn!( - "Maximum nesting depth ({}) exceeded for node {:?}, using default size", - Self::MAX_NESTING_DEPTH, - model.graph[node_idx].id - ); - let fallback = model.graph[node_idx].size; - sizes.insert(node_idx, fallback); - return fallback; - } - - if let Some(&size) = sizes.get(&node_idx) { - return size; - } - - let node = &model.graph[node_idx]; - - if !node.expanded || node.children.is_empty() { - sizes.insert(node_idx, node.size); - return node.size; - } - - match node.group_layout { - GroupLayout::LIST => { - let mut current_y = 30.0 + self.inner_padding; - let mut max_width = node.size.x; - - for &child_id in &node.children { - let child_size = if let Some(child_idx) = Self::child_index(model, child_id) { - self.compute_subtree_size(model, sizes, child_idx, depth + 1) - } else { - Self::default_node_size() - }; - current_y += child_size.y + self.child_spacing; - max_width = max_width.max(child_size.x + 2.0 * self.inner_padding); - } - - let final_size = - Vec2::new(max_width, (current_y + self.inner_padding).max(node.size.y)); - sizes.insert(node_idx, final_size); - final_size - } - GroupLayout::GRID => { - let child_count = node.children.len(); - let cols = (child_count as f32).sqrt().ceil() as usize; - - let mut current_x = self.inner_padding; - let mut current_y = 30.0 + self.inner_padding; - let mut row_max_height = 0.0; - let mut content_width: f32 = 0.0; - - for (i, &child_id) in node.children.iter().enumerate() { - if i > 0 && i % cols == 0 { - current_x = self.inner_padding; - current_y += row_max_height + self.child_spacing; - row_max_height = 0.0; - } - - let child_size = if let Some(child_idx) = Self::child_index(model, child_id) { - self.compute_subtree_size(model, sizes, child_idx, depth + 1) - } else { - Self::default_node_size() - }; - - current_x += child_size.x + self.child_spacing; - row_max_height = row_max_height.max(child_size.y); - content_width = content_width.max(current_x); - } - - // Account for last row height. - current_y += row_max_height; - - let final_size = Vec2::new( - content_width.max(node.size.x) + self.inner_padding, - (current_y + self.inner_padding).max(node.size.y), - ); - sizes.insert(node_idx, final_size); - final_size - } - } - } - - fn precompute_sizes( - &self, - model: &GraphModel, - root_nodes: &[NodeIndex], - ) -> (HashMap, HashMap) { - let mut per_root: Vec<(NodeIndex, Vec<(NodeIndex, Vec2)>, Vec2)> = root_nodes - .par_iter() - .map(|&root_idx| { - let mut local_sizes = HashMap::new(); - let root_size = self.compute_subtree_size(model, &mut local_sizes, root_idx, 0); - let entries = local_sizes.into_iter().collect::>(); - (root_idx, entries, root_size) - }) - .collect(); - - // Deterministic merge order. - per_root.sort_by_key(|(root, _, _)| *root); - - let mut sizes = HashMap::with_capacity(model.graph.node_count()); - let mut root_sizes = HashMap::with_capacity(root_nodes.len()); - for (root_idx, entries, root_size) in per_root { - root_sizes.insert(root_idx, root_size); - for (node_idx, size) in entries { - sizes.entry(node_idx).or_insert(size); - } - } - (sizes, root_sizes) - } - - fn place_subtree( - &self, - model: &GraphModel, - node_idx: NodeIndex, - x: f32, - y: f32, - positions: &mut HashMap, - sizes: &HashMap, - ) { - positions.insert(node_idx, (x, y)); - - let node = &model.graph[node_idx]; - if !node.expanded || node.children.is_empty() { - return; - } - - let start_y = y + 30.0 + self.inner_padding; // Header height + padding - - match node.group_layout { - GroupLayout::LIST => { - let mut current_y = start_y; - - for &child_id in &node.children { - let Some(child_idx) = Self::child_index(model, child_id) else { - continue; - }; - self.place_subtree( - model, - child_idx, - x + self.inner_padding, - current_y, - positions, - sizes, - ); - - let child_size = sizes - .get(&child_idx) - .copied() - .unwrap_or_else(Self::default_node_size); - current_y += child_size.y + self.child_spacing; - } - } - GroupLayout::GRID => { - let child_count = node.children.len(); - let cols = (child_count as f32).sqrt().ceil() as usize; - - let mut current_x = x + self.inner_padding; - let mut current_y = start_y; - let mut row_max_height = 0.0; - - for (i, &child_id) in node.children.iter().enumerate() { - if i > 0 && i % cols == 0 { - current_x = x + self.inner_padding; - current_y += row_max_height + self.child_spacing; - row_max_height = 0.0; - } - - let Some(child_idx) = Self::child_index(model, child_id) else { - continue; - }; - self.place_subtree(model, child_idx, current_x, current_y, positions, sizes); - let child_size = sizes - .get(&child_idx) - .copied() - .unwrap_or_else(Self::default_node_size); - - current_x += child_size.x + self.child_spacing; - row_max_height = row_max_height.max(child_size.y); - } - } - } - } - - fn layer_extent( - &self, - layer_nodes: &[NodeIndex], - root_sizes: &HashMap, - node_spacing: f32, - ) -> f32 { - let base_extent = match self.direction { - LayoutDirection::Vertical => layer_nodes - .iter() - .map(|idx| { - root_sizes - .get(idx) - .copied() - .unwrap_or_else(Self::default_node_size) - .x - }) - .sum::(), - LayoutDirection::Horizontal => layer_nodes - .iter() - .map(|idx| { - root_sizes - .get(idx) - .copied() - .unwrap_or_else(Self::default_node_size) - .y - }) - .sum::(), - }; - - base_extent + (layer_nodes.len().saturating_sub(1) as f32) * node_spacing - } - - #[allow(clippy::too_many_arguments)] - fn place_roots_in_layers( - &self, - model: &GraphModel, - layers: &HashMap>, - sorted_ranks: &[i32], - root_sizes: &HashMap, - sizes: &HashMap, - positions: &mut HashMap, - layer_spacing: f32, - node_spacing: f32, - ) { - for rank in sorted_ranks { - let Some(layer_nodes) = layers.get(rank) else { - continue; - }; - let extent = self.layer_extent(layer_nodes, root_sizes, node_spacing); - let mut current_offset = -extent / 2.0; - let rank_pos = *rank as f32 * layer_spacing; - - for &node_idx in layer_nodes { - let root_size = root_sizes - .get(&node_idx) - .copied() - .unwrap_or_else(Self::default_node_size); - match self.direction { - LayoutDirection::Vertical => { - self.place_subtree( - model, - node_idx, - current_offset, - rank_pos, - positions, - sizes, - ); - current_offset += root_size.x + node_spacing; - } - LayoutDirection::Horizontal => { - self.place_subtree( - model, - node_idx, - rank_pos, - current_offset, - positions, - sizes, - ); - current_offset += root_size.y + node_spacing; - } - } - } - } - } - - /// Apply simple force-directed repulsion to spread out overlapping nodes. - fn apply_force_directed( - &self, - positions: &mut HashMap, - sizes: &HashMap, - model: &GraphModel, - iterations: usize, - ) { - let repulsion_strength = 500.0; - let min_distance = 20.0; - let damping = 0.3; - - let mut node_indices: Vec = positions.keys().copied().collect(); - // Sort indices to ensure deterministic iteration order for force calculation. - node_indices.sort(); - if node_indices.is_empty() { - return; - } - - let node_ids: Vec = node_indices - .iter() - .map(|&idx| model.graph[idx].id) - .collect(); - let parents: Vec> = node_indices - .iter() - .map(|&idx| model.graph[idx].parent) - .collect(); - let node_sizes: Vec = node_indices - .iter() - .map(|&idx| { - sizes - .get(&idx) - .copied() - .unwrap_or_else(Self::default_node_size) - }) - .collect(); - - for _ in 0..iterations { - let mut forces_x = vec![0.0f32; node_indices.len()]; - - // Calculate repulsion forces between all node pairs. - for i in 0..node_indices.len() { - for j in (i + 1)..node_indices.len() { - // Skip if same hierarchy (parent-child). - if parents[i] == Some(node_ids[j]) || parents[j] == Some(node_ids[i]) { - continue; - } - - let a = node_indices[i]; - let b = node_indices[j]; - let (ax, ay) = positions[&a]; - let (bx, by) = positions[&b]; - let a_size = node_sizes[i]; - let b_size = node_sizes[j]; - - // Use center positions. - let acx = ax + a_size.x / 2.0; - let acy = ay + a_size.y / 2.0; - let bcx = bx + b_size.x / 2.0; - let bcy = by + b_size.y / 2.0; - - let dx = acx - bcx; - let dy = acy - bcy; - let dist = (dx * dx + dy * dy).sqrt().max(min_distance); - - // Only apply horizontal repulsion to avoid breaking layer structure. - let force = repulsion_strength / (dist * dist); - let fx = if dx.abs() > 0.01 { - force * dx.signum() - } else { - 0.0 - }; - - forces_x[i] += fx; - forces_x[j] -= fx; - } - } - - // Apply forces with damping. - for (i, &node_idx) in node_indices.iter().enumerate() { - if let Some(pos) = positions.get_mut(&node_idx) - && let LayoutDirection::Vertical = self.direction - { - pos.0 += forces_x[i] * damping; - } - } - } - } -} - -impl Layouter for NestingLayouter { - fn execute( - &self, - model: &GraphModel, - ) -> (HashMap, HashMap) { - let mut positions = HashMap::new(); - let root_nodes = Self::root_nodes(model); - - if root_nodes.is_empty() { - return (positions, HashMap::new()); - } - - let node_roots = Self::build_node_roots(model); - let relations = Self::build_root_relations(model, &node_roots); - let ranks = Self::assign_root_ranks(&root_nodes, &relations); - let mut layers = Self::build_layers(model, &ranks); - let sorted_ranks = Self::sorted_ranks(&layers); - let (barycenter_spacing, layer_spacing, node_spacing) = - Self::spacing_for_root_count(root_nodes.len()); - let mut layer_coords = - Self::initialize_layer_coords(&layers, &sorted_ranks, barycenter_spacing); - Self::run_barycenter_passes( - &mut layers, - &sorted_ranks, - &mut layer_coords, - &relations, - barycenter_spacing, - ); - - let (sizes, root_sizes) = self.precompute_sizes(model, &root_nodes); - self.place_roots_in_layers( - model, - &layers, - &sorted_ranks, - &root_sizes, - &sizes, - &mut positions, - layer_spacing, - node_spacing, - ); - - self.apply_force_directed(&mut positions, &sizes, model, 5); - - (positions, sizes) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use codestory_core::{Edge, EdgeKind, Node, NodeId, NodeKind}; - - fn add_node(model: &mut GraphModel, id: i64, name: &str) { - model.add_node(Node { - id: NodeId(id), - kind: NodeKind::FUNCTION, - serialized_name: name.to_string(), - ..Default::default() - }); - } - - fn add_edge(model: &mut GraphModel, id: i64, source: i64, target: i64) { - model.add_edge(Edge { - id: codestory_core::EdgeId(id), - source: NodeId(source), - target: NodeId(target), - kind: EdgeKind::CALL, - ..Default::default() - }); - } - - #[test] - fn test_nesting_layout_returns_positions_and_sizes() { - let mut model = GraphModel::new(); - add_node(&mut model, 1, "Root"); - add_node(&mut model, 2, "Child"); - - let root_idx = *model.node_map.get(&NodeId(1)).unwrap(); - let child_idx = *model.node_map.get(&NodeId(2)).unwrap(); - - model.graph[root_idx].expanded = true; - model.graph[root_idx].children.push(NodeId(2)); - model.graph[child_idx].parent = Some(NodeId(1)); - - let layouter = NestingLayouter { - inner_padding: NestingLayouter::DEFAULT_INNER_PADDING, - child_spacing: NestingLayouter::DEFAULT_CHILD_SPACING, - direction: LayoutDirection::Vertical, - }; - - let (positions, sizes) = layouter.execute(&model); - - assert_eq!(positions.len(), 2); - assert_eq!(sizes.len(), 2); - assert!(sizes[&root_idx].y > sizes[&child_idx].y); - } - - #[test] - fn test_nesting_layout_direction_changes_primary_axis() { - let mut model = GraphModel::new(); - add_node(&mut model, 1, "A"); - add_node(&mut model, 2, "B"); - add_edge(&mut model, 1, 1, 2); - - let vertical = NestingLayouter { - inner_padding: NestingLayouter::DEFAULT_INNER_PADDING, - child_spacing: NestingLayouter::DEFAULT_CHILD_SPACING, - direction: LayoutDirection::Vertical, - }; - let horizontal = NestingLayouter { - inner_padding: NestingLayouter::DEFAULT_INNER_PADDING, - child_spacing: NestingLayouter::DEFAULT_CHILD_SPACING, - direction: LayoutDirection::Horizontal, - }; - - let (v_pos, _) = vertical.execute(&model); - let (h_pos, _) = horizontal.execute(&model); - - let a = *model.node_map.get(&NodeId(1)).unwrap(); - let b = *model.node_map.get(&NodeId(2)).unwrap(); - - assert!((v_pos[&b].1 - v_pos[&a].1).abs() > 0.1); - assert!((h_pos[&b].0 - h_pos[&a].0).abs() > 0.1); - } - - #[test] - fn test_edge_bundler_groups_parallel_edges() { - let mut model = GraphModel::new(); - add_node(&mut model, 1, "A"); - add_node(&mut model, 2, "B"); - - model.add_edge(Edge { - id: codestory_core::EdgeId(1), - source: NodeId(1), - target: NodeId(2), - kind: EdgeKind::CALL, - ..Default::default() - }); - model.add_edge(Edge { - id: codestory_core::EdgeId(2), - source: NodeId(1), - target: NodeId(2), - kind: EdgeKind::USAGE, - ..Default::default() - }); - - let bundles = EdgeBundler::bundle_edges(&model); - - assert_eq!(bundles.len(), 1); - assert_eq!(bundles[0].len(), 2); - } -} diff --git a/crates/codestory-graph/src/lib.rs b/crates/codestory-graph/src/lib.rs deleted file mode 100644 index ca88dfc..0000000 --- a/crates/codestory-graph/src/lib.rs +++ /dev/null @@ -1,25 +0,0 @@ -pub mod bundling; -pub mod converter; -pub mod edge_router; -pub mod graph; -pub mod hit_tester; -pub mod layout; -pub mod node_graph; -pub mod style; -pub mod uml_types; - -pub use bundling::NodeBundler; -pub use graph::{ - DummyEdge, DummyNode, EdgeIndex, GraphModel, GroupLayout, GroupType, NodeIndex, Vec2, -}; -pub use hit_tester::{EdgeBundleRegion, HitResult, HitTester}; -pub use layout::{EdgeBundler, Layouter, NestingLayouter}; -pub use style::{ - Color, EdgeStyle, GroupType as StyleGroupType, NodeColors, NodeStyle, get_bundle_style, - get_edge_kind_label, get_edge_style, get_group_style, get_kind_label, get_node_colors, - get_node_style, -}; -pub use uml_types::{ - AnchorSide, BundleInfo, EdgeAnchor, EdgeRoute, MemberItem, Rect, UmlNode, VisibilityKind, - VisibilitySection, -}; diff --git a/crates/codestory-graph/src/node_graph.rs b/crates/codestory-graph/src/node_graph.rs deleted file mode 100644 index 6cdffd7..0000000 --- a/crates/codestory-graph/src/node_graph.rs +++ /dev/null @@ -1,58 +0,0 @@ -use codestory_core::NodeId; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub enum PinType { - Standard, - Inheritance, - Composition, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct NodeMember { - pub id: NodeId, - pub name: String, - pub kind: codestory_core::NodeKind, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct NodeGraphNode { - pub id: NodeId, - pub parent_id: Option, - pub kind: codestory_core::NodeKind, - pub label: String, - pub members: Vec, - pub inputs: Vec, - pub outputs: Vec, - pub bundle_info: Option, - /// Whether this node is indexed (affects hatching pattern overlay) - /// Non-indexed nodes (external/unresolved symbols) will have diagonal hatching - #[serde(default = "default_is_indexed")] - pub is_indexed: bool, -} - -/// Default value for is_indexed field (true for backwards compatibility) -fn default_is_indexed() -> bool { - true -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct NodeGraphPin { - pub label: String, - pub pin_type: PinType, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct NodeGraphEdge { - pub id: codestory_core::EdgeId, - pub source_node: NodeId, - pub source_output_index: usize, - pub target_node: NodeId, - pub target_input_index: usize, - pub edge_type: PinType, -} - -#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)] -pub struct NodeGraph { - pub nodes: Vec, - pub edges: Vec, -} diff --git a/crates/codestory-graph/src/style.rs b/crates/codestory-graph/src/style.rs deleted file mode 100644 index 35c1e8b..0000000 --- a/crates/codestory-graph/src/style.rs +++ /dev/null @@ -1,1253 +0,0 @@ -//! Graph View Style System -//! -//! Provides color mapping for nodes and edges based on their types, -//! mirroring the Sourcetrail color scheme. - -use codestory_core::{EdgeKind, NodeKind}; - -/// RGB color representation -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct Color { - pub r: u8, - pub g: u8, - pub b: u8, - pub a: u8, -} - -impl Color { - pub const fn rgb(r: u8, g: u8, b: u8) -> Self { - Self { r, g, b, a: 255 } - } - - pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self { - Self { r, g, b, a } - } - - pub fn to_tuple(&self) -> (u8, u8, u8, u8) { - (self.r, self.g, self.b, self.a) - } - - pub fn darken(&self, factor: f32) -> Self { - Self { - r: ((self.r as f32) * (1.0 - factor)) as u8, - g: ((self.g as f32) * (1.0 - factor)) as u8, - b: ((self.b as f32) * (1.0 - factor)) as u8, - a: self.a, - } - } - - pub fn lighten(&self, factor: f32) -> Self { - Self { - r: ((self.r as f32) + (255.0 - self.r as f32) * factor) as u8, - g: ((self.g as f32) + (255.0 - self.g as f32) * factor) as u8, - b: ((self.b as f32) + (255.0 - self.b as f32) * factor) as u8, - a: self.a, - } - } -} - -/// Node color palette based on Sourcetrail's color scheme -#[derive(Debug, Clone, Copy)] -pub struct NodeColors { - pub fill: Color, - pub border: Color, - pub text: Color, - pub hatching: Color, // For non-indexed nodes -} - -/// Edge color and style -#[derive(Debug, Clone, Copy)] -pub struct EdgeStyle { - pub color: Color, - pub width: f32, - pub dashed: bool, - pub arrow_head: bool, -} - -/// Hatching pattern for non-indexed nodes -/// Defines diagonal striped pattern overlay -#[derive(Debug, Clone, Copy)] -pub struct HatchingPattern { - /// Color of the hatching lines - pub color: Color, - - /// Angle of the hatching lines in degrees (typically 45 for diagonal) - pub angle: f32, - - /// Spacing between hatching lines in pixels - pub spacing: f32, - - /// Width of each hatching line in pixels - pub line_width: f32, -} - -/// Complete style for a graph node -#[derive(Debug, Clone)] -pub struct NodeStyle { - pub colors: NodeColors, - pub corner_radius: f32, - pub font_size: f32, - pub font_bold: bool, - pub min_width: f32, - pub min_height: f32, - pub icon: Option<&'static str>, -} - -// ============================================================================ -// Color Constants - Based on Sourcetrail's color scheme -// ============================================================================ - -// Types and Classes (gray tones) -pub const COLOR_TYPE_FILL: Color = Color::rgb(85, 85, 85); -pub const COLOR_TYPE_BORDER: Color = Color::rgb(70, 70, 70); -pub const COLOR_TYPE_TEXT: Color = Color::rgb(255, 255, 255); - -// Functions and Methods (yellow/gold tones) -pub const COLOR_FUNCTION_FILL: Color = Color::rgb(200, 160, 80); -pub const COLOR_FUNCTION_BORDER: Color = Color::rgb(170, 130, 60); -pub const COLOR_FUNCTION_TEXT: Color = Color::rgb(30, 30, 30); - -// Variables and Fields (blue tones) -pub const COLOR_VARIABLE_FILL: Color = Color::rgb(80, 130, 180); -pub const COLOR_VARIABLE_BORDER: Color = Color::rgb(60, 110, 160); -pub const COLOR_VARIABLE_TEXT: Color = Color::rgb(255, 255, 255); - -// Files and Modules (green tones) -pub const COLOR_FILE_FILL: Color = Color::rgb(80, 140, 100); -pub const COLOR_FILE_BORDER: Color = Color::rgb(60, 120, 80); -pub const COLOR_FILE_TEXT: Color = Color::rgb(255, 255, 255); - -// Namespaces and Packages (purple tones) -pub const COLOR_NAMESPACE_FILL: Color = Color::rgb(130, 100, 160); -pub const COLOR_NAMESPACE_BORDER: Color = Color::rgb(110, 80, 140); -pub const COLOR_NAMESPACE_TEXT: Color = Color::rgb(255, 255, 255); - -// Macros (orange tones) -pub const COLOR_MACRO_FILL: Color = Color::rgb(200, 120, 80); -pub const COLOR_MACRO_BORDER: Color = Color::rgb(180, 100, 60); -pub const COLOR_MACRO_TEXT: Color = Color::rgb(255, 255, 255); - -// Enums (teal tones) -pub const COLOR_ENUM_FILL: Color = Color::rgb(80, 150, 150); -pub const COLOR_ENUM_BORDER: Color = Color::rgb(60, 130, 130); -pub const COLOR_ENUM_TEXT: Color = Color::rgb(255, 255, 255); - -// Unknown/Default -pub const COLOR_UNKNOWN_FILL: Color = Color::rgb(100, 100, 100); -pub const COLOR_UNKNOWN_BORDER: Color = Color::rgb(80, 80, 80); -pub const COLOR_UNKNOWN_TEXT: Color = Color::rgb(255, 255, 255); - -// Bundle nodes -pub const COLOR_BUNDLE_FILL: Color = Color::rgb(60, 60, 70); -pub const COLOR_BUNDLE_BORDER: Color = Color::rgb(50, 50, 60); -pub const COLOR_BUNDLE_TEXT: Color = Color::rgb(200, 200, 200); - -// Focus/Active colors -pub const COLOR_FOCUS_BORDER: Color = Color::rgb(255, 200, 100); -pub const COLOR_ACTIVE_FILL: Color = Color::rgb(60, 80, 100); -pub const COLOR_HOVER_OVERLAY: Color = Color::rgba(255, 255, 255, 30); - -// Hatching color for non-indexed nodes -pub const COLOR_HATCHING: Color = Color::rgba(50, 50, 50, 150); - -// Edge colors -pub const COLOR_EDGE_MEMBER: Color = Color::rgb(100, 100, 100); -pub const COLOR_EDGE_TYPE_USE: Color = Color::rgb(140, 140, 140); -pub const COLOR_EDGE_CALL: Color = Color::rgb(200, 160, 80); -pub const COLOR_EDGE_INHERITANCE: Color = Color::rgb(80, 130, 180); -pub const COLOR_EDGE_OVERRIDE: Color = Color::rgb(100, 150, 200); -pub const COLOR_EDGE_USAGE: Color = Color::rgb(80, 130, 180); -pub const COLOR_EDGE_IMPORT: Color = Color::rgb(80, 140, 100); -pub const COLOR_EDGE_INCLUDE: Color = Color::rgb(80, 140, 100); -pub const COLOR_EDGE_MACRO_USAGE: Color = Color::rgb(200, 120, 80); -pub const COLOR_EDGE_ANNOTATION: Color = Color::rgb(180, 100, 140); -pub const COLOR_EDGE_UNKNOWN: Color = Color::rgb(120, 120, 120); - -// ============================================================================ -// Style Functions -// ============================================================================ - -/// State tracking for node rendering -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub struct NodeState { - /// Whether the node is currently active (selected/clicked) - pub is_active: bool, - - /// Whether the node is focused (e.g., via keyboard navigation) - pub is_focused: bool, - - /// Whether the node is currently being hovered over - pub is_hovered: bool, - - /// Whether the node is indexed (affects hatching pattern) - pub is_indexed: bool, -} - -impl NodeState { - /// Create a new node state with all flags set to false except is_indexed - pub fn new() -> Self { - Self { - is_active: false, - is_focused: false, - is_hovered: false, - is_indexed: true, - } - } - - /// Create a node state for an indexed node - pub fn indexed() -> Self { - Self { - is_active: false, - is_focused: false, - is_hovered: false, - is_indexed: true, - } - } - - /// Create a node state for a non-indexed node - pub fn not_indexed() -> Self { - Self { - is_active: false, - is_focused: false, - is_hovered: false, - is_indexed: false, - } - } - - /// Set the active state - pub fn with_active(mut self, active: bool) -> Self { - self.is_active = active; - self - } - - /// Set the focused state - pub fn with_focused(mut self, focused: bool) -> Self { - self.is_focused = focused; - self - } - - /// Set the hovered state - pub fn with_hovered(mut self, hovered: bool) -> Self { - self.is_hovered = hovered; - self - } - - /// Set the indexed state - pub fn with_indexed(mut self, indexed: bool) -> Self { - self.is_indexed = indexed; - self - } -} - -/// State tracking for edge rendering -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub struct EdgeState { - /// Whether the edge is currently active (selected/clicked) - pub is_active: bool, - - /// Whether the edge is currently being hovered over - pub is_hovered: bool, - - /// Whether the edge is part of a bundle - pub is_bundled: bool, - - /// Whether the edge is currently focused - pub is_focused: bool, -} - -impl EdgeState { - /// Create a new edge state with all flags set to false - pub fn new() -> Self { - Self { - is_active: false, - is_hovered: false, - is_bundled: false, - is_focused: false, - } - } - - /// Create an edge state for a bundled edge - pub fn bundled() -> Self { - Self { - is_active: false, - is_hovered: false, - is_bundled: true, - is_focused: false, - } - } - - /// Set the active state - pub fn with_active(mut self, active: bool) -> Self { - self.is_active = active; - self - } - - /// Set the hovered state - pub fn with_hovered(mut self, hovered: bool) -> Self { - self.is_hovered = hovered; - self - } - - /// Set the bundled state - pub fn with_bundled(mut self, bundled: bool) -> Self { - self.is_bundled = bundled; - self - } - - /// Set the focused state - pub fn with_focused(mut self, focused: bool) -> Self { - self.is_focused = focused; - self - } -} - -/// Get the style for a node based on its kind -pub fn get_node_style( - kind: NodeKind, - is_active: bool, - is_focused: bool, - is_indexed: bool, -) -> NodeStyle { - let base_colors = get_node_colors(kind); - - let colors = if is_active { - NodeColors { - fill: COLOR_ACTIVE_FILL, - border: COLOR_FOCUS_BORDER, - text: base_colors.text, - hatching: base_colors.hatching, - } - } else if is_focused { - NodeColors { - fill: base_colors.fill.lighten(0.1), - border: COLOR_FOCUS_BORDER, - text: base_colors.text, - hatching: base_colors.hatching, - } - } else if !is_indexed { - NodeColors { - fill: base_colors.fill.darken(0.2), - border: base_colors.border, - text: base_colors.text.darken(0.2), - hatching: COLOR_HATCHING, - } - } else { - base_colors - }; - - NodeStyle { - colors, - corner_radius: 5.0, - font_size: get_font_size_for_kind(kind), - font_bold: is_type_kind(kind), - min_width: 80.0, - min_height: 30.0, - icon: get_icon_for_kind(kind), - } -} - -/// Get the base colors for a node kind -pub fn get_node_colors(kind: NodeKind) -> NodeColors { - match kind { - // Types - NodeKind::CLASS - | NodeKind::STRUCT - | NodeKind::INTERFACE - | NodeKind::UNION - | NodeKind::TYPEDEF - | NodeKind::TYPE_PARAMETER => NodeColors { - fill: COLOR_TYPE_FILL, - border: COLOR_TYPE_BORDER, - text: COLOR_TYPE_TEXT, - hatching: COLOR_HATCHING, - }, - - // Functions - NodeKind::FUNCTION | NodeKind::METHOD => NodeColors { - fill: COLOR_FUNCTION_FILL, - border: COLOR_FUNCTION_BORDER, - text: COLOR_FUNCTION_TEXT, - hatching: COLOR_HATCHING, - }, - - // Variables - NodeKind::GLOBAL_VARIABLE | NodeKind::FIELD | NodeKind::VARIABLE | NodeKind::CONSTANT => { - NodeColors { - fill: COLOR_VARIABLE_FILL, - border: COLOR_VARIABLE_BORDER, - text: COLOR_VARIABLE_TEXT, - hatching: COLOR_HATCHING, - } - } - - // Files - NodeKind::FILE => NodeColors { - fill: COLOR_FILE_FILL, - border: COLOR_FILE_BORDER, - text: COLOR_FILE_TEXT, - hatching: COLOR_HATCHING, - }, - - // Namespaces/Modules - NodeKind::MODULE | NodeKind::NAMESPACE | NodeKind::PACKAGE => NodeColors { - fill: COLOR_NAMESPACE_FILL, - border: COLOR_NAMESPACE_BORDER, - text: COLOR_NAMESPACE_TEXT, - hatching: COLOR_HATCHING, - }, - - // Enums - NodeKind::ENUM | NodeKind::ENUM_CONSTANT => NodeColors { - fill: COLOR_ENUM_FILL, - border: COLOR_ENUM_BORDER, - text: COLOR_ENUM_TEXT, - hatching: COLOR_HATCHING, - }, - - // Macros - NodeKind::MACRO => NodeColors { - fill: COLOR_MACRO_FILL, - border: COLOR_MACRO_BORDER, - text: COLOR_MACRO_TEXT, - hatching: COLOR_HATCHING, - }, - - // Annotations - NodeKind::ANNOTATION => NodeColors { - fill: COLOR_NAMESPACE_FILL, - border: COLOR_NAMESPACE_BORDER, - text: COLOR_NAMESPACE_TEXT, - hatching: COLOR_HATCHING, - }, - - // Builtin - NodeKind::BUILTIN_TYPE => NodeColors { - fill: COLOR_TYPE_FILL.lighten(0.2), - border: COLOR_TYPE_BORDER, - text: COLOR_TYPE_TEXT, - hatching: COLOR_HATCHING, - }, - - // Unknown - NodeKind::UNKNOWN => NodeColors { - fill: COLOR_UNKNOWN_FILL, - border: COLOR_UNKNOWN_BORDER, - text: COLOR_UNKNOWN_TEXT, - hatching: COLOR_HATCHING, - }, - } -} - -/// Get the style for a node based on its kind and state -pub fn get_node_style_with_state(kind: NodeKind, state: NodeState) -> NodeStyle { - get_node_style(kind, state.is_active, state.is_focused, state.is_indexed) -} - -/// Get the style for an edge based on its kind -pub fn get_edge_style(kind: EdgeKind, is_active: bool, is_focused: bool) -> EdgeStyle { - let base_color = get_edge_color(kind); - let base_width = get_edge_width(kind); - - let (color, width) = if is_active { - (COLOR_FOCUS_BORDER, base_width * 2.0) - } else if is_focused { - (base_color.lighten(0.3), base_width * 1.5) - } else { - (base_color, base_width) - }; - - EdgeStyle { - color, - width, - dashed: is_dashed_edge(kind), - arrow_head: has_arrow_head(kind), - } -} - -/// Get the style for an edge based on its kind and state -pub fn get_edge_style_with_state(kind: EdgeKind, state: EdgeState) -> EdgeStyle { - get_edge_style(kind, state.is_active, state.is_focused) -} - -/// Get the base color for an edge kind -pub fn get_edge_color(kind: EdgeKind) -> Color { - match kind { - EdgeKind::MEMBER => COLOR_EDGE_MEMBER, - EdgeKind::TYPE_USAGE => COLOR_EDGE_TYPE_USE, - EdgeKind::CALL => COLOR_EDGE_CALL, - EdgeKind::USAGE => COLOR_EDGE_USAGE, - EdgeKind::INHERITANCE => COLOR_EDGE_INHERITANCE, - EdgeKind::OVERRIDE => COLOR_EDGE_OVERRIDE, - EdgeKind::TYPE_ARGUMENT => COLOR_EDGE_TYPE_USE, - EdgeKind::TEMPLATE_SPECIALIZATION => COLOR_EDGE_TYPE_USE, - EdgeKind::IMPORT => COLOR_EDGE_IMPORT, - EdgeKind::INCLUDE => COLOR_EDGE_INCLUDE, - EdgeKind::MACRO_USAGE => COLOR_EDGE_MACRO_USAGE, - EdgeKind::ANNOTATION_USAGE => COLOR_EDGE_ANNOTATION, - EdgeKind::UNKNOWN => COLOR_EDGE_UNKNOWN, - } -} - -/// Get the width for an edge kind -fn get_edge_width(kind: EdgeKind) -> f32 { - match kind { - EdgeKind::MEMBER => 1.0, - EdgeKind::INHERITANCE | EdgeKind::OVERRIDE => 2.0, - EdgeKind::CALL => 1.5, - _ => 1.0, - } -} - -/// Check if edge should be dashed -fn is_dashed_edge(kind: EdgeKind) -> bool { - matches!(kind, EdgeKind::OVERRIDE | EdgeKind::TEMPLATE_SPECIALIZATION) -} - -/// Check if edge should have an arrow head -fn has_arrow_head(kind: EdgeKind) -> bool { - !matches!(kind, EdgeKind::MEMBER) -} - -/// Check if node kind is a type -fn is_type_kind(kind: NodeKind) -> bool { - matches!( - kind, - NodeKind::CLASS - | NodeKind::STRUCT - | NodeKind::INTERFACE - | NodeKind::ENUM - | NodeKind::UNION - | NodeKind::TYPEDEF - ) -} - -/// Get font size for node kind -fn get_font_size_for_kind(kind: NodeKind) -> f32 { - match kind { - NodeKind::FILE | NodeKind::MODULE | NodeKind::NAMESPACE | NodeKind::PACKAGE => 12.0, - NodeKind::CLASS | NodeKind::STRUCT | NodeKind::INTERFACE => 14.0, - _ => 13.0, - } -} - -/// Get icon identifier for node kind -fn get_icon_for_kind(kind: NodeKind) -> Option<&'static str> { - match kind { - NodeKind::CLASS => Some("class"), - NodeKind::STRUCT => Some("struct"), - NodeKind::INTERFACE => Some("interface"), - NodeKind::ENUM => Some("enum"), - NodeKind::FUNCTION => Some("function"), - NodeKind::METHOD => Some("method"), - NodeKind::FIELD => Some("field"), - NodeKind::VARIABLE => Some("variable"), - NodeKind::CONSTANT => Some("constant"), - NodeKind::FILE => Some("file"), - NodeKind::MODULE => Some("module"), - NodeKind::NAMESPACE => Some("namespace"), - NodeKind::PACKAGE => Some("package"), - NodeKind::MACRO => Some("macro"), - _ => None, - } -} - -/// Get the style for bundle nodes -pub fn get_bundle_style(is_focused: bool, bundled_kind: Option) -> NodeStyle { - let base_colors = if let Some(kind) = bundled_kind { - let mut colors = get_node_colors(kind); - colors.fill = colors.fill.darken(0.3); - colors - } else { - NodeColors { - fill: COLOR_BUNDLE_FILL, - border: COLOR_BUNDLE_BORDER, - text: COLOR_BUNDLE_TEXT, - hatching: COLOR_HATCHING, - } - }; - - let colors = if is_focused { - NodeColors { - fill: base_colors.fill.lighten(0.1), - border: COLOR_FOCUS_BORDER, - ..base_colors - } - } else { - base_colors - }; - - NodeStyle { - colors, - corner_radius: 5.0, - font_size: 13.0, - font_bold: false, - min_width: 100.0, - min_height: 30.0, - icon: Some("bundle"), - } -} - -/// Get the style for group nodes (file groups, namespace groups) -pub fn get_group_style(group_type: GroupType, is_focused: bool) -> NodeStyle { - let base_colors = match group_type { - GroupType::File => NodeColors { - fill: Color::rgba(80, 140, 100, 40), - border: COLOR_FILE_BORDER, - text: COLOR_FILE_TEXT, - hatching: COLOR_HATCHING, - }, - GroupType::Namespace => NodeColors { - fill: Color::rgba(130, 100, 160, 40), - border: COLOR_NAMESPACE_BORDER, - text: COLOR_NAMESPACE_TEXT, - hatching: COLOR_HATCHING, - }, - }; - - let colors = if is_focused { - NodeColors { - border: COLOR_FOCUS_BORDER, - ..base_colors - } - } else { - base_colors - }; - - NodeStyle { - colors, - corner_radius: 8.0, - font_size: 12.0, - font_bold: true, - min_width: 150.0, - min_height: 50.0, - icon: match group_type { - GroupType::File => Some("file"), - GroupType::Namespace => Some("namespace"), - }, - } -} - -/// Group type for grouping nodes -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum GroupType { - File, - Namespace, -} - -/// Get the human-readable label for a node kind -pub fn get_kind_label(kind: NodeKind) -> &'static str { - match kind { - NodeKind::MODULE => "module", - NodeKind::NAMESPACE => "namespace", - NodeKind::PACKAGE => "package", - NodeKind::FILE => "file", - NodeKind::STRUCT => "struct", - NodeKind::CLASS => "class", - NodeKind::INTERFACE => "interface", - NodeKind::ANNOTATION => "annotation", - NodeKind::UNION => "union", - NodeKind::ENUM => "enum", - NodeKind::TYPEDEF => "typedef", - NodeKind::TYPE_PARAMETER => "type param", - NodeKind::BUILTIN_TYPE => "builtin", - NodeKind::FUNCTION => "function", - NodeKind::METHOD => "method", - NodeKind::MACRO => "macro", - NodeKind::GLOBAL_VARIABLE => "global var", - NodeKind::FIELD => "field", - NodeKind::VARIABLE => "variable", - NodeKind::CONSTANT => "constant", - NodeKind::ENUM_CONSTANT => "enum const", - NodeKind::UNKNOWN => "symbol", - } -} - -/// Get the human-readable label for an edge kind -pub fn get_edge_kind_label(kind: EdgeKind) -> &'static str { - match kind { - EdgeKind::MEMBER => "member", - EdgeKind::TYPE_USAGE => "type use", - EdgeKind::USAGE => "usage", - EdgeKind::CALL => "call", - EdgeKind::INHERITANCE => "inherits", - EdgeKind::OVERRIDE => "overrides", - EdgeKind::TYPE_ARGUMENT => "type arg", - EdgeKind::TEMPLATE_SPECIALIZATION => "specialization", - EdgeKind::INCLUDE => "include", - EdgeKind::IMPORT => "import", - EdgeKind::MACRO_USAGE => "macro use", - EdgeKind::ANNOTATION_USAGE => "annotation", - EdgeKind::UNKNOWN => "edge", - } -} - -/// Get the hatching pattern for non-indexed nodes -/// Returns a diagonal striped pattern overlay configuration -pub fn hatching_pattern() -> HatchingPattern { - HatchingPattern { - color: COLOR_HATCHING, - angle: 45.0, // Diagonal stripes at 45 degrees - spacing: 8.0, // 8 pixels between lines - line_width: 1.5, // 1.5 pixel wide lines - } -} - -/// Calculate edge width based on edge kind and bundle count -/// -/// For bundled edges, uses logarithmic scaling: min(log2(bundle_count) + base_width, max_width) -/// where base_width = 1.0 and max_width = 6.0 -/// -/// # Arguments -/// * `kind` - The type of edge -/// * `bundle_count` - Number of edges in the bundle (use 1 for non-bundled edges) -/// -/// # Returns -/// The width in pixels for rendering the edge -/// -/// **Validates: Requirements 14.5, Property 31: Edge Bundle Thickness Scaling** -pub fn edge_width(kind: EdgeKind, bundle_count: usize) -> f32 { - const BASE_WIDTH: f32 = 1.0; - const MAX_WIDTH: f32 = 6.0; - - // Get the base width for this edge kind - let kind_base_width = get_edge_width(kind); - - // If bundle_count is 1 or less, just return the base width for the kind - if bundle_count <= 1 { - return kind_base_width; - } - - // Apply logarithmic scaling for bundled edges - // Formula: min(log2(bundle_count) + base_width, max_width) - let bundle_width = (bundle_count as f32).log2() + BASE_WIDTH; - let scaled_width = bundle_width.min(MAX_WIDTH); - - // Combine with the kind's base width (use the larger of the two) - scaled_width.max(kind_base_width) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_node_colors() { - let colors = get_node_colors(NodeKind::CLASS); - assert_eq!(colors.fill, COLOR_TYPE_FILL); - } - - // ======================================================================== - // Property-Based Tests - // ======================================================================== - - #[cfg(test)] - mod property_tests { - use super::*; - use proptest::prelude::*; - - /// Strategy to generate all possible NodeKind values - fn node_kind_strategy() -> impl Strategy { - prop_oneof![ - // Structural - Just(NodeKind::MODULE), - Just(NodeKind::NAMESPACE), - Just(NodeKind::PACKAGE), - Just(NodeKind::FILE), - // Types - Just(NodeKind::STRUCT), - Just(NodeKind::CLASS), - Just(NodeKind::INTERFACE), - Just(NodeKind::ANNOTATION), - Just(NodeKind::UNION), - Just(NodeKind::ENUM), - Just(NodeKind::TYPEDEF), - Just(NodeKind::TYPE_PARAMETER), - Just(NodeKind::BUILTIN_TYPE), - // Callable/Executable - Just(NodeKind::FUNCTION), - Just(NodeKind::METHOD), - Just(NodeKind::MACRO), - // Variables/Constants - Just(NodeKind::GLOBAL_VARIABLE), - Just(NodeKind::FIELD), - Just(NodeKind::VARIABLE), - Just(NodeKind::CONSTANT), - Just(NodeKind::ENUM_CONSTANT), - // Other - Just(NodeKind::UNKNOWN), - ] - } - - /// Strategy to generate all possible EdgeKind values - fn edge_kind_strategy() -> impl Strategy { - prop_oneof![ - // Definition/Hierarchy - Just(EdgeKind::MEMBER), - // Usage - Just(EdgeKind::TYPE_USAGE), - Just(EdgeKind::USAGE), - Just(EdgeKind::CALL), - // OOP - Just(EdgeKind::INHERITANCE), - Just(EdgeKind::OVERRIDE), - // Generics - Just(EdgeKind::TYPE_ARGUMENT), - Just(EdgeKind::TEMPLATE_SPECIALIZATION), - // Imports - Just(EdgeKind::INCLUDE), - Just(EdgeKind::IMPORT), - // Metaprogramming - Just(EdgeKind::MACRO_USAGE), - Just(EdgeKind::ANNOTATION_USAGE), - // Other - Just(EdgeKind::UNKNOWN), - ] - } - - proptest! { - /// **Validates: Requirements 1.5, 12.3** - /// - /// Property 1: Node Color Mapping Consistency - /// - /// For any NodeKind value, the StyleResolver SHALL return a color that matches - /// the UML semantic color scheme: - /// - Types (CLASS, STRUCT, INTERFACE, UNION, TYPEDEF, TYPE_PARAMETER) = gray - /// - Functions (FUNCTION, METHOD) = yellow/gold - /// - Variables (GLOBAL_VARIABLE, FIELD, VARIABLE, CONSTANT) = blue - /// - Files (FILE) = green - /// - Namespaces (MODULE, NAMESPACE, PACKAGE) = purple - /// - Enums (ENUM, ENUM_CONSTANT) = teal - /// - Macros (MACRO) = orange - /// - Annotations (ANNOTATION) = purple - /// - Builtin types = lighter gray - /// - Unknown = gray - #[test] - fn prop_node_color_mapping_consistency(kind in node_kind_strategy()) { - let colors = get_node_colors(kind); - - // Verify that the returned color matches the expected UML semantic scheme - match kind { - // Types should be gray - NodeKind::CLASS | NodeKind::STRUCT | NodeKind::INTERFACE | - NodeKind::UNION | NodeKind::TYPEDEF | NodeKind::TYPE_PARAMETER => { - prop_assert_eq!(colors.fill, COLOR_TYPE_FILL, - "Type nodes should use gray color scheme"); - prop_assert_eq!(colors.border, COLOR_TYPE_BORDER); - prop_assert_eq!(colors.text, COLOR_TYPE_TEXT); - } - - // Functions should be yellow/gold - NodeKind::FUNCTION | NodeKind::METHOD => { - prop_assert_eq!(colors.fill, COLOR_FUNCTION_FILL, - "Function nodes should use yellow/gold color scheme"); - prop_assert_eq!(colors.border, COLOR_FUNCTION_BORDER); - prop_assert_eq!(colors.text, COLOR_FUNCTION_TEXT); - } - - // Variables should be blue - NodeKind::GLOBAL_VARIABLE | NodeKind::FIELD | - NodeKind::VARIABLE | NodeKind::CONSTANT => { - prop_assert_eq!(colors.fill, COLOR_VARIABLE_FILL, - "Variable nodes should use blue color scheme"); - prop_assert_eq!(colors.border, COLOR_VARIABLE_BORDER); - prop_assert_eq!(colors.text, COLOR_VARIABLE_TEXT); - } - - // Files should be green - NodeKind::FILE => { - prop_assert_eq!(colors.fill, COLOR_FILE_FILL, - "File nodes should use green color scheme"); - prop_assert_eq!(colors.border, COLOR_FILE_BORDER); - prop_assert_eq!(colors.text, COLOR_FILE_TEXT); - } - - // Namespaces should be purple - NodeKind::MODULE | NodeKind::NAMESPACE | NodeKind::PACKAGE => { - prop_assert_eq!(colors.fill, COLOR_NAMESPACE_FILL, - "Namespace nodes should use purple color scheme"); - prop_assert_eq!(colors.border, COLOR_NAMESPACE_BORDER); - prop_assert_eq!(colors.text, COLOR_NAMESPACE_TEXT); - } - - // Enums should be teal - NodeKind::ENUM | NodeKind::ENUM_CONSTANT => { - prop_assert_eq!(colors.fill, COLOR_ENUM_FILL, - "Enum nodes should use teal color scheme"); - prop_assert_eq!(colors.border, COLOR_ENUM_BORDER); - prop_assert_eq!(colors.text, COLOR_ENUM_TEXT); - } - - // Macros should be orange - NodeKind::MACRO => { - prop_assert_eq!(colors.fill, COLOR_MACRO_FILL, - "Macro nodes should use orange color scheme"); - prop_assert_eq!(colors.border, COLOR_MACRO_BORDER); - prop_assert_eq!(colors.text, COLOR_MACRO_TEXT); - } - - // Annotations should be purple (same as namespaces) - NodeKind::ANNOTATION => { - prop_assert_eq!(colors.fill, COLOR_NAMESPACE_FILL, - "Annotation nodes should use purple color scheme"); - prop_assert_eq!(colors.border, COLOR_NAMESPACE_BORDER); - prop_assert_eq!(colors.text, COLOR_NAMESPACE_TEXT); - } - - // Builtin types should be lighter gray - NodeKind::BUILTIN_TYPE => { - let expected_fill = COLOR_TYPE_FILL.lighten(0.2); - prop_assert_eq!(colors.fill, expected_fill, - "Builtin type nodes should use lighter gray color scheme"); - prop_assert_eq!(colors.border, COLOR_TYPE_BORDER); - prop_assert_eq!(colors.text, COLOR_TYPE_TEXT); - } - - // Unknown should be gray - NodeKind::UNKNOWN => { - prop_assert_eq!(colors.fill, COLOR_UNKNOWN_FILL, - "Unknown nodes should use gray color scheme"); - prop_assert_eq!(colors.border, COLOR_UNKNOWN_BORDER); - prop_assert_eq!(colors.text, COLOR_UNKNOWN_TEXT); - } - } - - // All nodes should have hatching color set - prop_assert_eq!(colors.hatching, COLOR_HATCHING, - "All nodes should have consistent hatching color"); - } - - /// **Validates: Requirements 3.4** - /// - /// Property 8: Edge Color Mapping - /// - /// For any EdgeKind value, the StyleResolver SHALL return the correct semantic color: - /// - CALL = yellow (200, 160, 80) - /// - INHERITANCE = blue (80, 130, 180) - /// - MEMBER = gray (100, 100, 100) - /// - TYPE_USAGE = gray (140, 140, 140) - /// - USAGE = blue (80, 130, 180) - /// - OVERRIDE = blue (100, 150, 200) - /// - TYPE_ARGUMENT = gray (140, 140, 140) - /// - TEMPLATE_SPECIALIZATION = gray (140, 140, 140) - /// - IMPORT = green (80, 140, 100) - /// - INCLUDE = green (80, 140, 100) - /// - MACRO_USAGE = orange (200, 120, 80) - /// - ANNOTATION_USAGE = purple/pink (180, 100, 140) - /// - UNKNOWN = gray (120, 120, 120) - #[test] - fn prop_edge_color_mapping(kind in edge_kind_strategy()) { - let color = get_edge_color(kind); - - // Verify that the returned color matches the expected semantic scheme - match kind { - // CALL edges should be yellow/gold - EdgeKind::CALL => { - prop_assert_eq!(color, COLOR_EDGE_CALL, - "CALL edges should use yellow/gold color (200, 160, 80)"); - prop_assert_eq!(color.r, 200); - prop_assert_eq!(color.g, 160); - prop_assert_eq!(color.b, 80); - } - - // INHERITANCE edges should be blue - EdgeKind::INHERITANCE => { - prop_assert_eq!(color, COLOR_EDGE_INHERITANCE, - "INHERITANCE edges should use blue color (80, 130, 180)"); - prop_assert_eq!(color.r, 80); - prop_assert_eq!(color.g, 130); - prop_assert_eq!(color.b, 180); - } - - // MEMBER edges should be gray - EdgeKind::MEMBER => { - prop_assert_eq!(color, COLOR_EDGE_MEMBER, - "MEMBER edges should use gray color (100, 100, 100)"); - prop_assert_eq!(color.r, 100); - prop_assert_eq!(color.g, 100); - prop_assert_eq!(color.b, 100); - } - - // TYPE_USAGE edges should be gray - EdgeKind::TYPE_USAGE => { - prop_assert_eq!(color, COLOR_EDGE_TYPE_USE, - "TYPE_USAGE edges should use gray color (140, 140, 140)"); - prop_assert_eq!(color.r, 140); - prop_assert_eq!(color.g, 140); - prop_assert_eq!(color.b, 140); - } - - // USAGE edges should be blue - EdgeKind::USAGE => { - prop_assert_eq!(color, COLOR_EDGE_USAGE, - "USAGE edges should use blue color (80, 130, 180)"); - prop_assert_eq!(color.r, 80); - prop_assert_eq!(color.g, 130); - prop_assert_eq!(color.b, 180); - } - - // OVERRIDE edges should be blue (lighter shade) - EdgeKind::OVERRIDE => { - prop_assert_eq!(color, COLOR_EDGE_OVERRIDE, - "OVERRIDE edges should use blue color (100, 150, 200)"); - prop_assert_eq!(color.r, 100); - prop_assert_eq!(color.g, 150); - prop_assert_eq!(color.b, 200); - } - - // TYPE_ARGUMENT edges should be gray (same as TYPE_USAGE) - EdgeKind::TYPE_ARGUMENT => { - prop_assert_eq!(color, COLOR_EDGE_TYPE_USE, - "TYPE_ARGUMENT edges should use gray color (140, 140, 140)"); - prop_assert_eq!(color.r, 140); - prop_assert_eq!(color.g, 140); - prop_assert_eq!(color.b, 140); - } - - // TEMPLATE_SPECIALIZATION edges should be gray (same as TYPE_USAGE) - EdgeKind::TEMPLATE_SPECIALIZATION => { - prop_assert_eq!(color, COLOR_EDGE_TYPE_USE, - "TEMPLATE_SPECIALIZATION edges should use gray color (140, 140, 140)"); - prop_assert_eq!(color.r, 140); - prop_assert_eq!(color.g, 140); - prop_assert_eq!(color.b, 140); - } - - // IMPORT edges should be green - EdgeKind::IMPORT => { - prop_assert_eq!(color, COLOR_EDGE_IMPORT, - "IMPORT edges should use green color (80, 140, 100)"); - prop_assert_eq!(color.r, 80); - prop_assert_eq!(color.g, 140); - prop_assert_eq!(color.b, 100); - } - - // INCLUDE edges should be green - EdgeKind::INCLUDE => { - prop_assert_eq!(color, COLOR_EDGE_INCLUDE, - "INCLUDE edges should use green color (80, 140, 100)"); - prop_assert_eq!(color.r, 80); - prop_assert_eq!(color.g, 140); - prop_assert_eq!(color.b, 100); - } - - // MACRO_USAGE edges should be orange - EdgeKind::MACRO_USAGE => { - prop_assert_eq!(color, COLOR_EDGE_MACRO_USAGE, - "MACRO_USAGE edges should use orange color (200, 120, 80)"); - prop_assert_eq!(color.r, 200); - prop_assert_eq!(color.g, 120); - prop_assert_eq!(color.b, 80); - } - - // ANNOTATION_USAGE edges should be purple/pink - EdgeKind::ANNOTATION_USAGE => { - prop_assert_eq!(color, COLOR_EDGE_ANNOTATION, - "ANNOTATION_USAGE edges should use purple/pink color (180, 100, 140)"); - prop_assert_eq!(color.r, 180); - prop_assert_eq!(color.g, 100); - prop_assert_eq!(color.b, 140); - } - - // UNKNOWN edges should be gray - EdgeKind::UNKNOWN => { - prop_assert_eq!(color, COLOR_EDGE_UNKNOWN, - "UNKNOWN edges should use gray color (120, 120, 120)"); - prop_assert_eq!(color.r, 120); - prop_assert_eq!(color.g, 120); - prop_assert_eq!(color.b, 120); - } - } - } - } - } - - #[test] - fn test_edge_colors() { - let color = get_edge_color(EdgeKind::CALL); - assert_eq!(color, COLOR_EDGE_CALL); - } - - #[test] - fn test_color_darken() { - let color = Color::rgb(100, 100, 100); - let darkened = color.darken(0.5); - assert_eq!(darkened.r, 50); - } - - #[test] - fn test_kind_labels() { - assert_eq!(get_kind_label(NodeKind::CLASS), "class"); - assert_eq!(get_edge_kind_label(EdgeKind::CALL), "call"); - } - - #[test] - fn test_node_state_creation() { - let state = NodeState::new(); - assert!(!state.is_active); - assert!(!state.is_focused); - assert!(!state.is_hovered); - assert!(state.is_indexed); - } - - #[test] - fn test_node_state_indexed() { - let state = NodeState::indexed(); - assert!(state.is_indexed); - assert!(!state.is_active); - } - - #[test] - fn test_node_state_not_indexed() { - let state = NodeState::not_indexed(); - assert!(!state.is_indexed); - assert!(!state.is_active); - } - - #[test] - fn test_node_state_builder() { - let state = NodeState::new() - .with_active(true) - .with_focused(true) - .with_hovered(true) - .with_indexed(false); - - assert!(state.is_active); - assert!(state.is_focused); - assert!(state.is_hovered); - assert!(!state.is_indexed); - } - - #[test] - fn test_edge_state_creation() { - let state = EdgeState::new(); - assert!(!state.is_active); - assert!(!state.is_hovered); - assert!(!state.is_bundled); - } - - #[test] - fn test_edge_state_bundled() { - let state = EdgeState::bundled(); - assert!(state.is_bundled); - assert!(!state.is_active); - assert!(!state.is_hovered); - } - - #[test] - fn test_edge_state_builder() { - let state = EdgeState::new() - .with_active(true) - .with_hovered(true) - .with_bundled(true); - - assert!(state.is_active); - assert!(state.is_hovered); - assert!(state.is_bundled); - } - - #[test] - fn test_get_node_style_with_state() { - let state = NodeState::new().with_active(true); - let style = get_node_style_with_state(NodeKind::CLASS, state); - assert_eq!(style.colors.fill, COLOR_ACTIVE_FILL); - } - - #[test] - fn test_get_edge_style_with_state() { - let state = EdgeState::new().with_active(true); - let style = get_edge_style_with_state(EdgeKind::CALL, state); - assert_eq!(style.color, COLOR_FOCUS_BORDER); - } - - #[test] - fn test_hatching_pattern() { - let pattern = hatching_pattern(); - assert_eq!(pattern.color, COLOR_HATCHING); - assert_eq!(pattern.angle, 45.0); - assert_eq!(pattern.spacing, 8.0); - assert_eq!(pattern.line_width, 1.5); - } - - #[test] - fn test_hatching_pattern_color_has_transparency() { - let pattern = hatching_pattern(); - // Hatching color should have some transparency for overlay effect - assert!(pattern.color.a < 255); - } - - #[test] - fn test_edge_width_single_edge() { - // Single edge (bundle_count = 1) should return base width for the kind - let width = edge_width(EdgeKind::CALL, 1); - assert_eq!(width, 1.5); // CALL edges have base width 1.5 - - let width = edge_width(EdgeKind::MEMBER, 1); - assert_eq!(width, 1.0); // MEMBER edges have base width 1.0 - - let width = edge_width(EdgeKind::INHERITANCE, 1); - assert_eq!(width, 2.0); // INHERITANCE edges have base width 2.0 - } - - #[test] - fn test_edge_width_bundle_scaling() { - // Test logarithmic scaling for bundled edges - // Formula: min(log2(bundle_count) + 1.0, 6.0) - - // bundle_count = 2: log2(2) + 1.0 = 1.0 + 1.0 = 2.0 - let width = edge_width(EdgeKind::MEMBER, 2); - assert_eq!(width, 2.0); - - // bundle_count = 4: log2(4) + 1.0 = 2.0 + 1.0 = 3.0 - let width = edge_width(EdgeKind::MEMBER, 4); - assert_eq!(width, 3.0); - - // bundle_count = 8: log2(8) + 1.0 = 3.0 + 1.0 = 4.0 - let width = edge_width(EdgeKind::MEMBER, 8); - assert_eq!(width, 4.0); - - // bundle_count = 16: log2(16) + 1.0 = 4.0 + 1.0 = 5.0 - let width = edge_width(EdgeKind::MEMBER, 16); - assert_eq!(width, 5.0); - - // bundle_count = 32: log2(32) + 1.0 = 5.0 + 1.0 = 6.0 - let width = edge_width(EdgeKind::MEMBER, 32); - assert_eq!(width, 6.0); - } - - #[test] - fn test_edge_width_max_clamping() { - // Test that width is clamped to MAX_WIDTH (6.0) - // bundle_count = 64: log2(64) + 1.0 = 6.0 + 1.0 = 7.0, clamped to 6.0 - let width = edge_width(EdgeKind::MEMBER, 64); - assert_eq!(width, 6.0); - - // bundle_count = 128: log2(128) + 1.0 = 7.0 + 1.0 = 8.0, clamped to 6.0 - let width = edge_width(EdgeKind::MEMBER, 128); - assert_eq!(width, 6.0); - - // Very large bundle count should still be clamped - let width = edge_width(EdgeKind::MEMBER, 1000); - assert_eq!(width, 6.0); - } - - #[test] - fn test_edge_width_zero_bundle() { - // bundle_count = 0 should be treated as non-bundled - let width = edge_width(EdgeKind::CALL, 0); - assert_eq!(width, 1.5); // Should return base width for CALL - } - - #[test] - fn test_edge_width_respects_kind_base() { - // For edges with higher base width (like INHERITANCE = 2.0), - // the result should be at least the kind's base width - let width = edge_width(EdgeKind::INHERITANCE, 2); - // log2(2) + 1.0 = 2.0, but INHERITANCE base is 2.0, so should be max(2.0, 2.0) = 2.0 - assert_eq!(width, 2.0); - - // With more edges, should scale up - let width = edge_width(EdgeKind::INHERITANCE, 4); - // log2(4) + 1.0 = 3.0, max(3.0, 2.0) = 3.0 - assert_eq!(width, 3.0); - } -} diff --git a/crates/codestory-graph/src/uml_types.rs b/crates/codestory-graph/src/uml_types.rs deleted file mode 100644 index fcdb9f0..0000000 --- a/crates/codestory-graph/src/uml_types.rs +++ /dev/null @@ -1,2846 +0,0 @@ -use codestory_core::{EdgeId, EdgeKind, NodeId, NodeKind}; -use codestory_events::LayoutAlgorithm; -use serde::{Deserialize, Serialize}; -use std::collections::{HashMap, HashSet}; - -use crate::Vec2; - -/// A rectangle defined by min and max corners -#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)] -pub struct Rect { - pub min: Vec2, - pub max: Vec2, -} - -impl Rect { - /// Create a new rectangle from min and max corners - pub fn from_min_max(min: Vec2, max: Vec2) -> Self { - Self { min, max } - } - - /// Create a new rectangle from position and size - pub fn from_pos_size(pos: Vec2, size: Vec2) -> Self { - Self { - min: pos, - max: Vec2::new(pos.x + size.x, pos.y + size.y), - } - } - - /// An empty rectangle - pub const NOTHING: Self = Self { - min: Vec2 { x: 0.0, y: 0.0 }, - max: Vec2 { x: 0.0, y: 0.0 }, - }; - - /// Get the width of the rectangle - pub fn width(&self) -> f32 { - self.max.x - self.min.x - } - - /// Get the height of the rectangle - pub fn height(&self) -> f32 { - self.max.y - self.min.y - } - - /// Get the size of the rectangle - pub fn size(&self) -> Vec2 { - Vec2::new(self.width(), self.height()) - } - - /// Get the center of the rectangle - pub fn center(&self) -> Vec2 { - Vec2::new( - self.min.x + self.width() * 0.5, - self.min.y + self.height() * 0.5, - ) - } - - /// Check if the rectangle contains a point - pub fn contains(&self, point: Vec2) -> bool { - point.x >= self.min.x - && point.x <= self.max.x - && point.y >= self.min.y - && point.y <= self.max.y - } - - /// Check if this rectangle intersects with another rectangle - pub fn intersects(&self, other: &Rect) -> bool { - self.min.x <= other.max.x - && self.max.x >= other.min.x - && self.min.y <= other.max.y - && self.max.y >= other.min.y - } - - /// Return a new rectangle expanded by `amount` on all sides - pub fn expand(&self, amount: f32) -> Rect { - Rect { - min: Vec2::new(self.min.x - amount, self.min.y - amount), - max: Vec2::new(self.max.x + amount, self.max.y + amount), - } - } -} - -/// A rectangle that is linked to a specific node or member context -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct AnchoredRect { - pub rect: Rect, - pub node_id: crate::NodeIndex, -} - -/// A node in the UML-style graph -#[derive(Debug, Clone)] -pub struct UmlNode { - /// Core node data from codestory-core - pub id: NodeId, - pub kind: NodeKind, - pub label: String, - - /// Parent node (for member relationships) - pub parent_id: Option, - - /// Whether this node is indexed (affects hatching) - pub is_indexed: bool, - - /// Members grouped by visibility - pub visibility_sections: Vec, - - /// Collapse state - pub is_collapsed: bool, - pub collapsed_sections: HashSet, - - /// Computed layout info - pub computed_rect: Rect, - pub member_rects: HashMap, - - /// Bundle info (if this represents bundled nodes) - pub bundle_info: Option, -} - -impl UmlNode { - /// Create a new UmlNode with default values - pub fn new(id: NodeId, kind: NodeKind, label: String) -> Self { - Self { - id, - kind, - label, - parent_id: None, - is_indexed: true, - visibility_sections: Vec::new(), - is_collapsed: false, - collapsed_sections: HashSet::new(), - computed_rect: Rect::NOTHING, - member_rects: HashMap::new(), - bundle_info: None, - } - } - - /// Create a new UmlNode with parent - pub fn with_parent(id: NodeId, kind: NodeKind, label: String, parent_id: NodeId) -> Self { - Self { - id, - kind, - label, - parent_id: Some(parent_id), - is_indexed: true, - visibility_sections: Vec::new(), - is_collapsed: false, - collapsed_sections: HashSet::new(), - computed_rect: Rect::NOTHING, - member_rects: HashMap::new(), - bundle_info: None, - } - } - - /// Calculate the size of a container node based on its content - /// - /// This method implements Property 2: Container Node Size Calculation - /// For any Container_Node with N members across M visibility sections, - /// the computed height SHALL be >= header_height + sum(section_header_heights) + - /// sum(member_row_heights) + padding. - /// - /// # Parameters - /// - `header_height`: Height of the node header (typically 30-40px) - /// - `section_header_height`: Height of each visibility section header (typically 20-25px) - /// - `member_row_height`: Height of each member row (typically 20-25px) - /// - `padding`: Total padding (top + bottom + between sections, typically 16-24px) - /// - `min_width`: Minimum width for the node (typically 150-200px) - /// - /// # Returns - /// A `Vec2` representing the calculated size (width, height) - /// - /// # Example - /// ``` - /// use codestory_graph::uml_types::{UmlNode, VisibilitySection, VisibilityKind, MemberItem}; - /// use codestory_core::{NodeId, NodeKind}; - /// use codestory_graph::Vec2; - /// - /// let mut node = UmlNode::new(NodeId(1), NodeKind::CLASS, "TestClass".to_string()); - /// - /// // Add a section with 3 members - /// let members = vec![ - /// MemberItem::new(NodeId(2), NodeKind::METHOD, "method1".to_string()), - /// MemberItem::new(NodeId(3), NodeKind::METHOD, "method2".to_string()), - /// MemberItem::new(NodeId(4), NodeKind::FIELD, "field1".to_string()), - /// ]; - /// node.visibility_sections.push(VisibilitySection::with_members(VisibilityKind::Public, members)); - /// - /// let size = node.calculate_size(30.0, 20.0, 20.0, 16.0, 150.0); - /// - /// // Expected: header(30) + section_header(20) + 3*member_row(60) + padding(16) = 126 - /// assert!(size.y >= 126.0); - /// ``` - pub fn calculate_size( - &self, - header_height: f32, - section_header_height: f32, - member_row_height: f32, - padding: f32, - min_width: f32, - ) -> Vec2 { - // Start with header height - let mut total_height = header_height; - - // If node is collapsed, only show header with member count badge - if self.is_collapsed { - // Add minimal padding for collapsed state - total_height += padding * 0.5; - return Vec2::new(min_width, total_height); - } - - // Add padding before sections - total_height += padding * 0.5; - - // Calculate height for each visibility section - for section in &self.visibility_sections { - // Skip empty sections - if section.members.is_empty() { - continue; - } - - // Add section header height - total_height += section_header_height; - - // If section is collapsed, only show header with count badge - if section.is_collapsed { - // Add minimal spacing after collapsed section - total_height += padding * 0.25; - continue; - } - - // Add height for each member in the section - let member_count = section.members.len(); - total_height += member_count as f32 * member_row_height; - - // Add spacing between sections - total_height += padding * 0.5; - } - - // Add bottom padding - total_height += padding * 0.5; - - // Calculate width based on content (for now, use min_width) - // In a full implementation, this would measure text width - let width = min_width; - - Vec2::new(width, total_height) - } - - /// Calculate the size with default UI constants - /// - /// This is a convenience method that uses standard UI dimensions: - /// - Header height: 35px - /// - Section header height: 22px - /// - Member row height: 22px - /// - Padding: 16px - /// - Minimum width: 180px - pub fn calculate_size_default(&self) -> Vec2 { - const DEFAULT_HEADER_HEIGHT: f32 = 35.0; - const DEFAULT_SECTION_HEADER_HEIGHT: f32 = 22.0; - const DEFAULT_MEMBER_ROW_HEIGHT: f32 = 22.0; - const DEFAULT_PADDING: f32 = 16.0; - const DEFAULT_MIN_WIDTH: f32 = 180.0; - - self.calculate_size( - DEFAULT_HEADER_HEIGHT, - DEFAULT_SECTION_HEADER_HEIGHT, - DEFAULT_MEMBER_ROW_HEIGHT, - DEFAULT_PADDING, - DEFAULT_MIN_WIDTH, - ) - } -} - -/// A visibility section within a container node -#[derive(Debug, Clone)] -pub struct VisibilitySection { - pub kind: VisibilityKind, - pub members: Vec, - pub is_collapsed: bool, -} - -impl VisibilitySection { - /// Create a new visibility section - pub fn new(kind: VisibilityKind) -> Self { - Self { - kind, - members: Vec::new(), - is_collapsed: false, - } - } - - /// Create a new visibility section with members - pub fn with_members(kind: VisibilityKind, members: Vec) -> Self { - Self { - kind, - members, - is_collapsed: false, - } - } - - /// Get the number of members in this section - pub fn member_count(&self) -> usize { - self.members.len() - } -} - -/// Visibility kind for grouping members within a container node -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum VisibilityKind { - Public, - Private, - Protected, - Internal, - Functions, // Fallback grouping by type - Variables, - Other, -} - -impl VisibilityKind { - /// Get the display label for this visibility kind - pub fn label(&self) -> &'static str { - match self { - VisibilityKind::Public => "PUBLIC", - VisibilityKind::Private => "PRIVATE", - VisibilityKind::Protected => "PROTECTED", - VisibilityKind::Internal => "INTERNAL", - VisibilityKind::Functions => "FUNCTIONS", - VisibilityKind::Variables => "VARIABLES", - VisibilityKind::Other => "OTHER", - } - } -} - -impl std::str::FromStr for VisibilityKind { - type Err = (); - - fn from_str(s: &str) -> Result { - match s.to_uppercase().as_str() { - "PUBLIC" => Ok(VisibilityKind::Public), - "PRIVATE" => Ok(VisibilityKind::Private), - "PROTECTED" => Ok(VisibilityKind::Protected), - "INTERNAL" => Ok(VisibilityKind::Internal), - "FUNCTIONS" => Ok(VisibilityKind::Functions), - "VARIABLES" => Ok(VisibilityKind::Variables), - "OTHER" => Ok(VisibilityKind::Other), - _ => Err(()), - } - } -} - -/// A member item within a visibility section -#[derive(Debug, Clone)] -pub struct MemberItem { - pub id: NodeId, - pub kind: NodeKind, - pub name: String, - pub has_outgoing_edges: bool, - pub signature: Option, -} - -impl MemberItem { - /// Create a new member item - pub fn new(id: NodeId, kind: NodeKind, name: String) -> Self { - Self { - id, - kind, - name, - has_outgoing_edges: false, - signature: None, - } - } - - /// Create a new member item with signature - pub fn with_signature(id: NodeId, kind: NodeKind, name: String, signature: String) -> Self { - Self { - id, - kind, - name, - has_outgoing_edges: false, - signature: Some(signature), - } - } - - /// Set whether this member has outgoing edges - pub fn set_has_outgoing_edges(&mut self, has_edges: bool) { - self.has_outgoing_edges = has_edges; - } -} - -/// Information about a bundle of nodes -#[derive(Debug, Clone)] -pub struct BundleInfo { - /// The node IDs that are bundled together - pub bundled_node_ids: Vec, - - /// The count of bundled nodes - pub count: usize, - - /// Whether the bundle is expanded - pub is_expanded: bool, -} - -impl BundleInfo { - /// Create a new bundle info - pub fn new(bundled_node_ids: Vec) -> Self { - let count = bundled_node_ids.len(); - Self { - bundled_node_ids, - count, - is_expanded: false, - } - } - - /// Toggle the expanded state - pub fn toggle_expanded(&mut self) { - self.is_expanded = !self.is_expanded; - } -} - -/// A routed edge with bezier control points -#[derive(Debug, Clone)] -pub struct EdgeRoute { - /// Unique identifier for this edge - pub id: EdgeId, - - /// Source anchor point - pub source: EdgeAnchor, - - /// Target anchor point - pub target: EdgeAnchor, - - /// Type of edge relationship - pub kind: EdgeKind, - - /// Bezier control points for the curve - pub control_points: Vec, - - /// Whether this edge is part of a bundle - pub is_bundled: bool, - - /// Number of edges in the bundle (1 if not bundled) - pub bundle_count: usize, -} - -impl EdgeRoute { - /// Create a new edge route - pub fn new(id: EdgeId, source: EdgeAnchor, target: EdgeAnchor, kind: EdgeKind) -> Self { - Self { - id, - source, - target, - kind, - control_points: Vec::new(), - is_bundled: false, - bundle_count: 1, - } - } - - /// Create a new edge route with control points - pub fn with_control_points( - id: EdgeId, - source: EdgeAnchor, - target: EdgeAnchor, - kind: EdgeKind, - control_points: Vec, - ) -> Self { - Self { - id, - source, - target, - kind, - control_points, - is_bundled: false, - bundle_count: 1, - } - } - - /// Create a bundled edge route - pub fn bundled( - id: EdgeId, - source: EdgeAnchor, - target: EdgeAnchor, - kind: EdgeKind, - control_points: Vec, - bundle_count: usize, - ) -> Self { - Self { - id, - source, - target, - kind, - control_points, - is_bundled: true, - bundle_count, - } - } -} - -/// Where an edge connects to a node -#[derive(Debug, Clone, Copy)] -pub struct EdgeAnchor { - /// The node this anchor is attached to - pub node_id: NodeId, - - /// If connecting to a specific member within the node - pub member_id: Option, - - /// Computed screen position of the anchor point - pub position: Vec2, - - /// Which side of the node/member the anchor is on - pub side: AnchorSide, -} - -impl EdgeAnchor { - /// Create a new edge anchor - pub fn new(node_id: NodeId, position: Vec2, side: AnchorSide) -> Self { - Self { - node_id, - member_id: None, - position, - side, - } - } - - /// Create a new edge anchor for a specific member - pub fn for_member( - node_id: NodeId, - member_id: NodeId, - position: Vec2, - side: AnchorSide, - ) -> Self { - Self { - node_id, - member_id: Some(member_id), - position, - side, - } - } -} - -/// Which side of a node or member an anchor is on -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum AnchorSide { - Left, - Right, - Top, - Bottom, -} - -impl AnchorSide { - /// Get the opposite side - pub fn opposite(&self) -> Self { - match self { - AnchorSide::Left => AnchorSide::Right, - AnchorSide::Right => AnchorSide::Left, - AnchorSide::Top => AnchorSide::Bottom, - AnchorSide::Bottom => AnchorSide::Top, - } - } - - /// Get a unit vector pointing in the direction of this side - pub fn direction_vector(&self) -> Vec2 { - match self { - AnchorSide::Left => Vec2::new(-1.0, 0.0), - AnchorSide::Right => Vec2::new(1.0, 0.0), - AnchorSide::Top => Vec2::new(0.0, -1.0), - AnchorSide::Bottom => Vec2::new(0.0, 1.0), - } - } -} - -/// A bundle of multiple edges between the same source and target nodes -#[derive(Debug, Clone)] -pub struct EdgeBundle { - /// All edges in this bundle - pub edges: Vec, - - /// Source node for all edges in the bundle - pub source_node: NodeId, - - /// Target node for all edges in the bundle - pub target_node: NodeId, - - /// The computed route for rendering the bundle - pub route: EdgeRoute, -} - -impl EdgeBundle { - /// Create a new edge bundle - pub fn new( - edges: Vec, - source_node: NodeId, - target_node: NodeId, - route: EdgeRoute, - ) -> Self { - Self { - edges, - source_node, - target_node, - route, - } - } - - /// Get the number of edges in this bundle - pub fn edge_count(&self) -> usize { - self.edges.len() - } - - /// Check if this bundle contains a specific edge - pub fn contains_edge(&self, edge_id: EdgeId) -> bool { - self.edges.contains(&edge_id) - } -} - -/// Information about a bundle of edges for visualization -#[derive(Debug, Clone)] -pub struct BundleData { - /// All edge IDs in this bundle - pub edge_ids: Vec, - - /// Edge kinds in bundle (for tooltip and rendering) - pub edge_kinds: Vec, - - /// Source and target symbols with their relationship types (for tooltip) - /// Format: (source_symbol_name, target_symbol_name, edge_kind) - pub relationships: Vec<(String, String, EdgeKind)>, - - /// Visual thickness (logarithmic scale based on edge count) - pub thickness: f32, - - /// Whether the bundle is expanded to show individual edges - pub is_expanded: bool, -} - -impl BundleData { - /// Create a new bundle data - pub fn new( - edge_ids: Vec, - edge_kinds: Vec, - relationships: Vec<(String, String, EdgeKind)>, - ) -> Self { - let thickness = Self::calculate_thickness(edge_ids.len()); - Self { - edge_ids, - edge_kinds, - relationships, - thickness, - is_expanded: false, - } - } - - /// Calculate thickness based on edge count using logarithmic scaling - /// Formula: min(log2(count) + base_width, max_width) - /// where base_width = 1.0 and max_width = 6.0 - pub fn calculate_thickness(edge_count: usize) -> f32 { - const BASE_WIDTH: f32 = 1.0; - const MAX_WIDTH: f32 = 6.0; - - if edge_count <= 1 { - BASE_WIDTH - } else { - let log_thickness = (edge_count as f32).log2() + BASE_WIDTH; - log_thickness.min(MAX_WIDTH) - } - } - - /// Get the number of edges in this bundle - pub fn edge_count(&self) -> usize { - self.edge_ids.len() - } - - /// Toggle the expanded state - pub fn toggle_expanded(&mut self) { - self.is_expanded = !self.is_expanded; - } - - /// Set the expanded state - pub fn set_expanded(&mut self, expanded: bool) { - self.is_expanded = expanded; - } - - /// Check if this bundle contains a specific edge - pub fn contains_edge(&self, edge_id: EdgeId) -> bool { - self.edge_ids.contains(&edge_id) - } -} - -/// Collapse state for a single node -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct CollapseState { - /// Whether the entire node is collapsed (showing only header) - pub is_collapsed: bool, - - /// Which visibility sections within the node are collapsed - pub collapsed_sections: HashSet, -} - -impl CollapseState { - /// Create a new collapse state with all sections expanded - pub fn new() -> Self { - Self { - is_collapsed: false, - collapsed_sections: HashSet::new(), - } - } - - /// Create a new collapse state with the node collapsed - pub fn collapsed() -> Self { - Self { - is_collapsed: true, - collapsed_sections: HashSet::new(), - } - } - - /// Create a new collapse state with specific sections collapsed - pub fn with_collapsed_sections(collapsed_sections: HashSet) -> Self { - Self { - is_collapsed: false, - collapsed_sections, - } - } - - /// Toggle the node's collapsed state - pub fn toggle_collapsed(&mut self) { - self.is_collapsed = !self.is_collapsed; - } - - /// Toggle a specific section's collapsed state - pub fn toggle_section(&mut self, section: VisibilityKind) { - if self.collapsed_sections.contains(§ion) { - self.collapsed_sections.remove(§ion); - } else { - self.collapsed_sections.insert(section); - } - } - - /// Check if a specific section is collapsed - pub fn is_section_collapsed(&self, section: VisibilityKind) -> bool { - self.collapsed_sections.contains(§ion) - } - - /// Expand all sections - pub fn expand_all_sections(&mut self) { - self.collapsed_sections.clear(); - } - - /// Collapse all sections - pub fn collapse_all_sections(&mut self) { - self.collapsed_sections.insert(VisibilityKind::Public); - self.collapsed_sections.insert(VisibilityKind::Private); - self.collapsed_sections.insert(VisibilityKind::Protected); - self.collapsed_sections.insert(VisibilityKind::Internal); - self.collapsed_sections.insert(VisibilityKind::Functions); - self.collapsed_sections.insert(VisibilityKind::Variables); - self.collapsed_sections.insert(VisibilityKind::Other); - } -} - -impl Default for CollapseState { - fn default() -> Self { - Self::new() - } -} - -/// Persisted state for the graph view -/// -/// This struct stores all user-configurable view state that should be persisted -/// across sessions, including collapse states, zoom, pan, and layout settings. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct GraphViewState { - /// Collapse state for each node (by NodeId) - pub collapse_states: HashMap, - - /// Section collapse states within nodes (NodeId -> VisibilityKind -> is_collapsed) - /// Note: This is redundant with collapse_states.collapsed_sections but kept for - /// backwards compatibility and easier querying - pub section_states: HashMap>, - - /// Set of hidden nodes that should not be rendered - pub hidden_nodes: HashSet, - - /// Custom node positions set by user dragging (overrides layout algorithm) - pub custom_positions: HashMap, - - /// Current layout algorithm selection - pub layout_algorithm: LayoutAlgorithm, - - /// Current layout direction (Horizontal or Vertical) - pub layout_direction: codestory_core::LayoutDirection, - - /// Current zoom level (0.1 to 4.0, representing 10% to 400%) - pub zoom: f32, - - /// Current pan offset in screen coordinates - pub pan: Vec2, -} - -impl GraphViewState { - /// Create a new graph view state with default values - pub fn new() -> Self { - Self { - collapse_states: HashMap::new(), - section_states: HashMap::new(), - hidden_nodes: HashSet::new(), - custom_positions: HashMap::new(), - layout_algorithm: LayoutAlgorithm::default(), - layout_direction: codestory_core::LayoutDirection::default(), - zoom: 1.0, - pan: Vec2::new(0.0, 0.0), - } - } - - /// Get the collapse state for a node, or create a default one if not present - pub fn get_collapse_state(&self, node_id: NodeId) -> CollapseState { - self.collapse_states - .get(&node_id) - .cloned() - .unwrap_or_default() - } - - /// Set the collapse state for a node - pub fn set_collapse_state(&mut self, node_id: NodeId, state: CollapseState) { - self.collapse_states.insert(node_id, state); - } - - /// Toggle a node's collapsed state - pub fn toggle_node_collapsed(&mut self, node_id: NodeId) { - let mut state = self.get_collapse_state(node_id); - state.toggle_collapsed(); - self.set_collapse_state(node_id, state); - } - - /// Toggle a section's collapsed state within a node - pub fn toggle_section_collapsed(&mut self, node_id: NodeId, section: VisibilityKind) { - let mut state = self.get_collapse_state(node_id); - state.toggle_section(section); - self.set_collapse_state(node_id, state.clone()); - - // Update section_states for backwards compatibility - self.section_states - .entry(node_id) - .or_default() - .insert(section, state.is_section_collapsed(section)); - } - - /// Check if a node is collapsed - pub fn is_node_collapsed(&self, node_id: NodeId) -> bool { - self.collapse_states - .get(&node_id) - .map(|s| s.is_collapsed) - .unwrap_or(false) - } - - /// Check if a section within a node is collapsed - pub fn is_section_collapsed(&self, node_id: NodeId, section: VisibilityKind) -> bool { - self.collapse_states - .get(&node_id) - .map(|s| s.is_section_collapsed(section)) - .unwrap_or(false) - } - - /// Hide a node - pub fn hide_node(&mut self, node_id: NodeId) { - self.hidden_nodes.insert(node_id); - } - - /// Show a node - pub fn show_node(&mut self, node_id: NodeId) { - self.hidden_nodes.remove(&node_id); - } - - /// Check if a node is hidden - pub fn is_node_hidden(&self, node_id: NodeId) -> bool { - self.hidden_nodes.contains(&node_id) - } - - /// Set a custom position for a node - pub fn set_custom_position(&mut self, node_id: NodeId, position: Vec2) { - self.custom_positions.insert(node_id, position); - } - - /// Get the custom position for a node, if any - pub fn get_custom_position(&self, node_id: NodeId) -> Option { - self.custom_positions.get(&node_id).copied() - } - - /// Clear custom position for a node - pub fn clear_custom_position(&mut self, node_id: NodeId) { - self.custom_positions.remove(&node_id); - } - - /// Clear all custom positions - pub fn clear_all_custom_positions(&mut self) { - self.custom_positions.clear(); - } - - /// Set the zoom level (clamped to 0.1 - 4.0 range) - pub fn set_zoom(&mut self, zoom: f32) { - self.zoom = zoom.clamp(0.1, 4.0); - } - - /// Set the pan offset - pub fn set_pan(&mut self, pan: Vec2) { - self.pan = pan; - } - - /// Compute the pan offset that would place `node_pos` at the viewport center. - /// - /// `node_pos` is in graph coordinates (the same coordinate space as node positions). - /// Pan is in screen coordinates, so we apply zoom when converting. - pub fn expected_pan_for_center_on(&self, node_pos: Vec2) -> Vec2 { - Vec2::new(-node_pos.x * self.zoom, -node_pos.y * self.zoom) - } - - /// Return true if the current pan would place `node_pos` within `tolerance_px` of the - /// viewport center. - pub fn is_centered_on(&self, node_pos: Vec2, tolerance_px: f32) -> bool { - let expected = self.expected_pan_for_center_on(node_pos); - (self.pan.x - expected.x).abs() <= tolerance_px - && (self.pan.y - expected.y).abs() <= tolerance_px - } - - /// Recenter the view so that `node_pos` is at the viewport center. - /// - /// Returns true if the pan changed by more than `tolerance_px` in either axis. - pub fn recenter_on(&mut self, node_pos: Vec2, tolerance_px: f32) -> bool { - if self.is_centered_on(node_pos, tolerance_px) { - return false; - } - self.pan = self.expected_pan_for_center_on(node_pos); - true - } - - /// Set the layout algorithm - pub fn set_layout_algorithm(&mut self, algorithm: LayoutAlgorithm) { - self.layout_algorithm = algorithm; - } - - /// Set the layout direction - pub fn set_layout_direction(&mut self, direction: codestory_core::LayoutDirection) { - self.layout_direction = direction; - } - - /// Expand all nodes - pub fn expand_all_nodes(&mut self) { - for state in self.collapse_states.values_mut() { - state.is_collapsed = false; - } - } - - /// Collapse all nodes - pub fn collapse_all_nodes(&mut self) { - for state in self.collapse_states.values_mut() { - state.is_collapsed = true; - } - } - - /// Expand all sections in all nodes - pub fn expand_all_sections(&mut self) { - for state in self.collapse_states.values_mut() { - state.expand_all_sections(); - } - self.section_states.clear(); - } - - /// Collapse all sections in all nodes - pub fn collapse_all_sections(&mut self) { - for state in self.collapse_states.values_mut() { - state.collapse_all_sections(); - } - // Update section_states for backwards compatibility - for (node_id, state) in &self.collapse_states { - let mut sections = HashMap::new(); - for section in &state.collapsed_sections { - sections.insert(*section, true); - } - self.section_states.insert(*node_id, sections); - } - } -} - -impl Default for GraphViewState { - fn default() -> Self { - Self::new() - } -} - -/// The culling margin in pixels added around the viewport when determining -/// which nodes are visible. Nodes within this margin of the viewport edge -/// are still considered visible to avoid popping artifacts during panning. -pub const VIEWPORT_CULL_MARGIN: f32 = 100.0; - -/// The minimum number of nodes in a graph before viewport culling is applied. -/// For smaller graphs the overhead of culling isn't worthwhile. -pub const VIEWPORT_CULL_THRESHOLD: usize = 50; - -/// Determine which node IDs are visible within the given viewport. -/// -/// For graphs with fewer than [`VIEWPORT_CULL_THRESHOLD`] nodes, all nodes are -/// returned as visible (no culling applied). For larger graphs, only nodes whose -/// bounding rectangles intersect the viewport (expanded by [`VIEWPORT_CULL_MARGIN`]) -/// are included. -/// -/// # Arguments -/// * `node_rects` - Map of node IDs to their screen-space bounding rectangles. -/// * `viewport` - The visible viewport rectangle in screen coordinates. -/// -/// # Returns -/// A `HashSet` of `NodeId`s that are considered visible. -/// -/// **Validates: Requirements 10.1, 10.4, Property 25** -pub fn viewport_cull(node_rects: &HashMap, viewport: Rect) -> HashSet { - // Below threshold: all nodes visible - if node_rects.len() < VIEWPORT_CULL_THRESHOLD { - return node_rects.keys().copied().collect(); - } - - let expanded = viewport.expand(VIEWPORT_CULL_MARGIN); - node_rects - .iter() - .filter(|(_, rect)| expanded.intersects(rect)) - .map(|(id, _)| *id) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_uml_node_creation() { - let node = UmlNode::new(NodeId(1), NodeKind::CLASS, "TestClass".to_string()); - assert_eq!(node.id, NodeId(1)); - assert_eq!(node.kind, NodeKind::CLASS); - assert_eq!(node.label, "TestClass"); - assert_eq!(node.parent_id, None); - assert!(node.is_indexed); - assert!(!node.is_collapsed); - assert!(node.visibility_sections.is_empty()); - } - - #[test] - fn test_uml_node_with_parent() { - let node = UmlNode::with_parent( - NodeId(2), - NodeKind::METHOD, - "testMethod".to_string(), - NodeId(1), - ); - assert_eq!(node.id, NodeId(2)); - assert_eq!(node.parent_id, Some(NodeId(1))); - } - - #[test] - fn test_visibility_section_creation() { - let section = VisibilitySection::new(VisibilityKind::Public); - assert_eq!(section.kind, VisibilityKind::Public); - assert!(section.members.is_empty()); - assert!(!section.is_collapsed); - } - - #[test] - fn test_visibility_section_with_members() { - let members = vec![ - MemberItem::new(NodeId(1), NodeKind::METHOD, "method1".to_string()), - MemberItem::new(NodeId(2), NodeKind::FIELD, "field1".to_string()), - ]; - let section = VisibilitySection::with_members(VisibilityKind::Public, members); - assert_eq!(section.member_count(), 2); - } - - #[test] - fn test_visibility_kind_labels() { - assert_eq!(VisibilityKind::Public.label(), "PUBLIC"); - assert_eq!(VisibilityKind::Private.label(), "PRIVATE"); - assert_eq!(VisibilityKind::Protected.label(), "PROTECTED"); - assert_eq!(VisibilityKind::Internal.label(), "INTERNAL"); - assert_eq!(VisibilityKind::Functions.label(), "FUNCTIONS"); - assert_eq!(VisibilityKind::Variables.label(), "VARIABLES"); - assert_eq!(VisibilityKind::Other.label(), "OTHER"); - } - - #[test] - fn test_member_item_creation() { - let member = MemberItem::new(NodeId(1), NodeKind::METHOD, "testMethod".to_string()); - assert_eq!(member.id, NodeId(1)); - assert_eq!(member.kind, NodeKind::METHOD); - assert_eq!(member.name, "testMethod"); - assert!(!member.has_outgoing_edges); - assert_eq!(member.signature, None); - } - - #[test] - fn test_member_item_with_signature() { - let member = MemberItem::with_signature( - NodeId(1), - NodeKind::METHOD, - "testMethod".to_string(), - "fn testMethod() -> bool".to_string(), - ); - assert_eq!( - member.signature, - Some("fn testMethod() -> bool".to_string()) - ); - } - - #[test] - fn test_member_item_set_has_outgoing_edges() { - let mut member = MemberItem::new(NodeId(1), NodeKind::METHOD, "testMethod".to_string()); - assert!(!member.has_outgoing_edges); - member.set_has_outgoing_edges(true); - assert!(member.has_outgoing_edges); - } - - #[test] - fn test_bundle_info_creation() { - let node_ids = vec![NodeId(1), NodeId(2), NodeId(3)]; - let bundle = BundleInfo::new(node_ids.clone()); - assert_eq!(bundle.count, 3); - assert_eq!(bundle.bundled_node_ids, node_ids); - assert!(!bundle.is_expanded); - } - - #[test] - fn test_bundle_info_toggle_expanded() { - let mut bundle = BundleInfo::new(vec![NodeId(1), NodeId(2)]); - assert!(!bundle.is_expanded); - bundle.toggle_expanded(); - assert!(bundle.is_expanded); - bundle.toggle_expanded(); - assert!(!bundle.is_expanded); - } - - #[test] - fn test_edge_route_creation() { - let source = EdgeAnchor::new(NodeId(1), Vec2::new(0.0, 0.0), AnchorSide::Right); - let target = EdgeAnchor::new(NodeId(2), Vec2::new(100.0, 0.0), AnchorSide::Left); - let route = EdgeRoute::new(EdgeId(1), source, target, EdgeKind::CALL); - - assert_eq!(route.id, EdgeId(1)); - assert_eq!(route.source.node_id, NodeId(1)); - assert_eq!(route.target.node_id, NodeId(2)); - assert_eq!(route.kind, EdgeKind::CALL); - assert!(route.control_points.is_empty()); - assert!(!route.is_bundled); - assert_eq!(route.bundle_count, 1); - } - - #[test] - fn test_edge_route_with_control_points() { - let source = EdgeAnchor::new(NodeId(1), Vec2::new(0.0, 0.0), AnchorSide::Right); - let target = EdgeAnchor::new(NodeId(2), Vec2::new(100.0, 0.0), AnchorSide::Left); - let control_points = vec![Vec2::new(25.0, 0.0), Vec2::new(75.0, 0.0)]; - let route = EdgeRoute::with_control_points( - EdgeId(1), - source, - target, - EdgeKind::CALL, - control_points.clone(), - ); - - assert_eq!(route.control_points.len(), 2); - assert_eq!(route.control_points, control_points); - } - - #[test] - fn test_edge_route_bundled() { - let source = EdgeAnchor::new(NodeId(1), Vec2::new(0.0, 0.0), AnchorSide::Right); - let target = EdgeAnchor::new(NodeId(2), Vec2::new(100.0, 0.0), AnchorSide::Left); - let control_points = vec![Vec2::new(25.0, 0.0), Vec2::new(75.0, 0.0)]; - let route = - EdgeRoute::bundled(EdgeId(1), source, target, EdgeKind::CALL, control_points, 5); - - assert!(route.is_bundled); - assert_eq!(route.bundle_count, 5); - } - - #[test] - fn test_edge_anchor_creation() { - let anchor = EdgeAnchor::new(NodeId(1), Vec2::new(10.0, 20.0), AnchorSide::Right); - - assert_eq!(anchor.node_id, NodeId(1)); - assert_eq!(anchor.member_id, None); - assert_eq!(anchor.position, Vec2::new(10.0, 20.0)); - assert_eq!(anchor.side, AnchorSide::Right); - } - - #[test] - fn test_edge_anchor_for_member() { - let anchor = EdgeAnchor::for_member( - NodeId(1), - NodeId(2), - Vec2::new(10.0, 20.0), - AnchorSide::Right, - ); - - assert_eq!(anchor.node_id, NodeId(1)); - assert_eq!(anchor.member_id, Some(NodeId(2))); - assert_eq!(anchor.position, Vec2::new(10.0, 20.0)); - assert_eq!(anchor.side, AnchorSide::Right); - } - - #[test] - fn test_anchor_side_opposite() { - assert_eq!(AnchorSide::Left.opposite(), AnchorSide::Right); - assert_eq!(AnchorSide::Right.opposite(), AnchorSide::Left); - assert_eq!(AnchorSide::Top.opposite(), AnchorSide::Bottom); - assert_eq!(AnchorSide::Bottom.opposite(), AnchorSide::Top); - } - - #[test] - fn test_anchor_side_direction_vector() { - assert_eq!(AnchorSide::Left.direction_vector(), Vec2::new(-1.0, 0.0)); - assert_eq!(AnchorSide::Right.direction_vector(), Vec2::new(1.0, 0.0)); - assert_eq!(AnchorSide::Top.direction_vector(), Vec2::new(0.0, -1.0)); - assert_eq!(AnchorSide::Bottom.direction_vector(), Vec2::new(0.0, 1.0)); - } - - #[test] - fn test_edge_bundle_creation() { - let source = EdgeAnchor::new(NodeId(1), Vec2::new(0.0, 0.0), AnchorSide::Right); - let target = EdgeAnchor::new(NodeId(2), Vec2::new(100.0, 0.0), AnchorSide::Left); - let route = EdgeRoute::new(EdgeId(1), source, target, EdgeKind::CALL); - - let edges = vec![EdgeId(1), EdgeId(2), EdgeId(3)]; - let bundle = EdgeBundle::new(edges.clone(), NodeId(1), NodeId(2), route); - - assert_eq!(bundle.edges, edges); - assert_eq!(bundle.source_node, NodeId(1)); - assert_eq!(bundle.target_node, NodeId(2)); - assert_eq!(bundle.edge_count(), 3); - } - - #[test] - fn test_container_node_size_calculation_empty() { - // Test size calculation for a node with no members - let node = UmlNode::new(NodeId(1), NodeKind::CLASS, "EmptyClass".to_string()); - - let size = node.calculate_size(30.0, 20.0, 20.0, 16.0, 150.0); - - // Expected: header(30) + padding(8) = 38 - assert_eq!(size.x, 150.0); - assert!(size.y >= 38.0, "Height should be at least header + padding"); - } - - #[test] - fn test_container_node_size_calculation_with_members() { - // Test size calculation for a node with members - let mut node = UmlNode::new(NodeId(1), NodeKind::CLASS, "TestClass".to_string()); - - // Add a section with 3 members - let members = vec![ - MemberItem::new(NodeId(2), NodeKind::METHOD, "method1".to_string()), - MemberItem::new(NodeId(3), NodeKind::METHOD, "method2".to_string()), - MemberItem::new(NodeId(4), NodeKind::FIELD, "field1".to_string()), - ]; - node.visibility_sections - .push(VisibilitySection::with_members( - VisibilityKind::Public, - members, - )); - - let size = node.calculate_size(30.0, 20.0, 20.0, 16.0, 150.0); - - // Expected: header(30) + padding_top(8) + section_header(20) + 3*member_row(60) + section_padding(8) + padding_bottom(8) = 134 - assert_eq!(size.x, 150.0); - assert!( - size.y >= 126.0, - "Height should include header + section header + members + padding" - ); - } - - #[test] - fn test_container_node_size_calculation_multiple_sections() { - // Test size calculation for a node with multiple sections - let mut node = UmlNode::new(NodeId(1), NodeKind::CLASS, "TestClass".to_string()); - - // Add Functions section with 2 members - let functions = vec![ - MemberItem::new(NodeId(2), NodeKind::METHOD, "method1".to_string()), - MemberItem::new(NodeId(3), NodeKind::METHOD, "method2".to_string()), - ]; - node.visibility_sections - .push(VisibilitySection::with_members( - VisibilityKind::Functions, - functions, - )); - - // Add Variables section with 2 members - let variables = vec![ - MemberItem::new(NodeId(4), NodeKind::FIELD, "field1".to_string()), - MemberItem::new(NodeId(5), NodeKind::FIELD, "field2".to_string()), - ]; - node.visibility_sections - .push(VisibilitySection::with_members( - VisibilityKind::Variables, - variables, - )); - - let size = node.calculate_size(30.0, 20.0, 20.0, 16.0, 150.0); - - // Expected: header(30) + padding_top(8) + - // section1_header(20) + 2*member_row(40) + section1_padding(8) + - // section2_header(20) + 2*member_row(40) + section2_padding(8) + - // padding_bottom(8) = 182 - assert_eq!(size.x, 150.0); - assert!( - size.y >= 174.0, - "Height should include header + 2 sections with members + padding" - ); - } - - #[test] - fn test_container_node_size_calculation_collapsed() { - // Test size calculation for a collapsed node - let mut node = UmlNode::new(NodeId(1), NodeKind::CLASS, "TestClass".to_string()); - node.is_collapsed = true; - - // Add members (should be ignored when collapsed) - let members = vec![ - MemberItem::new(NodeId(2), NodeKind::METHOD, "method1".to_string()), - MemberItem::new(NodeId(3), NodeKind::METHOD, "method2".to_string()), - ]; - node.visibility_sections - .push(VisibilitySection::with_members( - VisibilityKind::Public, - members, - )); - - let size = node.calculate_size(30.0, 20.0, 20.0, 16.0, 150.0); - - // Expected: header(30) + minimal_padding(8) = 38 - assert_eq!(size.x, 150.0); - assert!( - size.y >= 30.0 && size.y < 50.0, - "Collapsed node should only show header" - ); - } - - #[test] - fn test_container_node_size_calculation_collapsed_section() { - // Test size calculation with a collapsed section - let mut node = UmlNode::new(NodeId(1), NodeKind::CLASS, "TestClass".to_string()); - - // Add a collapsed section with members - let members = vec![ - MemberItem::new(NodeId(2), NodeKind::METHOD, "method1".to_string()), - MemberItem::new(NodeId(3), NodeKind::METHOD, "method2".to_string()), - ]; - let mut section = VisibilitySection::with_members(VisibilityKind::Public, members); - section.is_collapsed = true; - node.visibility_sections.push(section); - - let size = node.calculate_size(30.0, 20.0, 20.0, 16.0, 150.0); - - // Expected: header(30) + padding_top(8) + section_header(20) + section_padding(4) + padding_bottom(8) = 70 - assert_eq!(size.x, 150.0); - assert!( - size.y >= 62.0 && size.y < 80.0, - "Collapsed section should only show header" - ); - } - - #[test] - fn test_container_node_size_calculation_default() { - // Test the default size calculation method - let mut node = UmlNode::new(NodeId(1), NodeKind::CLASS, "TestClass".to_string()); - - let members = vec![MemberItem::new( - NodeId(2), - NodeKind::METHOD, - "method1".to_string(), - )]; - node.visibility_sections - .push(VisibilitySection::with_members( - VisibilityKind::Public, - members, - )); - - let size = node.calculate_size_default(); - - // Should use default constants - assert_eq!(size.x, 180.0); // DEFAULT_MIN_WIDTH - assert!(size.y > 0.0, "Size should be calculated"); - } - - #[test] - fn test_container_node_size_property_validation() { - // Property 2: For any Container_Node with N members across M visibility sections, - // the computed height SHALL be >= header_height + sum(section_header_heights) + - // sum(member_row_heights) + padding. - - let mut node = UmlNode::new(NodeId(1), NodeKind::CLASS, "TestClass".to_string()); - - let header_height = 30.0; - let section_header_height = 20.0; - let member_row_height = 20.0; - let padding = 16.0; - let min_width = 150.0; - - // Add 2 sections with different member counts - let section1_members = vec![ - MemberItem::new(NodeId(2), NodeKind::METHOD, "method1".to_string()), - MemberItem::new(NodeId(3), NodeKind::METHOD, "method2".to_string()), - ]; - node.visibility_sections - .push(VisibilitySection::with_members( - VisibilityKind::Functions, - section1_members, - )); - - let section2_members = vec![MemberItem::new( - NodeId(4), - NodeKind::FIELD, - "field1".to_string(), - )]; - node.visibility_sections - .push(VisibilitySection::with_members( - VisibilityKind::Variables, - section2_members, - )); - - let size = node.calculate_size( - header_height, - section_header_height, - member_row_height, - padding, - min_width, - ); - - // Calculate minimum expected height - let num_sections = 2; - let total_members = 3; - let min_expected_height = header_height - + (num_sections as f32 * section_header_height) - + (total_members as f32 * member_row_height) - + padding; - - assert!( - size.y >= min_expected_height, - "Calculated height {} should be >= minimum expected height {}", - size.y, - min_expected_height - ); - } - - #[test] - fn test_edge_bundle_contains_edge() { - let source = EdgeAnchor::new(NodeId(1), Vec2::new(0.0, 0.0), AnchorSide::Right); - let target = EdgeAnchor::new(NodeId(2), Vec2::new(100.0, 0.0), AnchorSide::Left); - let route = EdgeRoute::new(EdgeId(1), source, target, EdgeKind::CALL); - - let edges = vec![EdgeId(1), EdgeId(2), EdgeId(3)]; - let bundle = EdgeBundle::new(edges, NodeId(1), NodeId(2), route); - - assert!(bundle.contains_edge(EdgeId(1))); - assert!(bundle.contains_edge(EdgeId(2))); - assert!(bundle.contains_edge(EdgeId(3))); - assert!(!bundle.contains_edge(EdgeId(4))); - } - - #[test] - fn test_bundle_data_creation() { - let edge_ids = vec![EdgeId(1), EdgeId(2), EdgeId(3)]; - let edge_kinds = vec![EdgeKind::CALL, EdgeKind::CALL, EdgeKind::INHERITANCE]; - let relationships = vec![ - ("ClassA".to_string(), "ClassB".to_string(), EdgeKind::CALL), - ("ClassA".to_string(), "ClassB".to_string(), EdgeKind::CALL), - ( - "ClassA".to_string(), - "ClassB".to_string(), - EdgeKind::INHERITANCE, - ), - ]; - - let bundle = BundleData::new(edge_ids.clone(), edge_kinds.clone(), relationships.clone()); - - assert_eq!(bundle.edge_ids, edge_ids); - assert_eq!(bundle.edge_kinds, edge_kinds); - assert_eq!(bundle.relationships, relationships); - assert_eq!(bundle.edge_count(), 3); - assert!(!bundle.is_expanded); - } - - #[test] - fn test_bundle_data_thickness_calculation() { - // Single edge: base width - assert_eq!(BundleData::calculate_thickness(1), 1.0); - - // 2 edges: log2(2) + 1 = 1 + 1 = 2.0 - assert_eq!(BundleData::calculate_thickness(2), 2.0); - - // 4 edges: log2(4) + 1 = 2 + 1 = 3.0 - assert_eq!(BundleData::calculate_thickness(4), 3.0); - - // 8 edges: log2(8) + 1 = 3 + 1 = 4.0 - assert_eq!(BundleData::calculate_thickness(8), 4.0); - - // 16 edges: log2(16) + 1 = 4 + 1 = 5.0 - assert_eq!(BundleData::calculate_thickness(16), 5.0); - - // 32 edges: log2(32) + 1 = 5 + 1 = 6.0 - assert_eq!(BundleData::calculate_thickness(32), 6.0); - - // 64 edges: log2(64) + 1 = 6 + 1 = 7.0, but clamped to 6.0 - assert_eq!(BundleData::calculate_thickness(64), 6.0); - - // 100 edges: should be clamped to max 6.0 - assert_eq!(BundleData::calculate_thickness(100), 6.0); - } - - #[test] - fn test_bundle_data_toggle_expanded() { - let edge_ids = vec![EdgeId(1), EdgeId(2)]; - let edge_kinds = vec![EdgeKind::CALL, EdgeKind::CALL]; - let relationships = vec![ - ("A".to_string(), "B".to_string(), EdgeKind::CALL), - ("A".to_string(), "B".to_string(), EdgeKind::CALL), - ]; - - let mut bundle = BundleData::new(edge_ids, edge_kinds, relationships); - - assert!(!bundle.is_expanded); - bundle.toggle_expanded(); - assert!(bundle.is_expanded); - bundle.toggle_expanded(); - assert!(!bundle.is_expanded); - } - - #[test] - fn test_bundle_data_set_expanded() { - let edge_ids = vec![EdgeId(1), EdgeId(2)]; - let edge_kinds = vec![EdgeKind::CALL, EdgeKind::CALL]; - let relationships = vec![ - ("A".to_string(), "B".to_string(), EdgeKind::CALL), - ("A".to_string(), "B".to_string(), EdgeKind::CALL), - ]; - - let mut bundle = BundleData::new(edge_ids, edge_kinds, relationships); - - bundle.set_expanded(true); - assert!(bundle.is_expanded); - bundle.set_expanded(false); - assert!(!bundle.is_expanded); - } - - #[test] - fn test_bundle_data_contains_edge() { - let edge_ids = vec![EdgeId(1), EdgeId(2), EdgeId(3)]; - let edge_kinds = vec![EdgeKind::CALL, EdgeKind::CALL, EdgeKind::CALL]; - let relationships = vec![ - ("A".to_string(), "B".to_string(), EdgeKind::CALL), - ("A".to_string(), "B".to_string(), EdgeKind::CALL), - ("A".to_string(), "B".to_string(), EdgeKind::CALL), - ]; - - let bundle = BundleData::new(edge_ids, edge_kinds, relationships); - - assert!(bundle.contains_edge(EdgeId(1))); - assert!(bundle.contains_edge(EdgeId(2))); - assert!(bundle.contains_edge(EdgeId(3))); - assert!(!bundle.contains_edge(EdgeId(4))); - } - - #[test] - fn test_collapse_state_creation() { - let state = CollapseState::new(); - assert!(!state.is_collapsed); - assert!(state.collapsed_sections.is_empty()); - } - - #[test] - fn test_collapse_state_collapsed() { - let state = CollapseState::collapsed(); - assert!(state.is_collapsed); - assert!(state.collapsed_sections.is_empty()); - } - - #[test] - fn test_collapse_state_with_collapsed_sections() { - let mut sections = HashSet::new(); - sections.insert(VisibilityKind::Private); - sections.insert(VisibilityKind::Protected); - - let state = CollapseState::with_collapsed_sections(sections.clone()); - assert!(!state.is_collapsed); - assert_eq!(state.collapsed_sections, sections); - } - - #[test] - fn test_collapse_state_toggle_collapsed() { - let mut state = CollapseState::new(); - assert!(!state.is_collapsed); - - state.toggle_collapsed(); - assert!(state.is_collapsed); - - state.toggle_collapsed(); - assert!(!state.is_collapsed); - } - - #[test] - fn test_collapse_state_toggle_section() { - let mut state = CollapseState::new(); - - assert!(!state.is_section_collapsed(VisibilityKind::Public)); - - state.toggle_section(VisibilityKind::Public); - assert!(state.is_section_collapsed(VisibilityKind::Public)); - - state.toggle_section(VisibilityKind::Public); - assert!(!state.is_section_collapsed(VisibilityKind::Public)); - } - - #[test] - fn test_collapse_state_expand_all_sections() { - let mut state = CollapseState::new(); - state.toggle_section(VisibilityKind::Public); - state.toggle_section(VisibilityKind::Private); - - assert!(state.is_section_collapsed(VisibilityKind::Public)); - assert!(state.is_section_collapsed(VisibilityKind::Private)); - - state.expand_all_sections(); - assert!(!state.is_section_collapsed(VisibilityKind::Public)); - assert!(!state.is_section_collapsed(VisibilityKind::Private)); - } - - #[test] - fn test_collapse_state_collapse_all_sections() { - let mut state = CollapseState::new(); - - state.collapse_all_sections(); - assert!(state.is_section_collapsed(VisibilityKind::Public)); - assert!(state.is_section_collapsed(VisibilityKind::Private)); - assert!(state.is_section_collapsed(VisibilityKind::Protected)); - assert!(state.is_section_collapsed(VisibilityKind::Internal)); - assert!(state.is_section_collapsed(VisibilityKind::Functions)); - assert!(state.is_section_collapsed(VisibilityKind::Variables)); - assert!(state.is_section_collapsed(VisibilityKind::Other)); - } - - #[test] - fn test_graph_view_state_creation() { - let state = GraphViewState::new(); - assert!(state.collapse_states.is_empty()); - assert!(state.section_states.is_empty()); - assert!(state.hidden_nodes.is_empty()); - assert!(state.custom_positions.is_empty()); - assert_eq!(state.zoom, 1.0); - assert_eq!(state.pan, Vec2::new(0.0, 0.0)); - } - - #[test] - fn test_graph_view_state_get_collapse_state() { - let state = GraphViewState::new(); - - // Should return default state for non-existent node - let collapse_state = state.get_collapse_state(NodeId(1)); - assert!(!collapse_state.is_collapsed); - assert!(collapse_state.collapsed_sections.is_empty()); - } - - #[test] - fn test_graph_view_state_set_collapse_state() { - let mut state = GraphViewState::new(); - let collapse_state = CollapseState::collapsed(); - - state.set_collapse_state(NodeId(1), collapse_state.clone()); - - let retrieved = state.get_collapse_state(NodeId(1)); - assert_eq!(retrieved, collapse_state); - } - - #[test] - fn test_graph_view_state_toggle_node_collapsed() { - let mut state = GraphViewState::new(); - - assert!(!state.is_node_collapsed(NodeId(1))); - - state.toggle_node_collapsed(NodeId(1)); - assert!(state.is_node_collapsed(NodeId(1))); - - state.toggle_node_collapsed(NodeId(1)); - assert!(!state.is_node_collapsed(NodeId(1))); - } - - #[test] - fn test_graph_view_state_toggle_section_collapsed() { - let mut state = GraphViewState::new(); - - assert!(!state.is_section_collapsed(NodeId(1), VisibilityKind::Public)); - - state.toggle_section_collapsed(NodeId(1), VisibilityKind::Public); - assert!(state.is_section_collapsed(NodeId(1), VisibilityKind::Public)); - - state.toggle_section_collapsed(NodeId(1), VisibilityKind::Public); - assert!(!state.is_section_collapsed(NodeId(1), VisibilityKind::Public)); - } - - #[test] - fn test_graph_view_state_hide_show_node() { - let mut state = GraphViewState::new(); - - assert!(!state.is_node_hidden(NodeId(1))); - - state.hide_node(NodeId(1)); - assert!(state.is_node_hidden(NodeId(1))); - - state.show_node(NodeId(1)); - assert!(!state.is_node_hidden(NodeId(1))); - } - - #[test] - fn test_graph_view_state_custom_positions() { - let mut state = GraphViewState::new(); - let position = Vec2::new(100.0, 200.0); - - assert_eq!(state.get_custom_position(NodeId(1)), None); - - state.set_custom_position(NodeId(1), position); - assert_eq!(state.get_custom_position(NodeId(1)), Some(position)); - - state.clear_custom_position(NodeId(1)); - assert_eq!(state.get_custom_position(NodeId(1)), None); - } - - #[test] - fn test_graph_view_state_clear_all_custom_positions() { - let mut state = GraphViewState::new(); - - state.set_custom_position(NodeId(1), Vec2::new(100.0, 200.0)); - state.set_custom_position(NodeId(2), Vec2::new(300.0, 400.0)); - - assert_eq!(state.custom_positions.len(), 2); - - state.clear_all_custom_positions(); - assert!(state.custom_positions.is_empty()); - } - - #[test] - fn test_graph_view_state_set_zoom() { - let mut state = GraphViewState::new(); - - // Normal zoom - state.set_zoom(2.0); - assert_eq!(state.zoom, 2.0); - - // Zoom clamped to minimum - state.set_zoom(0.05); - assert_eq!(state.zoom, 0.1); - - // Zoom clamped to maximum - state.set_zoom(5.0); - assert_eq!(state.zoom, 4.0); - } - - #[test] - fn test_graph_view_state_set_pan() { - let mut state = GraphViewState::new(); - let pan = Vec2::new(50.0, 100.0); - - state.set_pan(pan); - assert_eq!(state.pan, pan); - } - - #[test] - fn test_graph_view_state_recenter_on() { - let mut state = GraphViewState::new(); - state.set_zoom(2.0); - - let node_pos = Vec2::new(10.0, -5.0); - let expected_pan = Vec2::new(-20.0, 10.0); - assert_eq!(state.expected_pan_for_center_on(node_pos), expected_pan); - - // Not centered initially. - assert!(!state.is_centered_on(node_pos, 0.5)); - - // Recenters by setting pan to the expected value. - assert!(state.recenter_on(node_pos, 0.5)); - assert_eq!(state.pan, expected_pan); - assert!(state.is_centered_on(node_pos, 0.5)); - - // Idempotent within tolerance. - assert!(!state.recenter_on(node_pos, 0.5)); - } - - #[test] - fn test_graph_view_state_set_layout_algorithm() { - use codestory_events::LayoutAlgorithm; - - let mut state = GraphViewState::new(); - - state.set_layout_algorithm(LayoutAlgorithm::Radial); - assert_eq!(state.layout_algorithm, LayoutAlgorithm::Radial); - - state.set_layout_algorithm(LayoutAlgorithm::Hierarchical); - assert_eq!(state.layout_algorithm, LayoutAlgorithm::Hierarchical); - } - - #[test] - fn test_graph_view_state_set_layout_direction() { - use codestory_core::LayoutDirection; - - let mut state = GraphViewState::new(); - - state.set_layout_direction(LayoutDirection::Vertical); - assert_eq!(state.layout_direction, LayoutDirection::Vertical); - - state.set_layout_direction(LayoutDirection::Horizontal); - assert_eq!(state.layout_direction, LayoutDirection::Horizontal); - } - - #[test] - fn test_graph_view_state_expand_collapse_all_nodes() { - let mut state = GraphViewState::new(); - - // Set up some nodes with collapsed state - state.set_collapse_state(NodeId(1), CollapseState::collapsed()); - state.set_collapse_state(NodeId(2), CollapseState::collapsed()); - - assert!(state.is_node_collapsed(NodeId(1))); - assert!(state.is_node_collapsed(NodeId(2))); - - // Expand all - state.expand_all_nodes(); - assert!(!state.is_node_collapsed(NodeId(1))); - assert!(!state.is_node_collapsed(NodeId(2))); - - // Collapse all - state.collapse_all_nodes(); - assert!(state.is_node_collapsed(NodeId(1))); - assert!(state.is_node_collapsed(NodeId(2))); - } - - #[test] - fn test_graph_view_state_expand_collapse_all_sections() { - let mut state = GraphViewState::new(); - - // Set up a node with some collapsed sections - let mut collapse_state = CollapseState::new(); - collapse_state.toggle_section(VisibilityKind::Public); - collapse_state.toggle_section(VisibilityKind::Private); - state.set_collapse_state(NodeId(1), collapse_state); - - assert!(state.is_section_collapsed(NodeId(1), VisibilityKind::Public)); - assert!(state.is_section_collapsed(NodeId(1), VisibilityKind::Private)); - - // Expand all sections - state.expand_all_sections(); - assert!(!state.is_section_collapsed(NodeId(1), VisibilityKind::Public)); - assert!(!state.is_section_collapsed(NodeId(1), VisibilityKind::Private)); - - // Collapse all sections - state.collapse_all_sections(); - assert!(state.is_section_collapsed(NodeId(1), VisibilityKind::Public)); - assert!(state.is_section_collapsed(NodeId(1), VisibilityKind::Private)); - } - - #[test] - fn test_graph_view_state_serialization() { - let mut state = GraphViewState::new(); - - // Set up some state - state.set_collapse_state(NodeId(1), CollapseState::collapsed()); - state.hide_node(NodeId(2)); - state.set_custom_position(NodeId(3), Vec2::new(100.0, 200.0)); - state.set_zoom(2.5); - state.set_pan(Vec2::new(50.0, 75.0)); - - // Serialize - let json = serde_json::to_string(&state).expect("Failed to serialize"); - - // Deserialize - let deserialized: GraphViewState = - serde_json::from_str(&json).expect("Failed to deserialize"); - - // Verify - assert!(deserialized.is_node_collapsed(NodeId(1))); - assert!(deserialized.is_node_hidden(NodeId(2))); - assert_eq!( - deserialized.get_custom_position(NodeId(3)), - Some(Vec2::new(100.0, 200.0)) - ); - assert_eq!(deserialized.zoom, 2.5); - assert_eq!(deserialized.pan, Vec2::new(50.0, 75.0)); - } - - #[test] - fn test_collapse_state_serialization() { - let mut state = CollapseState::new(); - state.toggle_collapsed(); - state.toggle_section(VisibilityKind::Public); - state.toggle_section(VisibilityKind::Private); - - // Serialize - let json = serde_json::to_string(&state).expect("Failed to serialize"); - - // Deserialize - let deserialized: CollapseState = - serde_json::from_str(&json).expect("Failed to deserialize"); - - // Verify - assert_eq!(deserialized, state); - assert!(deserialized.is_collapsed); - assert!(deserialized.is_section_collapsed(VisibilityKind::Public)); - assert!(deserialized.is_section_collapsed(VisibilityKind::Private)); - } -} - -#[cfg(test)] -mod property_tests { - use super::*; - use proptest::prelude::*; - - // Strategy for generating random member counts (0-50 members per section) - fn member_count_strategy() -> impl Strategy { - 0usize..=50 - } - - // Strategy for generating random section counts (0-5 sections) - fn section_count_strategy() -> impl Strategy { - 0usize..=5 - } - - // Strategy for generating random UI dimensions - fn ui_dimensions_strategy() -> impl Strategy { - ( - 20.0f32..=50.0, // header_height - 15.0f32..=30.0, // section_header_height - 15.0f32..=30.0, // member_row_height - 8.0f32..=32.0, // padding - 100.0f32..=300.0, // min_width - ) - } - - // Strategy for generating a visibility section with a specific number of members - fn visibility_section_with_members_strategy( - member_count: usize, - ) -> impl Strategy { - let kinds = vec![ - VisibilityKind::Public, - VisibilityKind::Private, - VisibilityKind::Protected, - VisibilityKind::Internal, - VisibilityKind::Functions, - VisibilityKind::Variables, - VisibilityKind::Other, - ]; - - prop::sample::select(kinds).prop_map(move |kind| { - // Generate members that match the section kind - let mut members: Vec = (0..member_count) - .map(|i| { - // Choose NodeKind based on the VisibilityKind to ensure correct grouping - let node_kind = match kind { - VisibilityKind::Functions => { - // Alternate between function-like kinds - match i % 3 { - 0 => NodeKind::FUNCTION, - 1 => NodeKind::METHOD, - _ => NodeKind::MACRO, - } - } - VisibilityKind::Variables => { - // Alternate between variable-like kinds - match i % 5 { - 0 => NodeKind::FIELD, - 1 => NodeKind::VARIABLE, - 2 => NodeKind::GLOBAL_VARIABLE, - 3 => NodeKind::CONSTANT, - _ => NodeKind::ENUM_CONSTANT, - } - } - VisibilityKind::Other => { - // Use kinds that don't fit in Functions or Variables - match i % 4 { - 0 => NodeKind::CLASS, - 1 => NodeKind::STRUCT, - 2 => NodeKind::ENUM, - _ => NodeKind::INTERFACE, - } - } - // For visibility-based sections, we can use any kind - // but let's mix them to test that visibility sections can contain any type - VisibilityKind::Public - | VisibilityKind::Private - | VisibilityKind::Protected - | VisibilityKind::Internal => { - if i % 2 == 0 { - NodeKind::METHOD - } else { - NodeKind::FIELD - } - } - }; - - MemberItem::new(NodeId((i + 1) as i64), node_kind, format!("member_{}", i)) - }) - .collect(); - - // Sort members by name to match the expected behavior from group_members_into_sections - members.sort_unstable_by(|a, b| a.name.cmp(&b.name)); - - VisibilitySection::with_members(kind, members) - }) - } - - // Strategy for generating a UmlNode with random sections and members - fn uml_node_with_sections_strategy() -> impl Strategy)> { - section_count_strategy().prop_flat_map(|_num_sections| { - prop::collection::vec( - (member_count_strategy(), any::()).prop_flat_map( - |(member_count, is_collapsed)| { - visibility_section_with_members_strategy(member_count).prop_map( - move |mut section| { - section.is_collapsed = is_collapsed; - (section, member_count, is_collapsed) - }, - ) - }, - ), - 0..=5, - ) - .prop_map(|sections_data| { - let mut node = UmlNode::new(NodeId(1), NodeKind::CLASS, "TestClass".to_string()); - - let section_info: Vec<(usize, bool)> = sections_data - .iter() - .map(|(_, count, collapsed)| (*count, *collapsed)) - .collect(); - - // Track the next available NodeId to ensure uniqueness across all sections - let mut next_node_id = 1i64; - - for (mut section, _, _) in sections_data { - // Reassign NodeIds to ensure uniqueness across all sections - for member in &mut section.members { - member.id = NodeId(next_node_id); - next_node_id += 1; - } - node.visibility_sections.push(section); - } - - (node, section_info) - }) - }) - } - - proptest! { - /// **Validates: Requirements 1.4** - /// - /// Property 2: Container Node Size Calculation - /// - /// For any Container_Node with N members across M visibility sections, - /// the computed height SHALL be >= header_height + sum(section_header_heights) + - /// sum(member_row_heights) + padding. - /// - /// This property ensures that the calculated size of a container node is always - /// large enough to accommodate all its content, including: - /// - The header - /// - All section headers (for non-empty sections) - /// - All member rows (for expanded sections) - /// - Appropriate padding - #[test] - fn prop_container_node_size_calculation( - (node, section_info) in uml_node_with_sections_strategy(), - (header_height, section_header_height, member_row_height, padding, min_width) - in ui_dimensions_strategy() - ) { - // Skip collapsed nodes as they have different sizing logic - prop_assume!(!node.is_collapsed); - - let size = node.calculate_size( - header_height, - section_header_height, - member_row_height, - padding, - min_width, - ); - - // Calculate the minimum expected height based on the formula - let mut min_expected_height = header_height; - - // Add padding before sections - min_expected_height += padding * 0.5; - - // Count non-empty sections and their members - let mut total_section_headers = 0; - let mut total_member_rows = 0; - - for section in node.visibility_sections.iter() { - // Skip empty sections - if section.members.is_empty() { - continue; - } - - // Add section header - total_section_headers += 1; - min_expected_height += section_header_height; - - // If section is collapsed, only add minimal spacing - if section.is_collapsed { - min_expected_height += padding * 0.25; - } else { - // Add member rows for expanded sections - let member_count = section.members.len(); - total_member_rows += member_count; - min_expected_height += member_count as f32 * member_row_height; - - // Add spacing between sections - min_expected_height += padding * 0.5; - } - } - - // Add bottom padding - min_expected_height += padding * 0.5; - - // Verify the property: calculated height >= minimum expected height - prop_assert!( - size.y >= min_expected_height - 0.01, // Allow small floating point tolerance - "Container node size calculation failed:\n\ - Calculated height: {}\n\ - Minimum expected height: {}\n\ - Header height: {}\n\ - Section headers: {} x {} = {}\n\ - Member rows: {} x {} = {}\n\ - Padding: {}\n\ - Number of sections: {}\n\ - Section info: {:?}", - size.y, - min_expected_height, - header_height, - total_section_headers, - section_header_height, - total_section_headers as f32 * section_header_height, - total_member_rows, - member_row_height, - total_member_rows as f32 * member_row_height, - padding, - node.visibility_sections.len(), - section_info - ); - - // Verify width is at least min_width - prop_assert_eq!( - size.x, - min_width, - "Width should equal min_width" - ); - - // Verify height is positive - prop_assert!( - size.y > 0.0, - "Height should be positive" - ); - } - - /// Property 2 variant: Test with collapsed nodes - /// - /// For collapsed nodes, the height should be just header + minimal padding, - /// regardless of the number of members. - #[test] - fn prop_container_node_size_calculation_collapsed( - (mut node, _section_info) in uml_node_with_sections_strategy(), - (header_height, section_header_height, member_row_height, padding, min_width) - in ui_dimensions_strategy() - ) { - // Force node to be collapsed - node.is_collapsed = true; - - let size = node.calculate_size( - header_height, - section_header_height, - member_row_height, - padding, - min_width, - ); - - // For collapsed nodes, height should be header + minimal padding - let expected_height = header_height + padding * 0.5; - - prop_assert!( - size.y >= expected_height - 0.01 && size.y <= expected_height + 1.0, - "Collapsed node should have minimal height:\n\ - Calculated height: {}\n\ - Expected height: {} (header {} + padding {})\n\ - Number of sections: {}", - size.y, - expected_height, - header_height, - padding * 0.5, - node.visibility_sections.len() - ); - - // Width should still be min_width - prop_assert_eq!(size.x, min_width); - } - - /// Property 2 variant: Test that size increases monotonically with member count - /// - /// Adding more members should never decrease the calculated size. - #[test] - fn prop_container_node_size_monotonic( - member_count1 in 0usize..=20, - member_count2 in 0usize..=20, - (header_height, section_header_height, member_row_height, padding, min_width) - in ui_dimensions_strategy() - ) { - // Create two nodes with different member counts - let mut node1 = UmlNode::new(NodeId(1), NodeKind::CLASS, "TestClass".to_string()); - let mut node2 = UmlNode::new(NodeId(2), NodeKind::CLASS, "TestClass".to_string()); - - // Add members to node1 - if member_count1 > 0 { - let members1: Vec = (0..member_count1) - .map(|i| MemberItem::new(NodeId((i + 1) as i64), NodeKind::METHOD, format!("method_{}", i))) - .collect(); - node1.visibility_sections.push(VisibilitySection::with_members(VisibilityKind::Public, members1)); - } - - // Add members to node2 - if member_count2 > 0 { - let members2: Vec = (0..member_count2) - .map(|i| MemberItem::new(NodeId((i + 1) as i64), NodeKind::METHOD, format!("method_{}", i))) - .collect(); - node2.visibility_sections.push(VisibilitySection::with_members(VisibilityKind::Public, members2)); - } - - let size1 = node1.calculate_size(header_height, section_header_height, member_row_height, padding, min_width); - let size2 = node2.calculate_size(header_height, section_header_height, member_row_height, padding, min_width); - - // If node2 has more members, it should be taller (or equal if both have 0) - if member_count2 > member_count1 { - prop_assert!( - size2.y >= size1.y, - "Node with more members should be taller:\n\ - Node1 members: {}, height: {}\n\ - Node2 members: {}, height: {}", - member_count1, size1.y, - member_count2, size2.y - ); - } else if member_count1 > member_count2 { - prop_assert!( - size1.y >= size2.y, - "Node with more members should be taller:\n\ - Node1 members: {}, height: {}\n\ - Node2 members: {}, height: {}", - member_count1, size1.y, - member_count2, size2.y - ); - } - } - - /// **Validates: Requirements 1.2** - /// - /// Property 3: Member Grouping Correctness - /// - /// For any Container_Node with members, all members SHALL be assigned to exactly one - /// Visibility_Section, and the section assignment SHALL be based on the member's - /// visibility or kind. - /// - /// This property ensures that: - /// 1. Every member appears in exactly one section (no duplicates, no missing members) - /// 2. Members are grouped correctly based on their NodeKind - /// 3. The grouping is deterministic and consistent - #[test] - fn prop_member_grouping_correctness( - (node, _section_info) in uml_node_with_sections_strategy() - ) { - // Skip nodes with no sections - prop_assume!(!node.visibility_sections.is_empty()); - - // Skip nodes with any empty sections - these should never be created by the grouping logic - // The actual implementation (group_members_into_sections) only creates non-empty sections - prop_assume!(node.visibility_sections.iter().all(|s| !s.members.is_empty())); - - // Collect all member IDs from all sections - let mut all_member_ids = Vec::new(); - let mut member_id_to_section = std::collections::HashMap::new(); - - for section in &node.visibility_sections { - for member in §ion.members { - all_member_ids.push(member.id); - - // Track which section this member belongs to - if let Some(existing_section) = member_id_to_section.insert(member.id, section.kind) { - // If we get here, the member appears in multiple sections - this is a violation - prop_assert!( - false, - "Member {:?} appears in multiple sections: {:?} and {:?}", - member.id, - existing_section, - section.kind - ); - } - } - } - - // Property 3.1: Each member appears exactly once (no duplicates) - let unique_count = all_member_ids.iter().collect::>().len(); - prop_assert_eq!( - unique_count, - all_member_ids.len(), - "Some members appear more than once. Total members: {}, Unique members: {}", - all_member_ids.len(), - unique_count - ); - - // Property 3.2: Section assignment is based on member's kind - // Verify that each member is in the correct section based on its kind - for section in &node.visibility_sections { - for member in §ion.members { - let is_correctly_grouped = match section.kind { - VisibilityKind::Functions => matches!( - member.kind, - NodeKind::FUNCTION | NodeKind::METHOD | NodeKind::MACRO - ), - VisibilityKind::Variables => matches!( - member.kind, - NodeKind::FIELD - | NodeKind::VARIABLE - | NodeKind::GLOBAL_VARIABLE - | NodeKind::CONSTANT - | NodeKind::ENUM_CONSTANT - ), - VisibilityKind::Other => { - // Other section should contain kinds that don't fit in Functions or Variables - !matches!( - member.kind, - NodeKind::FUNCTION - | NodeKind::METHOD - | NodeKind::MACRO - | NodeKind::FIELD - | NodeKind::VARIABLE - | NodeKind::GLOBAL_VARIABLE - | NodeKind::CONSTANT - | NodeKind::ENUM_CONSTANT - ) - } - // For visibility-based sections (Public, Private, Protected, Internal), - // we currently don't have visibility information in the test data, - // so we accept any kind in these sections - VisibilityKind::Public - | VisibilityKind::Private - | VisibilityKind::Protected - | VisibilityKind::Internal => true, - }; - - prop_assert!( - is_correctly_grouped, - "Member {:?} with kind {:?} is incorrectly placed in section {:?}", - member.name, - member.kind, - section.kind - ); - } - } - - // Property 3.3: Members within each section should be sorted by name - // (This ensures consistent, predictable ordering) - for section in &node.visibility_sections { - let member_names: Vec<&str> = section.members.iter().map(|m| m.name.as_str()).collect(); - let mut sorted_names = member_names.clone(); - sorted_names.sort_unstable(); - - prop_assert_eq!( - &member_names, - &sorted_names, - "Members in section {:?} are not sorted by name. Found: {:?}, Expected: {:?}", - section.kind, - member_names, - sorted_names - ); - } - } - } - - fn collapse_state_strategy() -> impl Strategy { - let sections_strategy = prop::collection::hash_set( - prop::sample::select(vec![ - VisibilityKind::Public, - VisibilityKind::Private, - VisibilityKind::Protected, - VisibilityKind::Internal, - VisibilityKind::Functions, - VisibilityKind::Variables, - VisibilityKind::Other, - ]), - 0..=7, - ); - - (any::(), sections_strategy).prop_map(|(is_collapsed, sections)| { - let mut state = CollapseState::new(); - state.is_collapsed = is_collapsed; - state.collapsed_sections = sections; - state - }) - } - - fn graph_view_state_strategy() -> impl Strategy { - let collapse_states_strategy = prop::collection::hash_map( - any::().prop_map(NodeId), - collapse_state_strategy(), - 0..=10, - ); - - collapse_states_strategy.prop_map(|collapse_states| { - let mut state = GraphViewState::new(); - state.collapse_states = collapse_states; - state - }) - } - - proptest! { - /// **Validates: Requirements 1.7** - /// - /// Property 5: Node Collapse Toggle Round-trip - /// - /// Toggling a node's collapse state twice should restore the original state. - #[test] - fn prop_node_collapse_toggle_round_trip( - mut state in collapse_state_strategy() - ) { - let original_state = state.clone(); - - state.toggle_collapsed(); - prop_assert_ne!(state.is_collapsed, original_state.is_collapsed); - - state.toggle_collapsed(); - prop_assert_eq!(state.is_collapsed, original_state.is_collapsed); - prop_assert_eq!(state, original_state); - } - - /// **Validates: Requirements 6.1** - /// - /// Property 19: Section Collapse Toggle Round-trip - /// - /// Toggling a section's collapse state twice should restore the original state. - #[test] - fn prop_section_collapse_toggle_round_trip( - mut state in collapse_state_strategy(), - kind_idx in 0usize..7 - ) { - let kinds = [ - VisibilityKind::Public, - VisibilityKind::Private, - VisibilityKind::Protected, - VisibilityKind::Internal, - VisibilityKind::Functions, - VisibilityKind::Variables, - VisibilityKind::Other, - ]; - let kind = kinds[kind_idx]; - let original_state = state.clone(); - - state.toggle_section(kind); - prop_assert_ne!(state.is_section_collapsed(kind), original_state.is_section_collapsed(kind)); - - state.toggle_section(kind); - prop_assert_eq!(state.is_section_collapsed(kind), original_state.is_section_collapsed(kind)); - prop_assert_eq!(state, original_state); - } - - /// **Validates: Requirements 6.5** - /// - /// Property 20: Persistence Round-trip - /// - /// Serializing and deserializing the GraphViewState should result in an identical state. - #[test] - fn prop_persistence_round_trip( - state in graph_view_state_strategy() - ) { - let json = serde_json::to_string(&state).expect("Failed to serialize"); - let deserialized: GraphViewState = serde_json::from_str(&json).expect("Failed to deserialize"); - prop_assert_eq!(state, deserialized); - } - - // ===================================================================== - // Phase 12: Viewport Controls Property Tests - // ===================================================================== - - /// **Validates: Requirements 7.4** - /// - /// Property 23: Zoom Level Clamping - /// - /// For any zoom input value, the resulting zoom level SHALL be clamped - /// to the range [0.1, 4.0] (10% to 400%). - #[test] - fn prop_zoom_level_clamping( - zoom_input in -10.0f32..=10.0f32 - ) { - let mut state = GraphViewState::new(); - state.set_zoom(zoom_input); - - prop_assert!( - state.zoom >= 0.1, - "Zoom level {} should be >= 0.1 (10%) for input {}", - state.zoom, zoom_input - ); - prop_assert!( - state.zoom <= 4.0, - "Zoom level {} should be <= 4.0 (400%) for input {}", - state.zoom, zoom_input - ); - - // If input is within valid range, zoom should equal input - if (0.1..=4.0).contains(&zoom_input) { - prop_assert!( - (state.zoom - zoom_input).abs() < f32::EPSILON, - "Zoom {} should equal input {} when within valid range", - state.zoom, zoom_input - ); - } - } - - /// Property 23 variant: Zoom clamping idempotence - /// - /// Clamping a value that's already in range should not change it. - #[test] - fn prop_zoom_clamping_idempotent( - zoom_input in 0.1f32..=4.0f32 - ) { - let mut state = GraphViewState::new(); - state.set_zoom(zoom_input); - let first_zoom = state.zoom; - state.set_zoom(first_zoom); - - prop_assert!( - (state.zoom - first_zoom).abs() < f32::EPSILON, - "Double-clamping should be idempotent: {} vs {}", - state.zoom, first_zoom - ); - } - - /// **Validates: Requirements 7.5** - /// - /// Property 24: Low Zoom Simplification - /// - /// For any zoom level < 0.5, Container_Nodes SHALL be rendered without - /// member details (simplified mode). - /// - /// This test verifies the threshold logic: zoom values below 0.5 should - /// trigger simplified rendering, while values >= 0.5 should show full detail. - #[test] - fn prop_low_zoom_simplification_threshold( - zoom_level in 0.1f32..=4.0f32 - ) { - let should_simplify = zoom_level < 0.5; - - // Verify the threshold is correct - if should_simplify { - prop_assert!( - zoom_level < 0.5, - "Zoom level {} should trigger simplified rendering", - zoom_level - ); - } else { - prop_assert!( - zoom_level >= 0.5, - "Zoom level {} should show full detail rendering", - zoom_level - ); - } - } - - /// **Validates: Requirements 7.3** - /// - /// Property 22: Zoom to Fit Bounds - /// - /// After executing "Zoom to Fit", all node bounding boxes SHALL be - /// fully contained within the viewport bounds. - /// - /// This test verifies the viewport containment math: given a set of - /// node positions and a viewport, the computed zoom/pan should contain - /// all nodes. - #[test] - fn prop_zoom_to_fit_bounds( - node_positions in prop::collection::vec( - (-1000.0f32..=1000.0, -1000.0f32..=1000.0), - 1..=50 - ), - viewport_width in 200.0f32..=2000.0, - viewport_height in 200.0f32..=2000.0, - ) { - // Compute the bounding box of all nodes - let mut min_x = f32::INFINITY; - let mut min_y = f32::INFINITY; - let mut max_x = f32::NEG_INFINITY; - let mut max_y = f32::NEG_INFINITY; - - let node_width = 150.0f32; - let node_height = 50.0f32; - - for &(x, y) in &node_positions { - min_x = min_x.min(x); - min_y = min_y.min(y); - max_x = max_x.max(x + node_width); - max_y = max_y.max(y + node_height); - } - - let content_width = max_x - min_x; - let content_height = max_y - min_y; - - // Calculate the zoom level needed to fit all content - let zoom_x = viewport_width / content_width.max(1.0); - let zoom_y = viewport_height / content_height.max(1.0); - let fit_zoom = zoom_x.min(zoom_y).clamp(0.1, 4.0); - - // After fit, all content should be within the viewport - // Allow small floating-point tolerance - let tolerance = 1.0; - let fitted_width = content_width * fit_zoom; - let fitted_height = content_height * fit_zoom; - - prop_assert!( - fitted_width <= viewport_width + tolerance || fit_zoom <= 0.1 + f32::EPSILON, - "Fitted width {} should be <= viewport width {} (zoom: {})", - fitted_width, viewport_width, fit_zoom - ); - prop_assert!( - fitted_height <= viewport_height + tolerance || fit_zoom <= 0.1 + f32::EPSILON, - "Fitted height {} should be <= viewport height {} (zoom: {})", - fitted_height, viewport_height, fit_zoom - ); - } - - /// **Validates: Requirements 7.2** - /// - /// Property 21: Zoom Cursor Centering - /// - /// For any zoom operation at cursor position P, the world-space point - /// under P before zoom SHALL remain under P after zoom (within - /// floating-point tolerance). - /// - /// This tests the math: zoom centered at a point should keep that - /// point fixed. - #[test] - fn prop_zoom_cursor_centering( - cursor_x in -500.0f32..=500.0, - cursor_y in -500.0f32..=500.0, - initial_zoom in 0.5f32..=2.0, - zoom_factor in 0.8f32..=1.25 - ) { - let initial_pan_x = 0.0f32; - let initial_pan_y = 0.0f32; - - // World point under cursor before zoom: - // world_point = (cursor - pan) / zoom - let world_x = (cursor_x - initial_pan_x) / initial_zoom; - let world_y = (cursor_y - initial_pan_y) / initial_zoom; - - // Apply zoom centered on cursor - let new_zoom = (initial_zoom * zoom_factor).clamp(0.1, 4.0); - - // After zoom, adjust pan so cursor still maps to same world point: - // cursor = world_point * new_zoom + new_pan - // new_pan = cursor - world_point * new_zoom - let new_pan_x = cursor_x - world_x * new_zoom; - let new_pan_y = cursor_y - world_y * new_zoom; - - // Verify: world point under cursor after zoom should match - let after_world_x = (cursor_x - new_pan_x) / new_zoom; - let after_world_y = (cursor_y - new_pan_y) / new_zoom; - - let tolerance = 0.01; - prop_assert!( - (after_world_x - world_x).abs() < tolerance, - "X world coordinate should be preserved: before={}, after={}, diff={}", - world_x, after_world_x, (after_world_x - world_x).abs() - ); - prop_assert!( - (after_world_y - world_y).abs() < tolerance, - "Y world coordinate should be preserved: before={}, after={}, diff={}", - world_y, after_world_y, (after_world_y - world_y).abs() - ); - } - - // ===================================================================== - // Phase 14: Performance Optimization Property Tests - // ===================================================================== - - /// **Validates: Requirements 10.1, 10.4** - /// - /// Property 25: Viewport Culling - /// - /// For any graph with more than 50 nodes, only nodes whose bounding - /// boxes intersect the expanded viewport (viewport + margin) SHALL be - /// included in the render list. - /// - /// This test verifies: - /// 1. Below threshold (< 50 nodes): all nodes returned regardless of position. - /// 2. At/above threshold (>= 50 nodes): only visible nodes returned. - /// 3. No visible node is incorrectly culled (false negative). - /// 4. No invisible node is incorrectly included (false positive). - #[test] - fn prop_viewport_culling( - // Viewport position and size - vp_x in -500.0f32..500.0, - vp_y in -500.0f32..500.0, - vp_w in 100.0f32..1000.0, - vp_h in 100.0f32..1000.0, - // Number of nodes (range covers below and above threshold) - node_count in 10usize..120, - ) { - let viewport = Rect::from_min_max( - Vec2::new(vp_x, vp_y), - Vec2::new(vp_x + vp_w, vp_y + vp_h), - ); - let expanded = viewport.expand(VIEWPORT_CULL_MARGIN); - - // Generate nodes: half inside viewport, half outside - let mut node_rects = HashMap::new(); - let node_size = 80.0f32; - for i in 0..node_count { - let id = NodeId(i as i64); - let rect = if i % 2 == 0 { - // Place inside the viewport - let frac = (i as f32) / (node_count as f32); - Rect::from_min_max( - Vec2::new(vp_x + frac * vp_w * 0.5, vp_y + frac * vp_h * 0.5), - Vec2::new( - vp_x + frac * vp_w * 0.5 + node_size, - vp_y + frac * vp_h * 0.5 + node_size, - ), - ) - } else { - // Place far outside the viewport (beyond margin) - Rect::from_min_max( - Vec2::new(vp_x + vp_w + VIEWPORT_CULL_MARGIN + 200.0, vp_y + vp_h + VIEWPORT_CULL_MARGIN + 200.0), - Vec2::new( - vp_x + vp_w + VIEWPORT_CULL_MARGIN + 200.0 + node_size, - vp_y + vp_h + VIEWPORT_CULL_MARGIN + 200.0 + node_size, - ), - ) - }; - node_rects.insert(id, rect); - } - - let visible = viewport_cull(&node_rects, viewport); - - if node_count < VIEWPORT_CULL_THRESHOLD { - // Below threshold: all nodes should be returned - prop_assert_eq!( - visible.len(), node_count, - "Below threshold ({}), all {} nodes should be visible, got {}", - VIEWPORT_CULL_THRESHOLD, node_count, visible.len() - ); - } else { - // Above threshold: only nodes intersecting expanded viewport should be returned - - // 1. No false negatives: every node intersecting expanded viewport is included - for (id, rect) in &node_rects { - if expanded.intersects(rect) { - prop_assert!( - visible.contains(id), - "Node {:?} intersects expanded viewport but was culled", id - ); - } - } - - // 2. No false positives: every visible node must intersect expanded viewport - for id in &visible { - let rect = node_rects.get(id).expect("visible node must exist"); - prop_assert!( - expanded.intersects(rect), - "Node {:?} is in visible set but does not intersect expanded viewport", id - ); - } - - // 3. Visible count should be less than total (we placed half far outside) - prop_assert!( - visible.len() <= node_count, - "Visible count {} should be <= total {}", - visible.len(), node_count - ); - } - } - - /// Property 25 variant: Viewport culling with all nodes inside viewport - /// - /// When all nodes are inside the viewport, all should be returned regardless - /// of the threshold. - #[test] - fn prop_viewport_culling_all_visible( - vp_w in 500.0f32..2000.0, - vp_h in 500.0f32..2000.0, - node_count in 50usize..100, - ) { - let viewport = Rect::from_min_max( - Vec2::new(0.0, 0.0), - Vec2::new(vp_w, vp_h), - ); - - let mut node_rects = HashMap::new(); - let node_size = 10.0f32; - for i in 0..node_count { - let id = NodeId(i as i64); - let x = (i as f32 % 20.0) * 20.0 + 10.0; - let y = (i as f32 / 20.0).floor() * 20.0 + 10.0; - node_rects.insert(id, Rect::from_min_max( - Vec2::new(x, y), - Vec2::new(x + node_size, y + node_size), - )); - } - - let visible = viewport_cull(&node_rects, viewport); - prop_assert_eq!( - visible.len(), node_count, - "All {} nodes are inside viewport, all should be visible", - node_count - ); - } - - /// Property 25 variant: Viewport culling with all nodes outside viewport - /// - /// When all nodes are far outside the viewport, none should be returned - /// (above threshold). - #[test] - fn prop_viewport_culling_none_visible( - node_count in 50usize..100, - ) { - let viewport = Rect::from_min_max( - Vec2::new(0.0, 0.0), - Vec2::new(100.0, 100.0), - ); - - let mut node_rects = HashMap::new(); - let far = 100.0 + VIEWPORT_CULL_MARGIN + 500.0; - for i in 0..node_count { - let id = NodeId(i as i64); - node_rects.insert(id, Rect::from_min_max( - Vec2::new(far + i as f32 * 20.0, far), - Vec2::new(far + i as f32 * 20.0 + 10.0, far + 10.0), - )); - } - - let visible = viewport_cull(&node_rects, viewport); - prop_assert_eq!( - visible.len(), 0, - "All nodes are far outside viewport, none should be visible, got {}", - visible.len() - ); - } - } -} From 7552f0557c051be792589ab33bba3d19a3585a07 Mon Sep 17 00:00:00 2001 From: Albert Najjar Date: Fri, 27 Feb 2026 20:40:30 -0500 Subject: [PATCH 2/2] redesigned the UX --- Cargo.lock | 121 +- codestory-ui/src/App.tsx | 938 ++++++-- codestory-ui/src/app/analytics.ts | 143 ++ codestory-ui/src/app/featureFlags.ts | 73 + codestory-ui/src/app/layoutPersistence.ts | 1 + codestory-ui/src/app/uiContract.ts | 25 + codestory-ui/src/app/useProjectLifecycle.ts | 32 +- .../src/components/AdvancedSettingsDrawer.tsx | 274 +++ .../src/components/BookmarkManager.tsx | 140 +- codestory-ui/src/components/CodePane.tsx | 8 +- .../src/components/CommandPalette.tsx | 202 ++ codestory-ui/src/components/GraphPane.tsx | 40 +- .../src/components/GraphTrailControls.tsx | 567 +++-- .../components/InvestigateFocusSwitcher.tsx | 34 + codestory-ui/src/components/ResponsePane.tsx | 601 ++--- .../src/components/bookmarkManagerUtils.ts | 171 ++ .../src/features/onboarding/Checklist.tsx | 206 ++ .../src/features/onboarding/SetupWizard.tsx | 213 ++ .../src/features/onboarding/StarterCard.tsx | 198 ++ .../src/features/settings/SettingsPage.tsx | 60 + .../src/features/spaces/SpacesPanel.tsx | 109 + codestory-ui/src/features/spaces/index.ts | 14 + codestory-ui/src/features/spaces/storage.ts | 197 ++ codestory-ui/src/features/spaces/types.ts | 25 + codestory-ui/src/graph/GraphViewport.tsx | 122 +- codestory-ui/src/graph/trailConfig.ts | 137 ++ codestory-ui/src/layout/AppShell.tsx | 78 + codestory-ui/src/layout/layoutPresets.ts | 45 + codestory-ui/src/styles.css | 885 ++++++- codestory-ui/tests/CommandPalette.test.tsx | 73 + codestory-ui/tests/ResponsePane.test.tsx | 97 +- codestory-ui/tests/app/AppShell.test.tsx | 44 + codestory-ui/tests/app/analytics.test.ts | 107 + codestory-ui/tests/app/featureFlags.test.ts | 43 + codestory-ui/tests/app/layoutPresets.test.ts | 32 + .../bookmarks/bookmarkManagerUtils.test.ts | 58 + .../tests/graph/GraphTrailControls.test.tsx | 71 +- .../tests/graph/GraphViewport.test.tsx | 42 +- codestory-ui/tests/graph/trailConfig.test.ts | 50 +- .../tests/onboarding/Checklist.test.tsx | 128 + .../tests/onboarding/SetupWizard.test.tsx | 104 + .../tests/onboarding/StarterCard.test.tsx | 49 + codestory-ui/tests/spaces/storage.test.ts | 103 + .../codestory-app/src/agent/local_runner.rs | 3 +- crates/codestory-app/src/agent/mod.rs | 2 +- .../codestory-app/src/agent/orchestrator.rs | 356 +++ crates/codestory-app/src/graph_builders.rs | 10 +- crates/codestory-app/src/lib.rs | 202 +- .../src/resolution/candidate_selection.rs | 65 +- crates/codestory-index/src/resolution/mod.rs | 7 +- .../src/resolution/pipeline.rs | 11 +- crates/codestory-storage/src/bookmarks.rs | 13 +- crates/codestory-storage/src/row_mapping.rs | 3 +- crates/codestory-storage/src/tests/mod.rs | 2054 ++++++++--------- crates/codestory-storage/src/trail.rs | 33 +- demo-screens/01-investigate-default.png | Bin 0 -> 808637 bytes demo-screens/02-home-onboarding.png | Bin 0 -> 689821 bytes demo-screens/03-perspectives.png | Bin 0 -> 811263 bytes demo-screens/04-library-spaces.png | Bin 0 -> 409833 bytes demo-screens/05-advanced-flags.png | Bin 0 -> 1569679 bytes demo-screens/06-investigate-advanced.png | Bin 0 -> 865841 bytes demo-screens/ux-reset-01-investigate.png | Bin 0 -> 464832 bytes demo-screens/ux-reset-02-library.png | Bin 0 -> 409052 bytes demo-screens/ux-reset-03-settings.png | Bin 0 -> 543491 bytes demo-screens/ux-reset-04-graph-focus.png | Bin 0 -> 772858 bytes demo-screens/ux-reset-05-code-focus.png | Bin 0 -> 435949 bytes .../ux-reset-06-mobile-investigate.png | Bin 0 -> 239567 bytes docs/ux-reset-rollout.md | 40 + 68 files changed, 7523 insertions(+), 1936 deletions(-) create mode 100644 codestory-ui/src/app/analytics.ts create mode 100644 codestory-ui/src/app/featureFlags.ts create mode 100644 codestory-ui/src/app/uiContract.ts create mode 100644 codestory-ui/src/components/AdvancedSettingsDrawer.tsx create mode 100644 codestory-ui/src/components/CommandPalette.tsx create mode 100644 codestory-ui/src/components/InvestigateFocusSwitcher.tsx create mode 100644 codestory-ui/src/components/bookmarkManagerUtils.ts create mode 100644 codestory-ui/src/features/onboarding/Checklist.tsx create mode 100644 codestory-ui/src/features/onboarding/SetupWizard.tsx create mode 100644 codestory-ui/src/features/onboarding/StarterCard.tsx create mode 100644 codestory-ui/src/features/settings/SettingsPage.tsx create mode 100644 codestory-ui/src/features/spaces/SpacesPanel.tsx create mode 100644 codestory-ui/src/features/spaces/index.ts create mode 100644 codestory-ui/src/features/spaces/storage.ts create mode 100644 codestory-ui/src/features/spaces/types.ts create mode 100644 codestory-ui/src/layout/AppShell.tsx create mode 100644 codestory-ui/src/layout/layoutPresets.ts create mode 100644 codestory-ui/tests/CommandPalette.test.tsx create mode 100644 codestory-ui/tests/app/AppShell.test.tsx create mode 100644 codestory-ui/tests/app/analytics.test.ts create mode 100644 codestory-ui/tests/app/featureFlags.test.ts create mode 100644 codestory-ui/tests/app/layoutPresets.test.ts create mode 100644 codestory-ui/tests/bookmarks/bookmarkManagerUtils.test.ts create mode 100644 codestory-ui/tests/onboarding/Checklist.test.tsx create mode 100644 codestory-ui/tests/onboarding/SetupWizard.test.tsx create mode 100644 codestory-ui/tests/onboarding/StarterCard.test.tsx create mode 100644 codestory-ui/tests/spaces/storage.test.ts create mode 100644 demo-screens/01-investigate-default.png create mode 100644 demo-screens/02-home-onboarding.png create mode 100644 demo-screens/03-perspectives.png create mode 100644 demo-screens/04-library-spaces.png create mode 100644 demo-screens/05-advanced-flags.png create mode 100644 demo-screens/06-investigate-advanced.png create mode 100644 demo-screens/ux-reset-01-investigate.png create mode 100644 demo-screens/ux-reset-02-library.png create mode 100644 demo-screens/ux-reset-03-settings.png create mode 100644 demo-screens/ux-reset-04-graph-focus.png create mode 100644 demo-screens/ux-reset-05-code-focus.png create mode 100644 demo-screens/ux-reset-06-mobile-investigate.png create mode 100644 docs/ux-reset-rollout.md diff --git a/Cargo.lock b/Cargo.lock index 15ca779..5c967dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -215,21 +215,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec", -] - -[[package]] -name = "bit-vec" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" - [[package]] name = "bitflags" version = "2.11.0" @@ -442,10 +427,10 @@ dependencies = [ name = "codestory-bench" version = "0.1.0" dependencies = [ - "anyhow", - "codestory-core", - "codestory-events", - "codestory-index", + "anyhow", + "codestory-core", + "codestory-events", + "codestory-index", "codestory-project", "codestory-storage", "criterion", @@ -1622,31 +1607,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "proptest" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" -dependencies = [ - "bit-set", - "bit-vec", - "bitflags", - "num-traits", - "rand 0.9.2", - "rand_chacha 0.9.0", - "rand_xorshift", - "regex-syntax", - "rusty-fork", - "tempfile", - "unarray", -] - -[[package]] -name = "quick-error" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" - [[package]] name = "quote" version = "1.0.44" @@ -1669,18 +1629,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", + "rand_chacha", + "rand_core", ] [[package]] @@ -1690,17 +1640,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", + "rand_core", ] [[package]] @@ -1712,15 +1652,6 @@ dependencies = [ "getrandom 0.2.17", ] -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - [[package]] name = "rand_distr" version = "0.4.3" @@ -1728,16 +1659,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" dependencies = [ "num-traits", - "rand 0.8.5", -] - -[[package]] -name = "rand_xorshift" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" -dependencies = [ - "rand_core 0.9.5", + "rand", ] [[package]] @@ -1878,18 +1800,6 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" -[[package]] -name = "rusty-fork" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" -dependencies = [ - "fnv", - "quick-error", - "tempfile", - "wait-timeout", -] - [[package]] name = "ryu" version = "1.0.23" @@ -2693,12 +2603,6 @@ dependencies = [ "tree-sitter-language", ] -[[package]] -name = "unarray" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" - [[package]] name = "unicase" version = "2.9.0" @@ -2759,15 +2663,6 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" -[[package]] -name = "wait-timeout" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" -dependencies = [ - "libc", -] - [[package]] name = "walkdir" version = "2.5.0" diff --git a/codestory-ui/src/App.tsx b/codestory-ui/src/App.tsx index e5e3036..2f60b6a 100644 --- a/codestory-ui/src/App.tsx +++ b/codestory-ui/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { api } from "./api/client"; import { @@ -8,6 +8,9 @@ import { toMonacoModelPath, type AgentConnectionState, } from "./app/layoutPersistence"; +import { loadFeatureFlags, saveFeatureFlags, type FeatureFlagState } from "./app/featureFlags"; +import { trackAnalyticsEvent } from "./app/analytics"; +import { UI_CONTRACT, UI_LAYOUT_SCHEMA_STORAGE_KEY } from "./app/uiContract"; import type { PendingSymbolFocus } from "./app/types"; import { useEditorDecorations } from "./app/useEditorDecorations"; import { useProjectLifecycle } from "./app/useProjectLifecycle"; @@ -16,11 +19,24 @@ import { useSymbolFocus } from "./app/useSymbolFocus"; import { useTrailActions } from "./app/useTrailActions"; import { BookmarkManager } from "./components/BookmarkManager"; import { CodePane, type CodeEdgeContext } from "./components/CodePane"; +import { CommandPalette, type CommandPaletteCommand } from "./components/CommandPalette"; import { GraphPane } from "./components/GraphPane"; +import { InvestigateFocusSwitcher } from "./components/InvestigateFocusSwitcher"; import { PendingFocusDialog } from "./components/PendingFocusDialog"; import { ResponsePane } from "./components/ResponsePane"; import { StatusStrip } from "./components/StatusStrip"; import { TopBar } from "./components/TopBar"; +import { StarterCard } from "./features/onboarding/StarterCard"; +import { SettingsPage } from "./features/settings/SettingsPage"; +import { SpacesPanel } from "./features/spaces/SpacesPanel"; +import { + createSpace, + deleteSpace, + listSpaces, + loadSpace, + updateSpace, + type InvestigationSpace, +} from "./features/spaces"; import type { AgentAnswerDto, AgentConnectionSettingsDto, @@ -33,6 +49,15 @@ import type { } from "./generated/api"; import { isTruncatedUmlGraph, languageForPath } from "./graph/GraphViewport"; import { defaultTrailUiConfig, type TrailUiConfig } from "./graph/trailConfig"; +import { AppShell, type AppShellSection } from "./layout/AppShell"; +import { + INVESTIGATE_FOCUS_MODE_KEY, + LEGACY_WORKSPACE_LAYOUT_PRESET_KEY, + migrateLegacyWorkspacePreset, + normalizeInvestigateFocusMode, + investigateFocusModeLabel, + type InvestigateFocusMode, +} from "./layout/layoutPresets"; export default function App() { const [projectPath, setProjectPath] = useState(() => { @@ -80,6 +105,33 @@ export default function App() { const [projectRevision, setProjectRevision] = useState(0); const [bookmarkManagerOpen, setBookmarkManagerOpen] = useState(false); const [bookmarkSeed, setBookmarkSeed] = useState<{ nodeId: string; label: string } | null>(null); + const [activeSection, setActiveSection] = useState("investigate"); + const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); + const [featureFlags, setFeatureFlags] = useState(() => loadFeatureFlags()); + const [investigateMode, setInvestigateMode] = useState(() => { + if (typeof window === "undefined") { + return "graph"; + } + const current = window.localStorage.getItem(INVESTIGATE_FOCUS_MODE_KEY); + if (current) { + return normalizeInvestigateFocusMode(current); + } + const legacy = migrateLegacyWorkspacePreset( + window.localStorage.getItem(LEGACY_WORKSPACE_LAYOUT_PRESET_KEY), + ); + return legacy ?? "graph"; + }); + const [hasCompletedIndex, setHasCompletedIndex] = useState(false); + const [askedFirstQuestion, setAskedFirstQuestion] = useState(false); + const [inspectedSource, setInspectedSource] = useState(false); + const [spaces, setSpaces] = useState(() => listSpaces()); + const [activeSpaceId, setActiveSpaceId] = useState(null); + + const firstAskTrackedRef = useRef(false); + const firstNodeSelectTrackedRef = useRef(false); + const firstSaveTrackedRef = useRef(false); + const firstTrailTrackedRef = useRef(false); + const previousIndexProgressRef = useRef<{ current: number; total: number } | null>(null); const isDirty = draftText !== savedText; const activeGraph = activeGraphId ? (graphMap[activeGraphId] ?? null) : null; @@ -107,6 +159,29 @@ export default function App() { trailConfig.targetId, ]); + useEffect(() => { + window.localStorage.setItem(INVESTIGATE_FOCUS_MODE_KEY, investigateMode); + window.localStorage.setItem(UI_LAYOUT_SCHEMA_STORAGE_KEY, String(UI_CONTRACT.schemaVersion)); + }, [investigateMode]); + + useEffect(() => { + saveFeatureFlags(featureFlags); + }, [featureFlags]); + + useEffect(() => { + const previous = previousIndexProgressRef.current; + if (previous !== null && indexProgress === null) { + setHasCompletedIndex(true); + } + previousIndexProgressRef.current = indexProgress; + }, [indexProgress]); + + useEffect(() => { + if (activeFilePath && projectOpen) { + setInspectedSource(true); + } + }, [activeFilePath, projectOpen]); + const { queueAutoIncrementalIndex, handleOpenProject, handleIndex } = useProjectLifecycle({ projectPath, projectOpen, @@ -144,6 +219,21 @@ export default function App() { path: activeFilePath, text: draftText, }); + const isFirstSave = !firstSaveTrackedRef.current; + if (isFirstSave) { + firstSaveTrackedRef.current = true; + } + trackAnalyticsEvent( + "file_saved", + { + file_path: activeFilePath, + bytes_written: response.bytes_written, + is_first: isFirstSave, + }, + { + projectPath, + }, + ); setSavedText(draftText); setStatus(`Saved ${activeFilePath} (${response.bytes_written} bytes).`); try { @@ -162,7 +252,15 @@ export default function App() { } finally { setIsSaving(false); } - }, [activeFilePath, draftText, isDirty, isSaving, projectOpen, queueAutoIncrementalIndex]); + }, [ + activeFilePath, + draftText, + isDirty, + isSaving, + projectOpen, + projectPath, + queueAutoIncrementalIndex, + ]); const upsertGraph = useCallback((graph: GraphArtifactDto, activate = false) => { setGraphMap((prev) => ({ @@ -270,10 +368,28 @@ export default function App() { } }, []); const handlePrompt = useCallback(async () => { - if (prompt.trim().length === 0) { + const trimmedPrompt = prompt.trim(); + if (trimmedPrompt.length === 0) { return; } + const isFirstAsk = !firstAskTrackedRef.current; + if (isFirstAsk) { + firstAskTrackedRef.current = true; + } + setAskedFirstQuestion(true); + trackAnalyticsEvent( + "ask_submitted", + { + prompt_length: trimmedPrompt.length, + tab: selectedTab, + is_first: isFirstAsk, + }, + { + projectPath, + }, + ); + const command = agentConnection.command?.trim(); const connection: AgentConnectionSettingsDto = { backend: agentConnection.backend, @@ -283,7 +399,7 @@ export default function App() { setIsBusy(true); try { const answer = await api.ask({ - prompt, + prompt: trimmedPrompt, retrieval_profile: retrievalProfile, focus_node_id: activeNodeDetails?.id, max_results: 10, @@ -312,7 +428,15 @@ export default function App() { } finally { setIsBusy(false); } - }, [activeNodeDetails?.id, agentConnection, focusSymbol, prompt, retrievalProfile]); + }, [ + activeNodeDetails?.id, + agentConnection, + focusSymbol, + projectPath, + prompt, + retrievalProfile, + selectedTab, + ]); const toggleNode = useCallback( async (node: SymbolSummaryDto) => { @@ -362,6 +486,650 @@ export default function App() { draftText, }); + const focusSymbolFromUi = useCallback( + (symbolId: string, label: string, source: "graph" | "explorer" | "bookmark") => { + const isFirstNodeSelection = !firstNodeSelectTrackedRef.current; + if (isFirstNodeSelection) { + firstNodeSelectTrackedRef.current = true; + } + + trackAnalyticsEvent( + "node_selected", + { + node_id: symbolId, + source, + is_first: isFirstNodeSelection, + }, + { + projectPath, + }, + ); + + focusSymbol(symbolId, label); + }, + [focusSymbol, projectPath], + ); + + const handleRunTrailWithAnalytics = useCallback(async () => { + const shouldTrack = Boolean(activeNodeDetails?.id) && !trailDisabledReason; + await runTrail(); + if (!shouldTrack) { + return; + } + + const isFirstTrailRun = !firstTrailTrackedRef.current; + if (isFirstTrailRun) { + firstTrailTrackedRef.current = true; + } + + trackAnalyticsEvent( + "trail_run", + { + mode: trailConfig.mode, + edge_filter_count: trailConfig.edgeFilter.length, + has_target_symbol: Boolean(trailConfig.targetId), + is_first: isFirstTrailRun, + }, + { + projectPath, + }, + ); + }, [activeNodeDetails?.id, projectPath, runTrail, trailConfig, trailDisabledReason]); + + const openProjectFromUi = useCallback(async () => { + setHasCompletedIndex(false); + setAskedFirstQuestion(false); + setInspectedSource(false); + await handleOpenProject(); + setActiveSection("investigate"); + setInvestigateMode("graph"); + }, [handleOpenProject]); + + const runIndexFromUi = useCallback( + async (mode: "Full" | "Incremental") => { + await handleIndex(mode); + setActiveSection("investigate"); + setInvestigateMode("graph"); + }, + [handleIndex], + ); + + const runRecommendedIndex = useCallback(async () => { + await runIndexFromUi("Incremental"); + }, [runIndexFromUi]); + + const seedFirstQuestion = useCallback(async () => { + setPrompt("Give me a quick architecture walkthrough of this repository."); + setActiveSection("investigate"); + setInvestigateMode("ask"); + }, []); + + const jumpToSourceInspection = useCallback(async () => { + setActiveSection("investigate"); + setInvestigateMode("code"); + if (activeNodeDetails?.id) { + focusSymbol(activeNodeDetails.id, activeNodeDetails.display_name); + return; + } + const firstRoot = rootSymbols[0]; + if (firstRoot) { + focusSymbol(firstRoot.id, firstRoot.label); + } + }, [activeNodeDetails?.display_name, activeNodeDetails?.id, focusSymbol, rootSymbols]); + + const focusGraphSearchInput = useCallback(() => { + const searchInput = document.querySelector(".graph-search-input"); + searchInput?.focus(); + setInvestigateMode("graph"); + setActiveSection("investigate"); + }, []); + + const setAgentBackend = useCallback((backend: AgentConnectionState["backend"]) => { + setAgentConnection((previous) => ({ + ...previous, + backend, + })); + }, []); + + const updateFeatureFlag = useCallback((flag: keyof FeatureFlagState, value: boolean) => { + setFeatureFlags((previous) => ({ + ...previous, + [flag]: value, + })); + }, []); + + const refreshSpaces = useCallback(() => { + setSpaces(listSpaces()); + }, []); + + const createSpaceFromCurrentContext = useCallback( + (name: string, notes: string) => { + const created = createSpace({ + name: name.trim().length > 0 ? name : `Investigation ${new Date().toLocaleString()}`, + prompt: prompt.trim().length > 0 ? prompt : "Untitled investigation prompt", + activeGraphId, + activeSymbolId: activeNodeDetails?.id ?? null, + notes, + owner: "local-user", + }); + setActiveSpaceId(created.id); + refreshSpaces(); + setStatus(`Saved space "${created.name}".`); + }, + [activeGraphId, activeNodeDetails?.id, prompt, refreshSpaces], + ); + + const loadSpaceIntoWorkspace = useCallback( + (spaceId: string) => { + const space = loadSpace(spaceId); + if (!space) { + setStatus("Requested space was not found."); + return; + } + setPrompt(space.prompt); + if (space.activeGraphId && graphMap[space.activeGraphId]) { + setActiveGraphId(space.activeGraphId); + } + if (space.activeSymbolId) { + focusSymbol(space.activeSymbolId, space.activeSymbolId); + } + setActiveSpaceId(space.id); + setActiveSection("investigate"); + setStatus(`Loaded space "${space.name}".`); + trackAnalyticsEvent( + "library_space_reopened", + { + space_id: space.id, + }, + { + projectPath, + }, + ); + updateSpace(space.id, { notes: space.notes ?? "" }); + refreshSpaces(); + }, + [focusSymbol, graphMap, projectPath, refreshSpaces], + ); + + const removeSpaceById = useCallback( + (spaceId: string) => { + const removed = deleteSpace(spaceId); + if (!removed) { + setStatus("Space was already removed."); + return; + } + if (activeSpaceId === spaceId) { + setActiveSpaceId(null); + } + refreshSpaces(); + setStatus("Deleted saved space."); + }, + [activeSpaceId, refreshSpaces], + ); + + const invokeCommand = useCallback( + (commandId: string, run: () => void | Promise) => { + trackAnalyticsEvent( + "command_invoked", + { + command_id: commandId, + }, + { + projectPath, + }, + ); + return run(); + }, + [projectPath], + ); + + const handleInvestigateModeChange = useCallback( + (mode: InvestigateFocusMode) => { + if (mode === investigateMode) { + return; + } + trackAnalyticsEvent( + "investigate_mode_switched", + { + from_mode: investigateMode, + to_mode: mode, + }, + { + projectPath, + }, + ); + setInvestigateMode(mode); + setActiveSection("investigate"); + setStatus(`Focus mode set to ${investigateFocusModeLabel(mode)}.`); + }, + [investigateMode, projectPath], + ); + + const commandPaletteCommands = useMemo( + () => [ + { + id: "open-project", + label: "Open Project", + detail: "Open the path currently in the top bar", + keywords: ["project", "setup", "workspace"], + disabled: isBusy, + run: () => invokeCommand("open-project", openProjectFromUi), + }, + { + id: "index-incremental", + label: "Run Incremental Index", + detail: "Refresh changed files only", + keywords: ["index", "incremental", "refresh"], + disabled: isBusy || !projectOpen, + run: () => invokeCommand("index-incremental", () => runIndexFromUi("Incremental")), + }, + { + id: "index-full", + label: "Run Full Index", + detail: "Rebuild graph and symbol index", + keywords: ["index", "full", "rebuild"], + disabled: isBusy || !projectOpen, + run: () => invokeCommand("index-full", () => runIndexFromUi("Full")), + }, + { + id: "ask-agent", + label: "Ask Agent", + detail: "Submit the active prompt", + keywords: ["ask", "agent", "prompt"], + disabled: isBusy || prompt.trim().length === 0, + run: () => + invokeCommand("ask-agent", () => { + setInvestigateMode("ask"); + void handlePrompt(); + }), + }, + { + id: "focus-graph-search", + label: "Focus Graph Search", + detail: "Jump to graph search input", + keywords: ["graph", "search", "find"], + run: () => + invokeCommand("focus-graph-search", () => { + setInvestigateMode("graph"); + focusGraphSearchInput(); + }), + }, + { + id: "run-trail", + label: "Run Trail Query", + detail: "Execute trail from selected root symbol", + keywords: ["trail", "graph", "path"], + disabled: Boolean(trailDisabledReason), + run: () => + invokeCommand("run-trail", () => { + setInvestigateMode("graph"); + void handleRunTrailWithAnalytics(); + }), + }, + { + id: "focus-ask", + label: "Focus Ask Mode", + detail: "Show only the Ask pane", + keywords: ["ask", "focus", "mode"], + disabled: investigateMode === "ask", + run: () => + invokeCommand("focus-ask", () => { + handleInvestigateModeChange("ask"); + }), + }, + { + id: "focus-graph", + label: "Focus Graph Mode", + detail: "Show only the Graph pane", + keywords: ["graph", "focus", "mode"], + disabled: investigateMode === "graph", + run: () => + invokeCommand("focus-graph", () => { + handleInvestigateModeChange("graph"); + }), + }, + { + id: "focus-code", + label: "Focus Code Mode", + detail: "Show only the Code pane", + keywords: ["code", "focus", "mode"], + disabled: investigateMode === "code", + run: () => + invokeCommand("focus-code", () => { + handleInvestigateModeChange("code"); + }), + }, + { + id: "open-bookmarks", + label: "Open Bookmark Manager", + detail: "Browse and manage saved symbols", + keywords: ["bookmark", "library", "saved"], + run: () => + invokeCommand("open-bookmarks", () => { + setBookmarkManagerOpen(true); + setActiveSection("investigate"); + }), + }, + { + id: "goto-investigate", + label: "Open Investigate Section", + detail: "Navigate shell to Investigate", + keywords: ["investigate", "workspace", "section"], + disabled: activeSection === "investigate", + run: () => + invokeCommand("goto-investigate", () => { + setActiveSection("investigate"); + }), + }, + { + id: "goto-library", + label: "Open Library Section", + detail: "Navigate shell to Library", + keywords: ["library", "spaces", "section"], + disabled: activeSection === "library", + run: () => + invokeCommand("goto-library", () => { + setActiveSection("library"); + }), + }, + { + id: "goto-settings", + label: "Open Settings Section", + detail: "Navigate shell to Settings", + keywords: ["settings", "preferences", "section"], + disabled: activeSection === "settings", + run: () => + invokeCommand("goto-settings", () => { + setActiveSection("settings"); + }), + }, + { + id: "save-space", + label: "Save Investigation Space", + detail: "Store current prompt and focus for reuse", + keywords: ["space", "save", "library"], + disabled: !featureFlags.spacesLibrary, + run: () => + invokeCommand("save-space", () => { + createSpaceFromCurrentContext("", ""); + setActiveSection("library"); + }), + }, + { + id: "toggle-ux-reset", + label: featureFlags.uxResetV2 ? "Disable UX Reset" : "Enable UX Reset", + detail: "Rollback switch for staged rollout", + keywords: ["feature flag", "rollback", "shell"], + run: () => + invokeCommand("toggle-ux-reset", () => { + updateFeatureFlag("uxResetV2", !featureFlags.uxResetV2); + }), + }, + ], + [ + activeSection, + createSpaceFromCurrentContext, + featureFlags.spacesLibrary, + featureFlags.uxResetV2, + focusGraphSearchInput, + handleInvestigateModeChange, + handlePrompt, + handleRunTrailWithAnalytics, + investigateMode, + invokeCommand, + isBusy, + openProjectFromUi, + projectOpen, + prompt, + runIndexFromUi, + trailDisabledReason, + updateFeatureFlag, + ], + ); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") { + event.preventDefault(); + setCommandPaletteOpen((previous) => !previous); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, []); + + const responsePaneView = ( + { + setAgentConnection((prev) => ({ + ...prev, + command, + })); + }} + onAskAgent={() => { + void handlePrompt(); + }} + isBusy={isBusy} + projectOpen={projectOpen} + agentAnswer={agentAnswer} + graphMap={graphMap} + onActivateGraph={setActiveGraphId} + rootSymbols={rootSymbols} + childrenByNode={childrenByNode} + expandedNodes={expandedNodes} + onToggleNode={toggleNode} + onFocusSymbol={(symbolId, label) => { + focusSymbolFromUi(symbolId, label, "explorer"); + }} + activeSymbolId={activeNodeDetails?.id ?? null} + /> + ); + + const graphPaneView = ( + { + if (searchHits.length > 0) { + setSearchOpen(true); + } + }} + onSearchBlur={() => { + window.setTimeout(() => setSearchOpen(false), 140); + }} + isSearching={isSearching} + searchOpen={searchOpen} + searchHits={searchHits} + searchIndex={searchIndex} + onSearchHitHover={setSearchIndex} + onSearchHitActivate={activateSearchHit} + projectOpen={projectOpen} + projectRevision={projectRevision} + graphOrder={graphOrder} + activeGraphId={activeGraphId} + graphMap={graphMap} + onActivateGraph={setActiveGraphId} + onSelectNode={(nodeId, label) => { + focusSymbolFromUi(nodeId, label, "graph"); + }} + onSelectEdge={(selection) => { + void selectEdge(selection); + }} + trailConfig={trailConfig} + trailRunning={isTrailRunning} + trailDisabledReason={trailDisabledReason} + hasActiveRoot={Boolean(activeNodeDetails?.id)} + activeRootLabel={activeNodeDetails?.display_name ?? null} + onOpenNodeInNewTab={(nodeId, label) => { + void openNeighborhoodInNewTab(nodeId, label); + }} + onNavigateBack={navigateGraphBack} + onNavigateForward={navigateGraphForward} + onShowDefinitionInIde={(nodeId) => { + void showDefinitionInIde(nodeId); + }} + onBookmarkNode={(nodeId, label) => { + setBookmarkSeed({ nodeId, label }); + setBookmarkManagerOpen(true); + }} + onOpenContainingFolder={(path) => { + void openContainingFolder(path); + }} + onOpenBookmarkManager={() => { + if (activeNodeDetails?.id) { + setBookmarkSeed({ + nodeId: activeNodeDetails.id, + label: activeNodeDetails.display_name, + }); + } + setBookmarkManagerOpen(true); + }} + onGraphStatusMessage={setStatus} + onTrailConfigChange={updateTrailConfig} + onRunTrail={() => { + void handleRunTrailWithAnalytics(); + }} + onResetTrailDefaults={resetTrailConfig} + /> + ); + + const codePaneView = ( + { + void selectOccurrenceByIndex(index); + }} + onNextOccurrence={() => { + void selectNextOccurrence(); + }} + onPreviousOccurrence={() => { + void selectPreviousOccurrence(); + }} + codeLanguage={codeLanguage} + draftText={draftText} + onDraftChange={setDraftText} + onEditorMount={handleEditorMount} + /> + ); + + const legacyWorkspaceView = ( +
+ {responsePaneView} + {graphPaneView} + {codePaneView} +
+ ); + + const focusedPane = + investigateMode === "graph" + ? graphPaneView + : investigateMode === "code" + ? codePaneView + : responsePaneView; + + const focusedWorkspaceView = ( +
+ {featureFlags.onboardingStarter ? ( + 0} + askedFirstQuestion={askedFirstQuestion} + inspectedSource={inspectedSource} + onOpenProject={openProjectFromUi} + onRunIndex={runRecommendedIndex} + onSeedQuestion={seedFirstQuestion} + onInspectSource={jumpToSourceInspection} + onPrimaryAction={(action) => { + trackAnalyticsEvent( + "starter_card_cta_clicked", + { + action, + }, + { + projectPath, + }, + ); + }} + /> + ) : null} + +
+ +
+ + +
+
+ +
{focusedPane}
+
+ ); + + const workspaceView = featureFlags.singlePaneInvestigate + ? focusedWorkspaceView + : legacyWorkspaceView; + + const sectionContent: Partial> = { + library: featureFlags.spacesLibrary ? ( + + ) : ( +
+

Spaces Disabled

+

Enable spaces in Settings to save and reopen investigations.

+
+ ), + settings: , + }; + return (
{ - void handleOpenProject(); + void openProjectFromUi(); }} onIndex={(mode) => { - void handleIndex(mode); + void runIndexFromUi(mode); }} /> -
- { - setAgentConnection((prev) => ({ - ...prev, - backend, - })); - }} - agentCommand={agentConnection.command ?? ""} - onAgentCommandChange={(command) => { - setAgentConnection((prev) => ({ - ...prev, - command, - })); - }} - onAskAgent={() => { - void handlePrompt(); - }} - isBusy={isBusy} - projectOpen={projectOpen} - agentAnswer={agentAnswer} - graphMap={graphMap} - onActivateGraph={setActiveGraphId} - rootSymbols={rootSymbols} - childrenByNode={childrenByNode} - expandedNodes={expandedNodes} - onToggleNode={toggleNode} - onFocusSymbol={focusSymbol} - activeSymbolId={activeNodeDetails?.id ?? null} - /> - - { - if (searchHits.length > 0) { - setSearchOpen(true); - } - }} - onSearchBlur={() => { - window.setTimeout(() => setSearchOpen(false), 140); - }} - isSearching={isSearching} - searchOpen={searchOpen} - searchHits={searchHits} - searchIndex={searchIndex} - onSearchHitHover={setSearchIndex} - onSearchHitActivate={activateSearchHit} - projectOpen={projectOpen} - projectRevision={projectRevision} - graphOrder={graphOrder} - activeGraphId={activeGraphId} - graphMap={graphMap} - onActivateGraph={setActiveGraphId} - onSelectNode={(nodeId, label) => { - focusSymbol(nodeId, label); - }} - onSelectEdge={(selection) => { - void selectEdge(selection); - }} - trailConfig={trailConfig} - trailRunning={isTrailRunning} - trailDisabledReason={trailDisabledReason} - hasActiveRoot={Boolean(activeNodeDetails?.id)} - activeRootLabel={activeNodeDetails?.display_name ?? null} - onOpenNodeInNewTab={(nodeId, label) => { - void openNeighborhoodInNewTab(nodeId, label); - }} - onNavigateBack={navigateGraphBack} - onNavigateForward={navigateGraphForward} - onShowDefinitionInIde={(nodeId) => { - void showDefinitionInIde(nodeId); - }} - onBookmarkNode={(nodeId, label) => { - setBookmarkSeed({ nodeId, label }); - setBookmarkManagerOpen(true); - }} - onOpenContainingFolder={(path) => { - void openContainingFolder(path); - }} - onOpenBookmarkManager={() => { - if (activeNodeDetails?.id) { - setBookmarkSeed({ - nodeId: activeNodeDetails.id, - label: activeNodeDetails.display_name, - }); - } - setBookmarkManagerOpen(true); - }} - onGraphStatusMessage={setStatus} - onTrailConfigChange={updateTrailConfig} - onRunTrail={() => { - void runTrail(); - }} - onResetTrailDefaults={resetTrailConfig} - /> - - { - void selectOccurrenceByIndex(index); - }} - onNextOccurrence={() => { - void selectNextOccurrence(); - }} - onPreviousOccurrence={() => { - void selectPreviousOccurrence(); - }} - codeLanguage={codeLanguage} - draftText={draftText} - onDraftChange={setDraftText} - onEditorMount={handleEditorMount} + {featureFlags.uxResetV2 ? ( + -
+ ) : ( + legacyWorkspaceView + )} setBookmarkManagerOpen(false)} onFocusSymbol={(nodeId, label) => { setBookmarkManagerOpen(false); - focusSymbol(nodeId, label); + focusSymbolFromUi(nodeId, label, "bookmark"); }} onStatus={setStatus} + onPromoteBookmarkToSpace={(bookmark) => { + createSpaceFromCurrentContext( + `Bookmark - ${bookmark.node_label}`, + bookmark.comment ?? "", + ); + setStatus(`Promoted "${bookmark.node_label}" to a space.`); + setActiveSection("library"); + }} /> + setCommandPaletteOpen(false)} + />
); } diff --git a/codestory-ui/src/app/analytics.ts b/codestory-ui/src/app/analytics.ts new file mode 100644 index 0000000..ea49448 --- /dev/null +++ b/codestory-ui/src/app/analytics.ts @@ -0,0 +1,143 @@ +const SESSION_STORAGE_KEY = "codestory:analytics-session-id"; +const ANALYTICS_CHANNEL = "codestory:analytics"; + +type AnalyticsEventName = + | "project_opened" + | "index_started" + | "index_completed" + | "ask_submitted" + | "node_selected" + | "file_saved" + | "trail_run" + | "command_invoked" + | "starter_card_cta_clicked" + | "investigate_mode_switched" + | "advanced_drawer_opened" + | "library_space_reopened"; + +type AnalyticsOptions = { + projectPath?: string | null; +}; + +export type AnalyticsEnvelope = { + event_id: string; + event: AnalyticsEventName; + timestamp: string; + session_id: string; + project_id: string | null; + payload: Record; +}; + +export type AnalyticsEmitter = (event: AnalyticsEnvelope) => void; + +let testEmitter: AnalyticsEmitter | null = null; +let memorySessionId: string | null = null; + +function randomHexSegment(length: number): string { + let result = ""; + for (let index = 0; index < length; index += 1) { + result += Math.floor(Math.random() * 16).toString(16); + } + return result; +} + +function fallbackId(prefix: string): string { + return `${prefix}-${Date.now().toString(36)}-${randomHexSegment(10)}`; +} + +function createId(prefix: string): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return `${prefix}-${crypto.randomUUID()}`; + } + return fallbackId(prefix); +} + +function resolveSessionId(): string { + if (typeof window === "undefined") { + if (!memorySessionId) { + memorySessionId = createId("session"); + } + return memorySessionId; + } + + const existing = window.localStorage.getItem(SESSION_STORAGE_KEY); + if (existing && existing.trim().length > 0) { + return existing; + } + + const next = createId("session"); + window.localStorage.setItem(SESSION_STORAGE_KEY, next); + return next; +} + +function fnv1aHash(value: string): string { + let hash = 0x811c9dc5; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = (hash * 0x01000193) >>> 0; + } + return hash.toString(36); +} + +export function toProjectId(projectPath?: string | null): string | null { + if (!projectPath) { + return null; + } + + const normalized = projectPath.trim().replace(/\\/g, "/").toLowerCase(); + if (normalized.length === 0) { + return null; + } + + return `project-${fnv1aHash(normalized)}`; +} + +function analyticsEnabled(): boolean { + return import.meta.env.VITE_DISABLE_ANALYTICS !== "true"; +} + +export function createAnalyticsEvent( + event: AnalyticsEventName, + payload: Record, + options?: AnalyticsOptions, +): AnalyticsEnvelope { + return { + event_id: createId("evt"), + event, + timestamp: new Date().toISOString(), + session_id: resolveSessionId(), + project_id: toProjectId(options?.projectPath), + payload, + }; +} + +function emitAnalyticsEvent(event: AnalyticsEnvelope): void { + if (testEmitter) { + testEmitter(event); + return; + } + + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent(ANALYTICS_CHANNEL, { + detail: event, + }), + ); + } +} + +export function trackAnalyticsEvent( + event: AnalyticsEventName, + payload: Record, + options?: AnalyticsOptions, +): void { + if (!analyticsEnabled()) { + return; + } + + emitAnalyticsEvent(createAnalyticsEvent(event, payload, options)); +} + +export function setAnalyticsEmitterForTests(emitter: AnalyticsEmitter | null): void { + testEmitter = emitter; +} diff --git a/codestory-ui/src/app/featureFlags.ts b/codestory-ui/src/app/featureFlags.ts new file mode 100644 index 0000000..8697f1e --- /dev/null +++ b/codestory-ui/src/app/featureFlags.ts @@ -0,0 +1,73 @@ +export type FeatureFlagState = { + uxResetV2: boolean; + onboardingStarter: boolean; + singlePaneInvestigate: boolean; + spacesLibrary: boolean; +}; + +export const FEATURE_FLAGS_STORAGE_KEY = "codestory:feature-flags:v2"; + +const DEFAULT_FLAGS: FeatureFlagState = { + uxResetV2: true, + onboardingStarter: true, + singlePaneInvestigate: true, + spacesLibrary: true, +}; + +function getStorage(): Storage | null { + if (typeof window === "undefined") { + return null; + } + return window.localStorage; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export function normalizeFeatureFlags(raw: unknown): FeatureFlagState { + if (!isRecord(raw)) { + return DEFAULT_FLAGS; + } + const legacyModernShell = + typeof raw.modernShell === "boolean" ? raw.modernShell : DEFAULT_FLAGS.uxResetV2; + const legacyOnboarding = + typeof raw.onboarding === "boolean" ? raw.onboarding : DEFAULT_FLAGS.onboardingStarter; + + return { + uxResetV2: typeof raw.uxResetV2 === "boolean" ? raw.uxResetV2 : legacyModernShell, + onboardingStarter: + typeof raw.onboardingStarter === "boolean" ? raw.onboardingStarter : legacyOnboarding, + singlePaneInvestigate: + typeof raw.singlePaneInvestigate === "boolean" + ? raw.singlePaneInvestigate + : DEFAULT_FLAGS.singlePaneInvestigate, + spacesLibrary: + typeof raw.spacesLibrary === "boolean" ? raw.spacesLibrary : DEFAULT_FLAGS.spacesLibrary, + }; +} + +export function loadFeatureFlags(): FeatureFlagState { + const storage = getStorage(); + if (!storage) { + return DEFAULT_FLAGS; + } + const raw = storage.getItem(FEATURE_FLAGS_STORAGE_KEY); + if (!raw) { + return DEFAULT_FLAGS; + } + try { + const parsed = JSON.parse(raw) as unknown; + return normalizeFeatureFlags(parsed); + } catch { + return DEFAULT_FLAGS; + } +} + +export function saveFeatureFlags(flags: FeatureFlagState): void { + const storage = getStorage(); + if (!storage) { + return; + } + storage.setItem(FEATURE_FLAGS_STORAGE_KEY, JSON.stringify(flags)); +} diff --git a/codestory-ui/src/app/layoutPersistence.ts b/codestory-ui/src/app/layoutPersistence.ts index a8368ca..074728b 100644 --- a/codestory-ui/src/app/layoutPersistence.ts +++ b/codestory-ui/src/app/layoutPersistence.ts @@ -51,6 +51,7 @@ export type AgentConnectionState = { command: string | null; }; +export const UI_LAYOUT_SCHEMA_VERSION = 2; export const LAST_OPENED_PROJECT_KEY = "codestory:last-opened-project"; export const DEFAULT_AGENT_CONNECTION: AgentConnectionState = { diff --git a/codestory-ui/src/app/uiContract.ts b/codestory-ui/src/app/uiContract.ts new file mode 100644 index 0000000..3425f98 --- /dev/null +++ b/codestory-ui/src/app/uiContract.ts @@ -0,0 +1,25 @@ +export const UI_CONTRACT = { + schemaVersion: 2, + shellMaxWidth: 1920, + appNavWidth: { + min: 220, + max: 260, + }, + paneMinHeight: { + desktop: 440, + laptop: 360, + }, + breakpoints: { + mobile: 700, + tablet: 1024, + desktop: 1280, + }, + spaceScale: { + xs: 0.375, + sm: 0.625, + md: 0.875, + lg: 1.25, + }, +} as const; + +export const UI_LAYOUT_SCHEMA_STORAGE_KEY = "codestory:ui-layout-schema-version"; diff --git a/codestory-ui/src/app/useProjectLifecycle.ts b/codestory-ui/src/app/useProjectLifecycle.ts index e427ea2..a9f0500 100644 --- a/codestory-ui/src/app/useProjectLifecycle.ts +++ b/codestory-ui/src/app/useProjectLifecycle.ts @@ -20,6 +20,7 @@ import { normalizeRetrievalProfile, type AgentConnectionState, } from "./layoutPersistence"; +import { trackAnalyticsEvent } from "./analytics"; type IndexProgressState = { current: number; total: number } | null; @@ -181,6 +182,15 @@ export function useProjectLifecycle({ setStatus( `Indexing complete in ${event.data.duration_ms} ms (parse ${phases.parse_index_ms} ms, flush ${phases.projection_flush_ms} ms, resolve ${phases.edge_resolution_ms} ms, cache ${phases.cache_refresh_ms ?? 0} ms).`, ); + trackAnalyticsEvent( + "index_completed", + { + duration_ms: event.data.duration_ms, + }, + { + projectPath, + }, + ); void loadRootSymbols(); break; } @@ -193,7 +203,7 @@ export function useProjectLifecycle({ break; } }); - }, [loadRootSymbols, setIndexProgress, setStatus]); + }, [loadRootSymbols, projectPath, setIndexProgress, setStatus]); const handleOpenProject = useCallback( async (pathOverride?: string, restored = false) => { @@ -211,6 +221,15 @@ export function useProjectLifecycle({ setTrailConfig(defaultTrailUiConfig()); setAgentConnection(DEFAULT_AGENT_CONNECTION); setRetrievalProfile(DEFAULT_RETRIEVAL_PROFILE); + trackAnalyticsEvent( + "project_opened", + { + source: restored ? "restore" : "manual", + }, + { + projectPath: path, + }, + ); await loadRootSymbols(); const saved = await api.getUiLayout(); @@ -285,13 +304,22 @@ export function useProjectLifecycle({ setIsBusy(true); try { await api.startIndexing({ mode }); + trackAnalyticsEvent( + "index_started", + { + mode, + }, + { + projectPath, + }, + ); } catch (error) { setStatus(error instanceof Error ? error.message : "Failed to start indexing."); } finally { setIsBusy(false); } }, - [setIsBusy, setStatus], + [projectPath, setIsBusy, setStatus], ); return { diff --git a/codestory-ui/src/components/AdvancedSettingsDrawer.tsx b/codestory-ui/src/components/AdvancedSettingsDrawer.tsx new file mode 100644 index 0000000..b394e4a --- /dev/null +++ b/codestory-ui/src/components/AdvancedSettingsDrawer.tsx @@ -0,0 +1,274 @@ +import type { ChangeEvent } from "react"; + +import type { + AgentConnectionSettingsDto, + AgentRetrievalPresetDto, + AgentRetrievalProfileSelectionDto, + AgentRetrievalTraceDto, + EdgeKind, + NodeKind, +} from "../generated/api"; + +export type ResolvedCustomConfig = { + depth: number; + direction: "Incoming" | "Outgoing" | "Both"; + edge_filter: EdgeKind[]; + node_filter: NodeKind[]; + max_nodes: number; + include_edge_occurrences: boolean; + enable_source_reads: boolean; +}; + +const PRESET_LABELS: Record = { + architecture: "Architecture", + callflow: "Call Flow", + inheritance: "Inheritance", + impact: "Impact", +}; + +type AdvancedSettingsDrawerProps = { + isOpen: boolean; + onToggle: () => void; + retrievalProfile: AgentRetrievalProfileSelectionDto; + onRetrievalProfileChange: (next: AgentRetrievalProfileSelectionDto) => void; + activeCustomConfig: ResolvedCustomConfig; + onCustomConfigChange: (patch: Partial) => void; + agentBackend: NonNullable; + onAgentBackendChange: (backend: NonNullable) => void; + agentCommand: string; + onAgentCommandChange: (command: string) => void; + retrievalTrace: AgentRetrievalTraceDto | null; +}; + +function parseCsvList(value: string): string[] { + return value + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + +function formatCsvList(values: string[]): string { + return values.join(", "); +} + +export function AdvancedSettingsDrawer({ + isOpen, + onToggle, + retrievalProfile, + onRetrievalProfileChange, + activeCustomConfig, + onCustomConfigChange, + agentBackend, + onAgentBackendChange, + agentCommand, + onAgentCommandChange, + retrievalTrace, +}: AdvancedSettingsDrawerProps) { + const handleBackendChange = (event: ChangeEvent) => { + const nextBackend = event.target.value; + if (nextBackend === "codex" || nextBackend === "claude_code") { + onAgentBackendChange(nextBackend); + } + }; + + const handleCommandChange = (event: ChangeEvent) => { + onAgentCommandChange(event.target.value); + }; + + const handleProfileModeChange = (event: ChangeEvent) => { + const nextMode = event.target.value; + if (nextMode === "auto") { + onRetrievalProfileChange({ kind: "auto" }); + return; + } + if (nextMode === "preset") { + const preset = retrievalProfile.kind === "preset" ? retrievalProfile.preset : "architecture"; + onRetrievalProfileChange({ kind: "preset", preset }); + return; + } + if (nextMode === "custom") { + onRetrievalProfileChange({ + kind: "custom", + config: activeCustomConfig, + }); + } + }; + + return ( +
+ + + {isOpen ? ( +
+
+ + +
+ +
+ + + {retrievalProfile.kind === "preset" ? ( + + ) : null} + + {retrievalProfile.kind === "custom" ? ( +
+ + + + + + + + + + + + + +
+ ) : null} +
+ +
+ Raw Retrieval Trace + {retrievalTrace ? ( +
{JSON.stringify(retrievalTrace, null, 2)}
+ ) : ( +
Ask a question to view trace data.
+ )} +
+
+ ) : null} +
+ ); +} diff --git a/codestory-ui/src/components/BookmarkManager.tsx b/codestory-ui/src/components/BookmarkManager.tsx index e35e920..b790ca5 100644 --- a/codestory-ui/src/components/BookmarkManager.tsx +++ b/codestory-ui/src/components/BookmarkManager.tsx @@ -1,7 +1,16 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useDeferredValue, useEffect, useMemo, useState } from "react"; import { api } from "../api/client"; import type { BookmarkCategoryDto, BookmarkDto } from "../generated/api"; +import { + bookmarkTagsToDraft, + filterBookmarksByQuery, + loadBookmarkMetadataMap, + removeBookmarkMetadata, + saveBookmarkMetadataMap, + type BookmarkLocalMetadataMap, + upsertBookmarkMetadata, +} from "./bookmarkManagerUtils"; type BookmarkSeed = { nodeId: string; @@ -14,6 +23,7 @@ type BookmarkManagerProps = { onClose: () => void; onFocusSymbol: (nodeId: string, label: string) => void; onStatus: (message: string) => void; + onPromoteBookmarkToSpace?: (bookmark: BookmarkDto) => void; }; const DEFAULT_CATEGORY_NAME = "General"; @@ -24,10 +34,12 @@ export function BookmarkManager({ onClose, onFocusSymbol, onStatus, + onPromoteBookmarkToSpace, }: BookmarkManagerProps) { const [categories, setCategories] = useState([]); const [bookmarks, setBookmarks] = useState([]); const [selectedCategoryId, setSelectedCategoryId] = useState(null); + const [bookmarkSearchQuery, setBookmarkSearchQuery] = useState(""); const [newCategoryName, setNewCategoryName] = useState(""); const [newBookmarkComment, setNewBookmarkComment] = useState(""); const [bookmarkCategoryId, setBookmarkCategoryId] = useState(""); @@ -35,15 +47,49 @@ export function BookmarkManager({ const [categoryDrafts, setCategoryDrafts] = useState>({}); const [bookmarkCommentDrafts, setBookmarkCommentDrafts] = useState>({}); const [bookmarkCategoryDrafts, setBookmarkCategoryDrafts] = useState>({}); + const [bookmarkTagDrafts, setBookmarkTagDrafts] = useState>({}); + const [bookmarkNotesTemplateDrafts, setBookmarkNotesTemplateDrafts] = useState< + Record + >({}); + const [bookmarkMetadataMap, setBookmarkMetadataMap] = useState(() => + loadBookmarkMetadataMap(), + ); const [loading, setLoading] = useState(false); + const deferredBookmarkSearchQuery = useDeferredValue(bookmarkSearchQuery); - const visibleBookmarks = useMemo(() => { + const categoryFilteredBookmarks = useMemo(() => { if (!selectedCategoryId) { return bookmarks; } return bookmarks.filter((bookmark) => bookmark.category_id === selectedCategoryId); }, [bookmarks, selectedCategoryId]); + const visibleBookmarks = useMemo( + () => + filterBookmarksByQuery( + categoryFilteredBookmarks, + deferredBookmarkSearchQuery, + bookmarkMetadataMap, + ), + [bookmarkMetadataMap, categoryFilteredBookmarks, deferredBookmarkSearchQuery], + ); + + const persistBookmarkMetadataDraft = useCallback( + (bookmarkId: string) => { + setBookmarkMetadataMap((previous) => { + const next = upsertBookmarkMetadata( + previous, + bookmarkId, + bookmarkTagDrafts[bookmarkId] ?? "", + bookmarkNotesTemplateDrafts[bookmarkId] ?? "", + ); + saveBookmarkMetadataMap(next); + return next; + }); + }, + [bookmarkNotesTemplateDrafts, bookmarkTagDrafts], + ); + const refreshData = useCallback( async (categoryFilter: string | null = selectedCategoryId): Promise => { setLoading(true); @@ -52,8 +98,10 @@ export function BookmarkManager({ api.getBookmarkCategories(), api.getBookmarks(categoryFilter), ]); + const loadedMetadata = loadBookmarkMetadataMap(); setCategories(loadedCategories); setBookmarks(loadedBookmarks); + setBookmarkMetadataMap(loadedMetadata); setCategoryDrafts( Object.fromEntries(loadedCategories.map((category) => [category.id, category.name])), ); @@ -67,6 +115,22 @@ export function BookmarkManager({ loadedBookmarks.map((bookmark) => [bookmark.id, bookmark.category_id]), ), ); + setBookmarkTagDrafts( + Object.fromEntries( + loadedBookmarks.map((bookmark) => [ + bookmark.id, + bookmarkTagsToDraft(loadedMetadata[bookmark.id]?.tags ?? []), + ]), + ), + ); + setBookmarkNotesTemplateDrafts( + Object.fromEntries( + loadedBookmarks.map((bookmark) => [ + bookmark.id, + loadedMetadata[bookmark.id]?.notesTemplate ?? "", + ]), + ), + ); if (!categoryFilter && loadedCategories[0] && bookmarkCategoryId.length === 0) { setBookmarkCategoryId(loadedCategories[0].id); @@ -194,6 +258,7 @@ export function BookmarkManager({ }; const saveBookmark = async (bookmark: BookmarkDto) => { + persistBookmarkMetadataDraft(bookmark.id); const nextComment = (bookmarkCommentDrafts[bookmark.id] ?? "").trim(); const nextCategory = bookmarkCategoryDrafts[bookmark.id] ?? bookmark.category_id; try { @@ -214,6 +279,21 @@ export function BookmarkManager({ try { await api.deleteBookmark(bookmarkId); setBookmarks((previous) => previous.filter((bookmark) => bookmark.id !== bookmarkId)); + setBookmarkTagDrafts((previous) => { + const next = { ...previous }; + delete next[bookmarkId]; + return next; + }); + setBookmarkNotesTemplateDrafts((previous) => { + const next = { ...previous }; + delete next[bookmarkId]; + return next; + }); + setBookmarkMetadataMap((previous) => { + const next = removeBookmarkMetadata(previous, bookmarkId); + saveBookmarkMetadataMap(next); + return next; + }); onStatus("Bookmark deleted."); } catch (error) { onStatus(error instanceof Error ? error.message : "Failed to delete bookmark."); @@ -241,7 +321,7 @@ export function BookmarkManager({ setNewCategoryName(event.target.value)} - placeholder="New category name" + placeholder="Category name" />