struct graph

This commit is contained in:
2026-09-07 15:52:46 -04:00
parent 30ed3351b5
commit 93521c53fb
15 changed files with 1061 additions and 30 deletions

View File

@@ -21,7 +21,7 @@ pub struct GraphEdge {
pub to: String,
pub label: String,
}
fn contains(range: &Range, p: Position) -> bool {
pub(super) fn contains(range: &Range, p: Position) -> bool {
(range.start.line, range.start.character) <= (p.line, p.character)
&& (p.line, p.character) < (range.end.line, range.end.character)
}
@@ -72,7 +72,7 @@ fn following(symbols: &[Value], p: Position) -> Option<&Value> {
)
})
}
fn excerpt(text: &str, range: &Range) -> String {
pub(super) fn excerpt(text: &str, range: &Range) -> String {
text.lines()
.enumerate()
.filter(|(i, _)| *i >= range.start.line as usize && *i <= range.end.line as usize)
@@ -111,7 +111,7 @@ fn concepts<'a>(node: &'a Value, out: &mut Vec<&'a Value>) {
concepts(child, out);
}
}
fn expression(
pub(super) fn expression(
node: &Value,
path: &str,
text: &str,

View File

@@ -3,6 +3,7 @@
mod constraints;
mod graph;
mod relationships;
mod types;
pub use graph::ConceptGraph;
pub use relationships::GraphOptions;
mod transport;

View File

@@ -79,7 +79,12 @@ impl Clangd {
..location.clone()
})
}
fn graph_neighbors(&self, target: &Location, up: bool) -> Result<ConceptGraph> {
fn graph_neighbors(
&self,
target: &Location,
up: bool,
ast_cache: &mut HashMap<String, Value>,
) -> Result<ConceptGraph> {
let uri = self.document(&target.path)?;
let ast = self.connection.request("textDocument/ast", json!({"textDocument":{"uri":uri},"range":{"start":target.range.start,"end":target.range.start}}))?;
if ast["kind"] == "Concept" && ast["role"] == "declaration" {
@@ -93,6 +98,10 @@ impl Clangd {
});
return Ok(graph);
}
let ast = self.declaration_ast(target, ast, ast_cache)?;
if super::types::is_type(&ast) {
return self.type_structure(target, &ast, up);
}
let items = self.connection.request(
"textDocument/prepareCallHierarchy",
json!({"textDocument":{"uri":uri},"position":target.range.start}),
@@ -100,7 +109,7 @@ impl Clangd {
let item = items
.as_array()
.and_then(|a| a.first())
.ok_or("Select a concept or function name to view its graph.")?;
.ok_or("Select a concept, function, class, or struct name to view its graph.")?;
let root = self.location(
item["uri"].as_str().ok_or("Missing function URI")?,
&item["selectionRange"],
@@ -152,6 +161,9 @@ impl Clangd {
label: "called by".into(),
});
}
if up {
self.function_constraints(target, &ast, &mut graph)?;
}
Ok(graph)
}
pub(super) fn relationship_graph(
@@ -185,7 +197,9 @@ impl Clangd {
let mut visited = HashSet::new();
let mut symbols = HashMap::<(String, u32, u32), Location>::new();
let mut edge_keys = HashSet::new();
let mut structures = HashMap::new();
let mut requests = 0;
let mut ast_cache = HashMap::new();
while let Some((location, parent, depth, up)) = queue.pop_front() {
if depth >= options.depth || !visited.insert((key(&location), up)) {
continue;
@@ -195,7 +209,7 @@ impl Clangd {
break;
}
requests += 1;
let local = match self.graph_neighbors(&location, up) {
let local = match self.graph_neighbors(&location, up, &mut ast_cache) {
Ok(local) => local,
Err(error) if depth == 0 => return Err(error),
Err(error) => {
@@ -205,6 +219,10 @@ impl Clangd {
continue;
}
};
let local_is_type = local
.warnings
.iter()
.any(|w| w.starts_with("Type graphs show"));
if graph.nodes.is_empty() {
let mut root = local.nodes[0].clone();
root.location = target.clone();
@@ -222,16 +240,18 @@ impl Clangd {
if start.elapsed().as_secs() >= 30 || graph.nodes.len() >= 150 {
graph
.warnings
.push("Graph reached its 150-node limit.".into());
.push("Graph reached its 150-node or 30-second budget.".into());
break;
}
let symbol_node = matches!(node.kind.as_str(), "concept" | "function" | "usage")
&& node.label
!= format!(
"{}:{}",
node.location.path,
node.location.range.start.line + 1
);
let symbol_node = matches!(
node.kind.as_str(),
"concept" | "function" | "type" | "field" | "usage"
) && node.label
!= format!(
"{}:{}",
node.location.path,
node.location.range.start.line + 1
);
let old_id = node.id.clone();
if symbol_node {
let symbol_key = key(&node.location);
@@ -255,6 +275,12 @@ impl Clangd {
let ast = self.connection.request("textDocument/ast", json!({"textDocument":{"uri":uri},"range":{"start":node.location.range.start,"end":node.location.range.start}}))?;
node.kind = match ast["kind"].as_str() {
Some("Concept") => "concept",
Some(
"CXXRecord"
| "ClassTemplate"
| "ClassTemplateSpecialization"
| "Record",
) => "type",
Some(
"Function" | "FunctionTemplate" | "CXXMethod" | "CXXConstructor"
| "CXXDestructor",
@@ -265,10 +291,16 @@ impl Clangd {
}
}
let node_key = key(&node.location);
let structure_key = (
node_key.clone(),
node.location.range.end.line,
node.location.range.end.character,
node.kind.clone(),
);
let id = if symbol_node {
known.get(&node_key).cloned()
} else {
None
structures.get(&structure_key).cloned()
};
let id = id.unwrap_or_else(|| format!("g{}", graph.nodes.len()));
mapping.insert(old_id, id.clone());
@@ -277,10 +309,19 @@ impl Clangd {
if symbol_node {
known.insert(node_key, id.clone());
}
if !symbol_node {
structures.insert(structure_key, id.clone());
}
graph.nodes.push(node.clone());
}
if symbol_node && matches!(node.kind.as_str(), "concept" | "function") {
queue.push_back((node.location, id, depth + 1, up));
if symbol_node && matches!(node.kind.as_str(), "concept" | "function" | "type") {
// A type's members expand towards their dependencies/callees,
// even though membership is drawn below the containing type.
let direction = if local_is_type { true } else { up };
queue.push_back((node.location.clone(), id.clone(), depth + 1, direction));
if node.kind == "type" {
queue.push_back((node.location, id, depth + 1, false));
}
}
}
for edge in local.edges {

View File

@@ -903,3 +903,221 @@ fn function_graph_depth_namespaces_and_cycles() {
);
server.stop();
}
#[test]
#[ignore = "requires clangd"]
fn type_graph_constraints_and_members() {
let temp = tempdir().unwrap();
let root = temp.path();
let build = root.join("build");
fs::create_dir_all(build.join("meson-info")).unwrap();
fs::write(build.join("meson-info/intro-projectinfo.json"), "{}").unwrap();
fs::write(
root.join("types.hpp"),
include_str!("../../../../examples/types/types.hpp"),
)
.unwrap();
fs::write(
root.join("main.cpp"),
include_str!("../../../../examples/types/main.cpp"),
)
.unwrap();
fs::write(build.join("compile_commands.json"),serde_json::to_vec(&json!([{"directory":root,"file":root.join("main.cpp"),"arguments":["clang++","-std=c++23","-c",root.join("main.cpp")]}])).unwrap()).unwrap();
let server = Clangd::start(crate::open(root, Some(&build), false).unwrap()).unwrap();
let source = include_str!("../../../../examples/types/types.hpp");
let position = |needle: &str| {
let (line, text) = source
.lines()
.enumerate()
.find(|(_, l)| l.contains(needle))
.unwrap();
Position {
line: line as u32,
character: (text.find(needle).unwrap() + 1) as u32,
}
};
let pos = position("Box :");
let graph = server
.graph(
"types.hpp",
pos,
&GraphOptions {
depth: 1,
excluded_namespaces: vec![],
},
)
.unwrap();
for label in [
"demo::Base",
"demo::Helper",
"demo::Box::value",
"demo::Box::helper",
"demo::Box::set",
"demo::Box::run",
"demo::Addable",
"demo::Sized",
"std::copyable",
"std::constructible_from",
"std::integral",
"std::convertible_to",
] {
assert!(
graph.nodes.iter().any(|n| n.label == label),
"Missing {label}: {graph:?}"
);
}
assert!(
graph
.nodes
.iter()
.any(|n| n.kind == "requires" && n.detail.contains("u.size()"))
);
assert!(
graph
.nodes
.iter()
.any(|n| n.kind == "parameter" && n.label == "argument x")
);
assert!(
graph
.nodes
.iter()
.any(|n| n.kind == "template-parameter" && n.label == "template T")
);
let default_graph = server
.graph("types.hpp", pos, &GraphOptions::default())
.unwrap();
assert!(
!default_graph
.nodes
.iter()
.any(|n| n.label.starts_with("std::"))
);
let node = |label: &str| graph.nodes.iter().find(|n| n.label == label).unwrap();
let edge = |from: &str, to: &str, label: &str| {
graph
.edges
.iter()
.any(|e| e.from == from && e.to == to && e.label == label)
};
assert!(edge(&node("demo::Base").id, "selected", "base of"));
assert!(edge(
&node("demo::Helper").id,
&node("demo::Box::helper").id,
"type of"
));
assert!(edge(
&node("template T").id,
&node("demo::Box::value").id,
"type of"
));
assert!(edge(
&node("std::integral").id,
&node("argument n").id,
"constrains"
));
let u = graph
.nodes
.iter()
.find(|n| {
n.label == "template U" && edge(&n.id, &node("demo::Box::set").id, "template parameter")
})
.unwrap();
let argument = graph
.nodes
.iter()
.find(|n| n.label == "argument u" && edge(&n.id, &node("demo::Box::set").id, "argument"))
.unwrap();
assert!(edge(&node("demo::Sized").id, &u.id, "constrains"));
assert!(edge(&u.id, &argument.id, "type of"));
let calls = server
.graph(
"types.hpp",
pos,
&GraphOptions {
depth: 2,
..Default::default()
},
)
.unwrap();
let work = calls
.nodes
.iter()
.find(|n| n.label == "demo::Helper::work")
.unwrap();
let set = calls
.nodes
.iter()
.find(|n| n.label == "demo::Box::set")
.unwrap();
assert!(
calls
.edges
.iter()
.any(|e| e.from == work.id && e.to == set.id && e.label == "called by")
);
assert!(
graph
.nodes
.iter()
.any(|n| n.kind == "operator" && n.label == "||")
);
assert!(
graph
.nodes
.iter()
.any(|n| n.kind == "type" && n.label.starts_with("std::vector")),
"{graph:?}"
);
let method = server
.graph(
"types.hpp",
position("set("),
&GraphOptions {
depth: 1,
excluded_namespaces: vec![],
},
)
.unwrap();
assert!(method.title.ends_with("::set"), "{method:?}");
assert!(
method.nodes.iter().any(|n| n.label == "demo::Sized"),
"{method:?}"
);
let constructor = server
.graph(
"types.hpp",
position("Box(Addable"),
&GraphOptions {
depth: 1,
excluded_namespaces: vec![],
},
)
.unwrap();
assert!(
constructor
.nodes
.iter()
.any(|n| n.label == "std::constructible_from"),
"{constructor:?}"
);
let alias = server
.graph("types.hpp", position("IntBox ="), &GraphOptions::default())
.unwrap();
assert!(
alias.title == "demo::Box"
|| alias
.nodes
.iter()
.any(|n| n.kind == "type" && n.label == "demo::Box"),
"{alias:?}"
);
let enumeration = server
.graph("types.hpp", position("State {"), &GraphOptions::default())
.unwrap();
assert!(
enumeration.nodes.iter().any(|n| n.label == "idle"),
"{enumeration:?}"
);
server.stop();
}

View File

@@ -0,0 +1,460 @@
//! Type structure and signature constraints come from clangd's AST and symbols.
use super::graph::{ConceptGraph, GraphEdge, GraphNode, contains, excerpt, expression};
use super::*;
use std::collections::HashMap;
fn children(node: &Value) -> impl Iterator<Item = &Value> {
node["children"].as_array().into_iter().flatten()
}
pub(super) fn is_record(node: &Value) -> bool {
matches!(
node["kind"].as_str(),
Some(
"CXXRecord"
| "Record"
| "ClassTemplate"
| "ClassTemplateSpecialization"
| "ClassTemplatePartialSpecialization"
)
) && node["role"] == "declaration"
}
pub(super) fn is_type(node: &Value) -> bool {
is_record(node)
|| node["role"] == "declaration"
&& matches!(
node["kind"].as_str(),
Some("Enum" | "TypeAlias" | "Typedef" | "TypeAliasTemplate")
)
}
fn is_function(node: &Value) -> bool {
matches!(
node["kind"].as_str(),
Some("Function" | "CXXMethod" | "CXXConstructor" | "CXXDestructor" | "CXXConversion")
) && node["role"] == "declaration"
}
fn range(node: &Value) -> Option<Range> {
serde_json::from_value(node["range"].clone()).ok()
}
fn declaration<'a>(symbols: &'a [Value], node: &Value) -> Option<&'a Value> {
let r = range(node)?;
let body_start = children(node)
.find(|c| c["kind"] == "Compound")
.and_then(range)
.map(|r| (r.start.line, r.start.character));
for s in symbols {
if let Some(found) = s["children"].as_array().and_then(|c| declaration(c, node)) {
return Some(found);
}
if let Ok(selection) = serde_json::from_value::<Range>(s["selectionRange"].clone())
&& contains(&r, selection.start)
&& body_start.is_none_or(|p| (selection.start.line, selection.start.character) < p)
&& (s["name"] == node["detail"]
|| (is_function(node) || node["kind"] == "FunctionTemplate")
&& matches!(s["kind"].as_u64(), Some(6 | 9 | 12)))
{
return Some(s);
}
}
None
}
struct Builder<'a> {
path: &'a str,
text: &'a str,
symbols: &'a [Value],
graph: ConceptGraph,
templates: HashMap<String, String>,
}
impl Builder<'_> {
fn add(
&mut self,
node: &Value,
kind: &str,
label: String,
owner: &str,
relation: &str,
above: bool,
) -> Option<String> {
if self.graph.nodes.len() >= 120 {
return None;
}
let r = range(node)?;
let mut location = declaration(self.symbols, node)
.and_then(|s| serde_json::from_value(s["selectionRange"].clone()).ok())
.unwrap_or_else(|| r.clone());
if kind == "type" {
for qualifier in children(node).filter(|c| c["role"] == "specifier") {
if let Some(r) = range(qualifier) {
location.start = r.end;
}
}
}
let id = format!("type{}", self.graph.nodes.len());
self.graph.nodes.push(GraphNode {
id: id.clone(),
label,
kind: kind.into(),
detail: format!(
"{}:{}\n{}",
self.path,
r.start.line + 1,
excerpt(self.text, &r)
),
location: Location {
path: self.path.into(),
range: location,
label: String::new(),
},
});
self.graph.edges.push(GraphEdge {
from: if above { id.clone() } else { owner.into() },
to: if above { owner.into() } else { id.clone() },
label: relation.into(),
});
Some(id)
}
fn constraint(&mut self, node: &Value, owner: &str) {
let old = self.graph.edges.len();
expression(node, self.path, self.text, &mut self.graph, owner, 0);
for edge in &mut self.graph.edges[old..] {
if edge.to == owner {
edge.label = "constrains".into();
}
}
}
fn signature(&mut self, node: &Value, owner: &str) {
// Only signature children: bodies, default arguments, and constructor
// initializers must not be mistaken for associated constraints.
let function = if is_function(node) {
Some(node)
} else {
children(node).find(|c| is_function(c))
};
let mut templates = self.templates.clone();
let mut params = Vec::new();
if let Some(function) = function {
for proto in children(function).filter(|c| c["kind"] == "FunctionProto") {
for param in children(proto).filter(|c| c["kind"] == "ParmVar") {
let label = format!(
"argument {}",
param["detail"].as_str().unwrap_or("(unnamed)")
);
if let Some(id) = self.add(param, "parameter", label, owner, "argument", true) {
params.push((param, id));
}
}
}
}
for child in children(node) {
if matches!(
child["kind"].as_str(),
Some("TemplateTypeParm" | "NonTypeTemplateParm" | "TemplateTemplateParm")
) && child["role"] == "declaration"
{
// Abbreviated function templates synthesize an `x:auto` template
// parameter; attach its concept to the actual argument x.
let name = child["detail"].as_str().unwrap_or("(unnamed)");
let argument = name
.strip_suffix(":auto")
.and_then(|name| params.iter().find(|(p, _)| p["detail"] == name))
.or_else(|| {
range(child).and_then(|r| {
params
.iter()
.find(|(p, _)| range(p).is_some_and(|pr| contains(&pr, r.start)))
})
});
let id = if let Some((_, id)) = argument {
Some(id.clone())
} else {
self.add(
child,
"template-parameter",
format!("template {name}"),
owner,
"template parameter",
true,
)
};
if let Some(id) = id {
templates.insert(name.to_owned(), id.clone());
for concept in children(child)
.filter(|c| c["kind"] == "Concept" && c["role"] == "reference")
{
self.constraint(concept, &id);
}
}
}
if child["role"] == "expression" {
self.constraint(child, owner);
}
}
if let Some(function) = function
&& !std::ptr::eq(function, node)
{
for child in children(function).filter(|c| c["role"] == "expression") {
self.constraint(child, owner);
}
}
if matches!(
node["kind"].as_str(),
Some("ClassTemplate" | "TypeAliasTemplate" | "ClassTemplatePartialSpecialization")
) {
self.templates = templates.clone();
}
// Some clangd versions expose constrained auto under the parameter type.
for (param, id) in params {
self.parameter_types(param, &id, &templates);
let mut stack: Vec<_> = children(param).filter(|c| c["role"] == "type").collect();
while let Some(n) = stack.pop() {
if n["kind"] == "Concept" && n["role"] == "reference" {
self.constraint(n, &id);
} else {
stack.extend(
children(n).filter(|c| c["role"] == "type" || c["role"] == "reference"),
);
}
}
}
}
fn parameter_types(&mut self, node: &Value, owner: &str, templates: &HashMap<String, String>) {
let mut stack: Vec<_> = children(node).filter(|n| n["role"] == "type").collect();
let mut seen = HashSet::new();
let mut visited = 0;
while let Some(n) = stack.pop() {
visited += 1;
if visited > 1000 {
break;
}
if n["kind"] == "TemplateTypeParm"
&& let Some(id) = n["detail"].as_str().and_then(|name| templates.get(name))
&& id != owner
&& seen.insert(id.clone())
{
self.graph.edges.push(GraphEdge {
from: id.clone(),
to: owner.into(),
label: "type of".into(),
});
}
stack.extend(
children(n).filter(|n| n["role"] == "type" || n["role"] == "template argument"),
);
}
}
fn composed_types(&mut self, node: &Value, owner: &str, relation: &str) {
self.parameter_types(node, owner, &self.templates.clone());
let mut stack: Vec<_> = children(node)
.filter(|n| n["role"] == "type" || n["role"] == "base")
.collect();
let mut visited = 0;
while let Some(n) = stack.pop() {
visited += 1;
if visited > 300 || self.graph.nodes.len() >= 120 {
break;
}
if matches!(
n["kind"].as_str(),
Some("Record" | "TemplateSpecialization" | "Typedef" | "Enum")
) && n["role"] == "type"
{
if let Some(r) = range(n) {
self.add(n, "type", excerpt(self.text, &r), owner, relation, true);
}
} else if n["kind"] == "Builtin" {
if let Some(r) = range(n) {
self.add(
n,
"primitive",
excerpt(self.text, &r),
owner,
relation,
true,
);
}
} else {
stack.extend(children(n).filter(|n| n["role"] == "type" || n["role"] == "base"));
}
}
}
}
impl Clangd {
pub(super) fn declaration_ast(
&self,
target: &Location,
point_ast: Value,
cache: &mut HashMap<String, Value>,
) -> Result<Value> {
// Fetch the file AST only for declarations whose template envelope may
// have been omitted by the point query. Included headers are not expanded.
if !is_type(&point_ast) && !is_function(&point_ast) {
return Ok(point_ast);
}
let uri = self.document(&target.path)?;
if !cache.contains_key(&target.path) {
let full = self
.connection
.request("textDocument/ast", json!({"textDocument":{"uri":uri}}))?;
cache.insert(target.path.clone(), full);
}
let full = &cache[&target.path];
let mut stack = vec![full];
let mut count = 0;
while let Some(n) = stack.pop() {
count += 1;
if count > 100000 {
return Err("Declaration AST exceeds the 100,000-node inspection limit.".into());
}
if let Some(r) = range(n)
&& !contains(&r, target.range.start)
{
continue;
}
if matches!(
n["kind"].as_str(),
Some("ClassTemplate" | "FunctionTemplate" | "TypeAliasTemplate")
) && children(n)
.any(|c| (is_function(c) || is_type(c)) && c["range"] == point_ast["range"])
{
return Ok(n.clone());
}
stack.extend(children(n));
}
Ok(point_ast)
}
pub(super) fn type_structure(
&self,
target: &Location,
ast: &Value,
up: bool,
) -> Result<ConceptGraph> {
let text = self.read_target(&target.path)?.content;
let symbols = self
.query(&target.path, target.range.start, Query::Symbols)?
.symbols;
let mut b = Builder {
path: &target.path,
templates: HashMap::new(),
text: &text,
symbols: &symbols,
graph: ConceptGraph {
title: target.label.clone(),
nodes: vec![GraphNode {
id: "selected".into(),
label: target.label.clone(),
kind: "selected".into(),
detail: range(ast).map(|r| excerpt(&text, &r)).unwrap_or_default(),
location: target.clone(),
}],
edges: vec![],
warnings: vec![],
},
};
let record = if ast["kind"] == "ClassTemplate" {
children(ast).find(|c| is_record(c))
} else {
Some(ast)
};
b.signature(ast, "selected");
if up {
if matches!(ast["kind"].as_str(), Some("TypeAlias" | "Typedef" | "Enum")) {
b.composed_types(ast, "selected", "underlying type");
}
if ast["kind"] == "TypeAliasTemplate" {
for alias in children(ast).filter(|c| c["kind"] == "TypeAlias") {
b.composed_types(alias, "selected", "underlying type");
}
}
if let Some(record) = record {
for base in children(record).filter(|c| c["role"] == "base") {
b.composed_types(base, "selected", "base of");
}
}
} else if let Some(record) = record {
if children(record)
.filter(|c| c["role"] == "declaration")
.count()
> 80
{
b.graph
.warnings
.push("Only the first 80 member declarations were considered.".into());
}
for member in children(record)
.filter(|c| c["role"] == "declaration")
.take(80)
{
let function = if is_function(member) {
Some(member)
} else if member["kind"] == "FunctionTemplate" {
children(member).find(|c| is_function(c))
} else {
None
};
let (kind, relation) = if function.is_some_and(|f| f["kind"] == "CXXConstructor") {
("function", "constructor")
} else if function.is_some() {
("function", "member")
} else if is_record(member) || member["kind"] == "Enum" {
("type", "nested type")
} else if matches!(member["kind"].as_str(), Some("Field" | "Var")) {
("field", "member")
} else if matches!(
member["kind"].as_str(),
Some("TypeAlias" | "Typedef" | "TypeAliasTemplate")
) {
("type", "member alias")
} else if member["kind"] == "EnumConstant" {
("enumerator", "enumerator")
} else {
continue;
};
let symbol = declaration(&symbols, function.unwrap_or(member));
let label = symbol
.and_then(|s| s["name"].as_str())
.or(member["detail"].as_str())
.unwrap_or("constructor")
.to_owned();
if let Some(id) = b.add(
function.unwrap_or(member),
kind,
label,
"selected",
relation,
false,
) {
if function.is_some() {
b.signature(member, &id);
} else {
b.composed_types(member, &id, "type of");
}
}
}
}
if b.graph.nodes.len() >= 100 {
b.graph.warnings.push("Type structure reached its node budget; some members or constraints may be omitted.".into());
}
b.graph.warnings.push("Type graphs show declared bases, members and signature constraints, not every template instantiation or inherited member. Set depth 2 to expand member calls; dependent calls may remain unresolved.".into());
Ok(b.graph)
}
pub(super) fn function_constraints(
&self,
target: &Location,
ast: &Value,
graph: &mut ConceptGraph,
) -> Result<()> {
let text = self.read_target(&target.path)?.content;
let symbols = self
.query(&target.path, target.range.start, Query::Symbols)?
.symbols;
let mut b = Builder {
path: &target.path,
templates: HashMap::new(),
text: &text,
symbols: &symbols,
graph: graph.clone(),
};
b.signature(ast, "selected");
*graph = b.graph;
Ok(())
}
}

View File

@@ -0,0 +1,5 @@
cmake_minimum_required(VERSION 3.20)
project(cex_type_demo LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_executable(type-demo main.cpp)

2
examples/types/main.cpp Normal file
View File

@@ -0,0 +1,2 @@
#include "types.hpp"
int main() { demo::Box<int> box(1); box.run(2); }

View File

@@ -0,0 +1,2 @@
project('cex-type-demo', 'cpp', default_options: ['cpp_std=c++23'])
executable('type-demo', 'main.cpp')

25
examples/types/types.hpp Normal file
View File

@@ -0,0 +1,25 @@
#pragma once
#include <concepts>
#include <vector>
namespace demo {
template<class T> concept Addable = requires(T a) { a + a; };
template<class T> concept Sized = requires(T a) { a.size(); };
struct Base { int id; };
struct Helper { int work() { return 1; } };
template<Addable T>
requires std::copyable<T> && (Addable<T> || Sized<T>)
struct Box : Base {
T value;
std::vector<T> entries;
Helper helper;
using value_type = T;
Box(Addable auto x) requires std::constructible_from<T, decltype(x)> : value(x) { helper.work(); }
template<Sized U> requires requires(U u) { u.size(); }
void set(const U& u) requires Addable<T> { helper.work(); }
void run(std::integral auto n) { helper.work(); }
template<class U> requires std::convertible_to<U, T>
void assign(U u) { value = u; }
};
using IntBox = Box<int>;
enum class State { idle, ready };
}

View File

@@ -22,7 +22,7 @@ export function installGraph(editor: monaco.editor.IStandaloneCodeEditor, option
<label for="graph-depth">Graph depth</label><select id="graph-depth"><option value="1">1 · immediate neighbors</option><option value="2">2 levels</option><option value="3">3 levels</option><option value="4">4 levels</option></select>
<p class="hint">Independent of LLM context. Higher depths may take longer.</p>
<label for="graph-namespace">Excluded namespaces</label><div id="graph-exclusions"></div><div class="field-row"><input id="graph-namespace" placeholder="library::detail" autocomplete="off"><button id="graph-add">Add</button></div><button id="graph-apply">Apply to graph</button><p id="graph-options-status" class="hint" role="status"></p>
</details><p id="graph-status" class="hint" role="status">Select a concept or function name in the editor.</p>
</details><div id="graph-progress" hidden role="status" aria-live="polite"><span class="graph-spinner" aria-hidden="true"></span><span>Building graph…</span><progress aria-label="Building graph"></progress></div><p id="graph-status" class="hint" role="status">Select a concept, function, class, or struct name in the editor.</p>
<div class="graph-tools"><button id="graph-back" aria-label="Previous graph" disabled>←</button><button id="graph-forward" aria-label="Next graph" disabled>→</button><button id="graph-fit">Fit</button><button id="graph-center">Center symbol</button><button id="graph-out" aria-label="Zoom out graph"></button><button id="graph-in" aria-label="Zoom in graph">+</button><span id="graph-zoom" class="hint">100%</span></div>
<div id="graph-canvas" tabindex="0" aria-label="Symbol relationship graph"></div><div id="graph-detail" class="hint"></div><details id="graph-warning-details"><summary>Coverage and limits</summary><div id="graph-warnings" class="hint"></div></details>`;
pane.append(panel);
@@ -107,7 +107,7 @@ export function installGraph(editor: monaco.editor.IStandaloneCodeEditor, option
const p = node.location.range.start;
open.onclick = () => void options.open(node.location.path, { path: node.location.path, line:p.line+1, column:p.character+1,end_column:p.character+1 },true);
$('graph-detail').append(pre,open);
if (node.kind === 'concept' || node.kind === 'selected' || node.kind === 'function') { const focus = document.createElement('button'); focus.textContent = node.kind === 'concept' ? 'Focus this concept' : 'Focus this symbol'; focus.onclick = () => void refresh(node.location); $('graph-detail').append(focus); }
if (node.kind === 'concept' || node.kind === 'selected' || node.kind === 'function' || node.kind === 'type') { const focus = document.createElement('button'); focus.textContent = node.kind === 'concept' ? 'Focus this concept' : 'Focus this symbol'; focus.onclick = () => void refresh(node.location); $('graph-detail').append(focus); }
}
function render(value: Graph) {
const layout = new dagre.graphlib.Graph().setGraph({ rankdir:'TB', nodesep:32, ranksep:54, marginx:24, marginy:24 }).setDefaultEdgeLabel(() => ({}));
@@ -130,7 +130,7 @@ export function installGraph(editor: monaco.editor.IStandaloneCodeEditor, option
const label=svg('text',{x:'12',y:'38'});
const text=node.label.replace(/\s+/g,' '); const lines=text.match(/.{1,32}(?:\s|$)|.{1,32}/g) || [''];
lines.slice(0,3).forEach((line,i) => {const span=svg('tspan',{x:'12',dy:i?'17':'0'});span.textContent=line.trim()+(i===2&&lines.length>3?'…':'');label.append(span);});group.append(label);
group.onclick=() => select(node); group.ondblclick=() => { if (['concept','function','selected'].includes(node.kind)) void refresh(node.location); };group.onkeydown=event => {
group.onclick=() => select(node); group.ondblclick=() => { if (['concept','function','type','selected'].includes(node.kind)) void refresh(node.location); };group.onkeydown=event => {
if(event.key==='Enter'||event.key===' '){event.preventDefault();select(node);}
if (event.key.startsWith('Arrow')) {
const direction = event.key, here = layoutPositions.get(node.id)!;
@@ -152,12 +152,13 @@ export function installGraph(editor: monaco.editor.IStandaloneCodeEditor, option
const rendered = drawing;
requestAnimationFrame(() => { if (drawing === rendered) { fit(true); centerSymbol(); } });
}
function building(value: boolean) { $('graph-progress').hidden = !value; canvas.setAttribute('aria-busy', String(value)); $<HTMLButtonElement>('graph-refresh').disabled = value; }
async function refresh(location?: Location, record = true) {
const workspace=options.workspace(); if(!workspace) return;
const path=location?.path || options.path(), p=editor.getPosition(); if(!path||!p) return;
const position=location?.range.start || {line:p.lineNumber-1,character:p.column-1};
const operation=++ticket, epoch=options.epoch(); show('graph');
$('graph-status').textContent='Building relationships with clangd…'; $<HTMLButtonElement>('graph-refresh').disabled=true;
$('graph-status').textContent='Building relationships with clangd…'; building(true);
graph=null; $('graph-canvas').replaceChildren();$('graph-detail').replaceChildren();$('graph-warnings').replaceChildren();
try {
await options.ready(); if(operation!==ticket||epoch!==options.epoch())return;
@@ -165,13 +166,13 @@ export function installGraph(editor: monaco.editor.IStandaloneCodeEditor, option
if(operation!==ticket||epoch!==options.epoch())return;
if (record && center) { back.push(center); if (back.length > 30) back.shift(); forward.length=0; }
graph=result; center=graph.nodes.find(n => n.id === 'selected')?.location; historyControls(); render(graph);$('graph-title').textContent=graph.title;
$('graph-status').textContent='Dependencies / callees ↑ · uses / callers ↓. Select for details; double-click a symbol to refocus. Drag to pan, Ctrl/⌘+scroll to zoom.';
$('graph-status').textContent='Dependencies / constraints ↑ · members / uses / callers ↓. Select for details; double-click a symbol to refocus. Drag to pan, Ctrl/⌘+scroll to zoom.';
} catch(error) {if(operation===ticket)$('graph-status').textContent=String(error);}
finally {if(operation===ticket)$<HTMLButtonElement>('graph-refresh').disabled=false;}
finally {if(operation===ticket)building(false);}
}
$('graph-back').onclick = () => { const previous = back.pop(); if (previous) {if (center) forward.push(center); historyControls(); void refresh(previous, false);} };
$('graph-forward').onclick = () => { const next = forward.pop(); if (next) {if (center) back.push(center); historyControls(); void refresh(next, false);} };
$('graph-refresh').onclick=()=>void refresh();$('nav-graph').onclick=()=>void refresh();
editor.addAction({id:'cex.graph',label:'Show Symbol Graph',contextMenuGroupId:'navigation',contextMenuOrder:3,run:()=>refresh()});
return { reset(){++ticket;center=undefined;back.length=0;forward.length=0;historyControls();graphOptions=structuredClone(options.workspace()?.settings?.graph ?? {depth:1,excluded_namespaces:['std']});settingsUI();$('graph-options-status').textContent='';toggle.disabled=false;$<HTMLButtonElement>('nav-graph').disabled=true;graph=null;drawing=null; $('graph-canvas').replaceChildren();$('graph-detail').replaceChildren();$('graph-warnings').replaceChildren();$('graph-title').textContent='Symbol graph';$('graph-status').textContent='Select a concept or function name in the editor.';$<HTMLButtonElement>('graph-refresh').disabled=false;collapse();}, fileOpened(){toggle.disabled=false;$<HTMLButtonElement>('nav-graph').disabled=editor.getModel()?.getLanguageId()!=='cpp';} };
return { reset(){++ticket;building(false);center=undefined;back.length=0;forward.length=0;historyControls();graphOptions=structuredClone(options.workspace()?.settings?.graph ?? {depth:1,excluded_namespaces:['std']});settingsUI();$('graph-options-status').textContent='';toggle.disabled=false;$<HTMLButtonElement>('nav-graph').disabled=true;graph=null;drawing=null; $('graph-canvas').replaceChildren();$('graph-detail').replaceChildren();$('graph-warnings').replaceChildren();$('graph-title').textContent='Symbol graph';$('graph-status').textContent='Select a concept, function, class, or struct name in the editor.';$<HTMLButtonElement>('graph-refresh').disabled=false;collapse();}, fileOpened(){toggle.disabled=false;$<HTMLButtonElement>('nav-graph').disabled=editor.getModel()?.getLanguageId()!=='cpp';} };
}

View File

@@ -6,6 +6,7 @@ import 'monaco-editor/esm/vs/editor/contrib/folding/browser/folding';
import 'monaco-editor/esm/vs/editor/contrib/find/browser/findController';
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import './style.css';
import { installSidebarResize } from './resize';
import { installTheme } from './theme';
import { installNavigation } from './navigation';
import { installGraph } from './graph';
@@ -21,7 +22,7 @@ type Hit = { path: string; line: number; column: number; end_column: number; pre
type SearchResults = { hits: Hit[]; truncated: boolean; cancelled: boolean; skipped_files: number };
const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
<header><div class="brand">cex<span>CODE EXPLORER</span></div><div id="project-name">No project open</div><select id="theme-select" aria-label="Color theme" title="Color theme"><option value="system">System</option><option value="light">Light</option><option value="dark">Dark</option></select><button id="toggle-sidebar" class="icon-button" aria-label="Toggle right sidebar" title="Toggle right sidebar" aria-expanded="false" aria-controls="explanation-pane" disabled><svg viewBox="0 0 20 20" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="2.5" y="3.5" width="15" height="13" rx="2"/><path d="M12.5 4v12"/></svg></button><button id="workspace-button">Open project…</button></header>
<header><details id="file-menu"><summary>File</summary><div class="file-dropdown"><button id="file-open">Open Project…</button><details id="file-recents"><summary>Open Recent</summary><div id="file-recent-list"><span class="hint">Loading…</span></div></details><button id="file-welcome">Return to Welcome</button></div></details><div class="brand">cex<span>CODE EXPLORER</span></div><div id="project-name">No project open</div><select id="theme-select" aria-label="Color theme" title="Color theme"><option value="system">System</option><option value="light">Light</option><option value="dark">Dark</option></select><button id="toggle-sidebar" class="icon-button" aria-label="Toggle right sidebar" title="Toggle right sidebar" aria-expanded="false" aria-controls="explanation-pane" disabled><svg viewBox="0 0 20 20" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="2.5" y="3.5" width="15" height="13" rx="2"/><path d="M12.5 4v12"/></svg></button><button id="workspace-button">Open project…</button></header>
<main class="landing">
<section id="landing" aria-label="Open Recent"><div class="landing-intro"><div class="welcome-mark">{ cex }</div><h1>Open Recent</h1><p>Pick up where you left off, or explore a new project.</p><button id="recent-open" class="primary">Open a project</button></div><div class="recent-heading"><h2>Recent projects</h2><button id="refresh-recents" aria-label="Refresh recent projects">Refresh</button></div><p id="recent-status" class="hint" role="status">Loading recent projects…</p><div id="recent-projects"></div><small class="hint">History is stored in ~/.cex. Project settings stay with each project.</small></section>
<aside><nav><button id="files-tab" class="active">Files</button><button id="search-tab">Search</button><button id="navigation-tab">Navigation</button></nav>
@@ -75,11 +76,14 @@ const navigation = installNavigation(editor, { workspace: () => workspace, path:
const explanations = installExplanations(editor, { workspace: () => workspace, path: () => currentPath, epoch: () => epoch, ready: navigation.whenReady, open: openFile });
const graph = installGraph(editor, { workspace: () => workspace, path: () => currentPath, epoch: () => epoch, ready: navigation.whenReady, open: openFile });
const vim = installVim(editor, navigation);
installSidebarResize(editor);
function status(message: string, error = false) { $('status').textContent = message; $('status').classList.toggle('error', error); }
function errorText(error: unknown) { return error instanceof Error ? error.message : String(error); }
function button(text: string, className = '') { const el = document.createElement('button'); el.textContent = text; el.className = className; return el; }
let closingWorkspace = false;
function showSetup() {
if (closingWorkspace) return;
source.value = workspace?.root ?? '';
build.value = workspace?.settings.build_directory ?? '';
clangdPath.value = workspace?.settings.clangd_path ?? 'clangd';
@@ -312,10 +316,7 @@ async function loadRecents() {
const path = document.createElement('span'); path.textContent = project.root;
const date = document.createElement('small'); date.textContent = `Last opened ${new Date(project.last_opened * 1000).toLocaleString()}`;
open.append(name, path, date); open.title = project.root;
open.onclick = async () => {
showSetup(); source.value = project.root; await inspectSource();
if (inspection && !inspection.warning && source.value === project.root) $<HTMLFormElement>('setup-form').requestSubmit();
};
open.onclick = () => void openRecent(project.root);
const remove = button('×', 'recent-remove'); remove.setAttribute('aria-label', `Remove ${name.textContent} from recent projects`);
remove.title = 'Remove from history; project files are preserved';
remove.onclick = async () => {
@@ -331,3 +332,61 @@ async function loadRecents() {
$('recent-open').onclick = () => $('workspace-button').click();
$('refresh-recents').onclick = () => void loadRecents();
void loadRecents();
function closeFileMenu() { $<HTMLDetailsElement>('file-menu').open = false; }
async function openRecent(root: string) {
if (closingWorkspace) return;
closeFileMenu(); showSetup(); source.value = root; await inspectSource();
if (setup.open && inspection && !inspection.warning && source.value === root) $<HTMLFormElement>('setup-form').requestSubmit();
}
$('file-open').onclick = () => {
if (closingWorkspace) return;
closeFileMenu(); showSetup(); ++inspecting;
source.value = ''; build.value = ''; clangdPath.value = 'clangd'; inspectedRoot = ''; inspection = null;
reset.checked = false; reset.disabled = true; $('saved-state').textContent = ''; $('setup-title').textContent = 'Open project'; source.focus();
};
$('file-recents').addEventListener('toggle', async () => {
if (!$<HTMLDetailsElement>('file-recents').open) return;
$('file-recent-list').textContent = 'Loading…';
try {
const projects = await invoke<{root:string;last_opened:number}[]>('recent_projects');
$('file-recent-list').replaceChildren(...projects.map(project => {
const item = button('', 'file-recent');
const name = document.createElement('strong'); name.textContent = project.root.split('/').pop() || project.root;
const path = document.createElement('small'); path.textContent = project.root;
item.append(name,path); item.title = project.root; item.onclick = () => void openRecent(project.root); return item;
}));
if (!projects.length) $('file-recent-list').textContent = 'No recent projects yet.';
} catch(error) { $('file-recent-list').textContent = errorText(error); }
});
$('file-menu').addEventListener('toggle', () => {
if ($<HTMLDetailsElement>('file-menu').open) {
$<HTMLButtonElement>('file-welcome').disabled = !workspace || closingWorkspace;
$<HTMLDetailsElement>('file-recents').open = false;
}
});
document.addEventListener('pointerdown', event => { if (!$('file-menu').contains(event.target as Node)) closeFileMenu(); });
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && $<HTMLDetailsElement>('file-menu').open) { closeFileMenu(); $('file-menu').querySelector('summary')!.focus(); }
});
$('file-welcome').onclick = async () => {
if (!workspace || closingWorkspace) return;
const root = workspace.root;
closingWorkspace = true; closeFileMenu(); ++epoch; ++fileRequest; ++searchRequest;
clearTimeout(searchTimer); graph.reset(); explanations.reset();
status('Closing project…');
try {
await rememberQueue;
await invoke('close_workspace', {root});
workspace = null; currentPath = ''; viewStates.clear(); navigation.reset(); vim.reset();
const model = editor.getModel(); editor.setModel(null); model?.dispose();
graph.reset(); $<HTMLButtonElement>('toggle-sidebar').disabled = true;
$('landing').hidden = false; document.querySelector('main')!.classList.add('landing');
$('project-name').textContent = 'No project open'; $('project-name').title = '';
$('workspace-button').textContent = 'Open project…'; $('file-path').textContent = 'Welcome to CEX';
$('cursor').textContent = ''; $('tree').replaceChildren(); $('results').replaceChildren(); query.value = '';
status('Ready'); await loadRecents(); $('recent-open').focus();
} catch(error) { status(`Could not close project: ${errorText(error)}`, true); }
finally { closingWorkspace = false; }
};

46
frontend/resize.ts Normal file
View File

@@ -0,0 +1,46 @@
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
// Widths stay in effect while switching projects, tabs, and collapsed panels.
export function installSidebarResize(editor: monaco.editor.IStandaloneCodeEditor) {
const main = document.querySelector('main')!;
const left = main.querySelector('aside')!;
const right = document.getElementById('explanation-pane')!;
const handles: { side: 'left' | 'right'; panel: HTMLElement; handle: HTMLDivElement }[] = [];
function limits(side: 'left' | 'right') {
const overlay = window.innerWidth <= 1100 || right.classList.contains('graph-expanded');
const other = side === 'left' ? (right.hidden || overlay ? 0 : right.getBoundingClientRect().width) : (overlay ? 0 : left.getBoundingClientRect().width);
return { min: side === 'left' ? 180 : 300, max: Math.max(side === 'left' ? 180 : 300, Math.min(side === 'left' ? 650 : 1100, main.clientWidth - other - (overlay && side === 'right' ? 40 : 280))) };
}
function widthProperty(side: 'left' | 'right') { return side === 'right' && right.classList.contains('graph-expanded') ? '--expanded-sidebar-width' : `--${side}-sidebar-width`; }
function setWidth(side: 'left' | 'right', width: number) {
const {min,max} = limits(side);
document.documentElement.style.setProperty(widthProperty(side), `${Math.round(Math.max(min,Math.min(max,width)))}px`);
editor.layout(); update();
}
function update() {
for (const {side,panel,handle} of handles) {
const {min,max}=limits(side);
handle.setAttribute('aria-valuemin',String(min));handle.setAttribute('aria-valuemax',String(Math.round(max)));
handle.setAttribute('aria-valuenow',String(Math.round(panel.getBoundingClientRect().width)));
}
}
for (const [side,panel] of [['left',left],['right',right]] as const) {
const handle = document.createElement('div');handle.className=`sidebar-resizer ${side}-resizer`;handle.tabIndex=0;
handle.setAttribute('role','separator');handle.setAttribute('aria-orientation','vertical');
handle.setAttribute('aria-label',`Resize ${side} sidebar`);handle.title='Drag to resize · arrow keys adjust · double-click to reset';
panel.append(handle);handles.push({side,panel,handle});
let drag: {x:number;width:number} | null=null;
handle.onpointerdown=event=>{if(event.button)return;event.preventDefault();drag={x:event.clientX,width:panel.getBoundingClientRect().width};handle.setPointerCapture(event.pointerId);document.body.classList.add('resizing-sidebars');};
handle.onpointermove=event=>{if(drag)setWidth(side,drag.width+(event.clientX-drag.x)*(side==='left'?1:-1));};
const stop=()=>{drag=null;document.body.classList.remove('resizing-sidebars');};
handle.onpointerup=handle.onpointercancel=handle.onlostpointercapture=stop;
handle.ondblclick=()=>{document.documentElement.style.removeProperty(widthProperty(side));editor.layout();update();};
handle.onkeydown=event=>{
if(event.key==='ArrowLeft'||event.key==='ArrowRight') {event.preventDefault();const direction=(event.key==='ArrowRight'?1:-1)*(side==='left'?1:-1);setWidth(side,panel.getBoundingClientRect().width+direction*(event.shiftKey?50:10));}
else if(event.key==='Home'||event.key==='End') {event.preventDefault();const range=limits(side);setWidth(side,event.key==='Home'?range.min:range.max);}
};
}
const observer=new ResizeObserver(update);observer.observe(left);observer.observe(right);
window.addEventListener('resize',()=>{for(const {side,panel} of handles) if(!panel.hidden && document.documentElement.style.getPropertyValue(widthProperty(side))) setWidth(side,panel.getBoundingClientRect().width);});
update();
}

View File

@@ -260,3 +260,40 @@ dialog { background: var(--surface); border-color: var(--border); box-shadow: va
#graph-warnings { padding-top: 8px; }
#graph-zoom { margin-left: auto; font-variant-numeric: tabular-nums; }
#explanation-pane.graph-expanded { position: fixed; z-index: 15; right: 0; top: 58px; bottom: 29px; width: min(1050px, 94vw); box-shadow: var(--shadow); }
#graph-progress { display: flex; align-items: center; gap: 9px; padding: 9px 11px; background: var(--selected); color: var(--accent-text); border-radius: 7px; font-size: 12px; flex-shrink: 0; }
#graph-progress progress { width: 64px; height: 4px; margin-left: auto; accent-color: var(--accent); }
.graph-spinner { width: 14px; height: 14px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: graph-spin .8s linear infinite; }
@keyframes graph-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .graph-spinner { animation: none; } }
#file-menu { position: relative; font-size: 12px; }
#file-menu > summary { padding: 7px 8px; cursor: pointer; border-radius: 5px; list-style: none; }
#file-menu > summary::-webkit-details-marker { display: none; }
#file-menu[open] > summary, #file-menu > summary:hover { background: var(--hover); }
.file-dropdown { position: absolute; top: calc(100% + 8px); left: 0; width: min(350px, 85vw); z-index: 30; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 6px; box-shadow: var(--shadow); }
.file-dropdown > button, #file-recents > summary { display: block; width: 100%; text-align: left; background: transparent; border: 0; padding: 9px 10px; cursor: pointer; border-radius: 5px; }
.file-dropdown button:hover, #file-recents > summary:hover { background: var(--hover); }
#file-welcome { border-top: 1px solid var(--border); margin-top: 5px; }
#file-recent-list { max-height: 320px; overflow: auto; padding: 4px 8px; color: var(--muted); }
.file-recent { display: block; width: 100%; background: transparent; border: 0; text-align: left; padding: 8px; }
.file-recent strong, .file-recent small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-recent small { color: var(--muted); margin-top: 4px; }
main { grid-template-columns: minmax(180px, var(--left-sidebar-width, clamp(230px, 22vw, 320px))) minmax(280px, 1fr); }
main.explaining { grid-template-columns: minmax(180px, var(--left-sidebar-width, 19vw)) minmax(280px, 1fr) minmax(300px, var(--right-sidebar-width, 33vw)); }
main > aside, #explanation-pane { position: relative; }
.sidebar-resizer { position: absolute; top: 0; bottom: 0; width: 8px; z-index: 20; cursor: col-resize; touch-action: none; }
.left-resizer { right: -4px; }
.right-resizer { left: -4px; }
.sidebar-resizer:hover, .sidebar-resizer:focus-visible { background: var(--accent); opacity: .6; outline: none; }
body.resizing-sidebars, body.resizing-sidebars * { cursor: col-resize !important; user-select: none !important; }
.graph-node.type rect { fill: var(--node-operator); }
.graph-node.field rect, .graph-node.alias rect { fill: var(--node); }
.graph-node.parameter rect, .graph-node.template-parameter rect { fill: var(--node-requires); }
@media (max-width: 1100px) {
main.explaining { grid-template-columns: minmax(180px, var(--left-sidebar-width, clamp(230px, 22vw, 320px))) minmax(280px, 1fr); }
#explanation-pane { position: fixed; width: min(var(--right-sidebar-width, 520px), 94vw); }
}
#explanation-pane.graph-expanded { width: min(var(--expanded-sidebar-width, 1050px), 94vw); }
main > aside > nav { overflow-x: auto; flex-shrink: 0; }

View File

@@ -73,6 +73,27 @@ async fn open_workspace(
.await
}
#[tauri::command]
async fn close_workspace(root: PathBuf, state: State<'_, AppState>) -> Result<(), String> {
let state = state.inner().clone();
blocking(move || {
let _lifecycle = state.lifecycle.lock().unwrap();
let mut workspace = state.workspace.lock().unwrap();
if workspace.as_ref().is_none_or(|w| w.root != root) {
return Err("Workspace changed.".into());
}
state.search_generation.fetch_add(1, Ordering::Relaxed);
state.explanations.invalidate(true);
if let Some(server) = state.analysis.lock().unwrap().take() {
server.stop();
}
*workspace = None;
*state.analysis_status.lock().unwrap() =
AnalysisStatus::unavailable("Open a project to start analysis.");
Ok(())
})
.await
}
#[tauri::command]
async fn list_directory(path: String, state: State<'_, AppState>) -> Result<Vec<Entry>, String> {
let workspace = state.current()?;
blocking(move || workspace.list(&path)).await
@@ -322,6 +343,7 @@ fn main() {
remove_recent,
inspect_workspace,
open_workspace,
close_workspace,
list_directory,
read_file,
remember_file,

View File

@@ -25,6 +25,7 @@ async function mockDesktop(page: Page) {
case 'remember_recent': return null;
case 'remove_recent': Object.assign(window, { recentProjects: [] }); return null;
case 'inspect_workspace': if ((window as unknown as { missingProject?: boolean }).missingProject) throw new Error('Source directory does not exist.'); return { root: '/sample', settings: state, warning: null, can_reset: true };
case 'close_workspace': return null;
case 'open_workspace': return { root: '/sample', settings: { ...state, build_directory: args.build, last_file: args.reset ? null : state.last_file } };
case 'list_directory': return args.path ? [{ name: 'concepts.hpp', path: 'include/concepts.hpp', directory: false }] : [{ name: 'include', path: 'include', directory: true }, { name: 'main.cpp', path: 'main.cpp', directory: false }];
case 'read_file': return { path: args.path, content };
@@ -38,6 +39,7 @@ async function mockDesktop(page: Page) {
case 'graph_request':
case 'analysis_request':
if (command === 'graph_request' || args.kind === 'conceptgraph') {
if ((window as unknown as {slowGraph?:boolean}).slowGraph) await new Promise(resolve => setTimeout(resolve, 900));
if ((window as unknown as { failGraph?: boolean }).failGraph) throw new Error('Select a concept or function name to view its graph.');
const location = (line: number, label: string) => ({ path: 'main.cpp', range: { start: { line, character: 8 }, end: { line, character: 14 } }, label });
const graph = { title: 'Number', nodes: [
@@ -45,7 +47,15 @@ async function mockDesktop(page: Page) {
{ id: 'requirement', label: 'requires(T value) { value + value; }', kind: 'requires', detail: 'requires(T value) { value + value; }', location: location(3, 'requires') },
{ id: 'concept', label: 'Base<T>', kind: 'concept', detail: 'Base<T>', location: location(2, 'Base') },
{ id: 'use', label: 'main', kind: 'usage', detail: 'used by main.cpp:8', location: location(7, 'main') },
], edges: [{ from: 'concept', to: 'requirement', label: '' }, { from: 'requirement', to: 'selected', label: '' }, { from: 'selected', to: 'use', label: 'used by' }], warnings: ['Active build only.'] }; return command === 'graph_request' ? graph : {graph};
], edges: [{ from: 'concept', to: 'requirement', label: '' }, { from: 'requirement', to: 'selected', label: '' }, { from: 'selected', to: 'use', label: 'used by' }], warnings: ['Active build only.'] };
if ((window as unknown as {functionGraph?:boolean}).functionGraph) { graph.title='target'; graph.nodes[0].label='target'; graph.nodes[1].kind='function'; graph.nodes[1].label='helper'; graph.nodes[2].kind='function'; graph.nodes[2].label='leaf'; graph.nodes[3].kind='function'; graph.nodes[3].label='caller'; }
if ((window as unknown as {typeGraph?:boolean}).typeGraph) {
graph.title='demo::Box'; graph.nodes[0].label='demo::Box';
graph.nodes[1].kind='template-parameter'; graph.nodes[1].label='template T';
graph.nodes[2].kind='type'; graph.nodes[2].label='demo::Base';
graph.nodes[3].kind='function'; graph.nodes[3].label='demo::Box::run';
}
return command === 'graph_request' ? graph : {graph};
}
return {
locations: (window as unknown as { multipleLocations?: boolean }).multipleLocations && ['callers', 'references'].includes(args.kind as string) ? [{ path: 'first.cpp', range: { start: { line: 3, character: 8 }, end: { line: 3, character: 14 } }, label: 'first' }, { path: 'second.cpp', range: { start: { line: 7, character: 4 }, end: { line: 7, character: 8 } }, label: 'second' }] : args.kind === 'folding' || args.kind === 'hover' ? [] : [{ path: args.kind === 'definition' ? 'lib.cpp' : 'main.cpp', range: { start: { line: 3, character: 8 }, end: { line: 3, character: 14 } }, label: args.kind === 'callers' ? 'main' : '' }],
@@ -437,3 +447,105 @@ test('graph options are separate from context and navigation supports history an
await page.locator('#graph-fit').click();
await page.screenshot({path:'test-results/graph-navigation.png'});
});
test('function graphs navigate callees above and callers below using nodes and keyboard', async ({page}) => {
await mockDesktop(page); await page.addInitScript(() => Object.assign(window,{functionGraph:true}));
await openProject(page); await page.locator('#nav-graph').click();
await expect(page.locator('#graph-title')).toHaveText('target');
const root = page.getByRole('button',{name:'selected: target',exact:true});
const caller = page.getByRole('button',{name:'function: caller',exact:true});
const helper = page.getByRole('button',{name:'function: helper',exact:true});
expect((await helper.boundingBox())!.y).toBeLessThan((await root.boundingBox())!.y);
expect((await caller.boundingBox())!.y).toBeGreaterThan((await root.boundingBox())!.y);
await root.focus(); await root.press('ArrowUp');
await expect(helper).toBeFocused();
await page.getByRole('button',{name:'Focus this symbol',exact:true}).click();
await expect(page.locator('#graph-back')).toBeEnabled();
});
test('graph building indicator covers success and failure and clears on return to welcome', async ({page}) => {
await mockDesktop(page); await page.addInitScript(() => Object.assign(window,{slowGraph:true}));
await openProject(page); await page.locator('#nav-graph').click();
await expect(page.locator('#graph-progress')).toBeVisible();
await expect(page.locator('#graph-canvas')).toHaveAttribute('aria-busy','true');
await expect(page.locator('#graph-progress')).toBeHidden();
await page.evaluate(() => Object.assign(window,{failGraph:true}));
await page.locator('#graph-refresh').click();
await expect(page.locator('#graph-progress')).toBeVisible();
await expect(page.locator('#graph-status')).toContainText('Select a concept or function');
await expect(page.locator('#graph-progress')).toBeHidden();
await page.evaluate(() => Object.assign(window,{failGraph:false}));
await page.locator('#graph-refresh').click();
await expect(page.locator('#graph-progress')).toBeVisible();
await page.locator('#file-menu > summary').click();
await page.getByRole('button',{name:'Return to Welcome',exact:true}).click();
await expect(page.locator('#landing')).toBeVisible();
await expect(page.locator('#graph-progress')).toBeHidden();
await expect(page.locator('#explanation-pane')).toBeHidden();
});
test('File menu opens fresh projects, recent projects, and returns to Welcome', async ({page}) => {
await mockDesktop(page);
await page.addInitScript(() => Object.assign(window,{recentProjects:[{root:'/sample',last_opened:1700000000}]}));
await openProject(page);
await page.locator('#file-menu > summary').click();
await page.getByRole('button',{name:'Open Project…',exact:true}).click();
await expect(page.locator('#source')).toHaveValue('');
await expect(page.locator('#build')).toHaveValue('');
await page.locator('#setup-cancel').click();
await expect(page.locator('#file-path')).toHaveText('main.cpp');
await page.locator('#file-menu > summary').click();
await page.locator('#file-recents > summary').click();
await expect(page.locator('#file-recent-list button')).toHaveCount(1);
await page.locator('#file-recent-list button').click();
await expect(page.locator('#setup')).toBeHidden();
await expect(page.locator('#file-menu')).not.toHaveAttribute('open','');
await page.locator('#file-menu > summary').click();
await page.getByRole('button',{name:'Return to Welcome',exact:true}).click();
await expect(page.locator('#landing')).toBeVisible();
await expect(page.locator('#project-name')).toHaveText('No project open');
await expect(page.locator('#toggle-sidebar')).toBeDisabled();
expect(await page.evaluate(() => (window as unknown as {calls:{command:string;args:unknown}[]}).calls.filter(c=>c.command==='close_workspace').at(-1)?.args)).toEqual({root:'/sample'});
await page.locator('.recent-project').click();
await expect(page.locator('#landing')).toBeHidden();
await expect(page.locator('#file-path')).toHaveText('main.cpp');
});
test('both sidebars resize with pointer and keyboard and keep widths across collapse', async ({page}) => {
await mockDesktop(page); await openProject(page); await page.locator('#nav-graph').click();
const left=page.getByRole('separator',{name:'Resize left sidebar'}), right=page.getByRole('separator',{name:'Resize right sidebar'});
const initialLeft=(await page.locator('main > aside').boundingBox())!.width;
const handle=(await left.boundingBox())!;
await page.mouse.move(handle.x+4,handle.y+150);await page.mouse.down();await page.mouse.move(handle.x+64,handle.y+150);await page.mouse.up();
await expect.poll(async()=>(await page.locator('main > aside').boundingBox())!.width).toBeGreaterThan(initialLeft+45);
const initialRight=(await page.locator('#explanation-pane').boundingBox())!.width;
const r=(await right.boundingBox())!;
await page.mouse.move(r.x+4,r.y+150);await page.mouse.down();await page.mouse.move(r.x-46,r.y+150);await page.mouse.up();
await expect.poll(async()=>(await page.locator('#explanation-pane').boundingBox())!.width).toBeGreaterThan(initialRight+35);
await right.focus();await right.press('ArrowLeft');
const width=(await page.locator('#explanation-pane').boundingBox())!.width;
await page.getByRole('button',{name:'Collapse right sidebar'}).click();await page.locator('#toggle-sidebar').click();
expect((await page.locator('#explanation-pane').boundingBox())!.width).toBeCloseTo(width,0);
await page.locator('#graph-expand').click();
expect((await page.locator('#explanation-pane').boundingBox())!.width).toBeGreaterThan(width+100);
await page.locator('#graph-expand').click();
expect((await page.locator('#explanation-pane').boundingBox())!.width).toBeCloseTo(width,0);
await page.setViewportSize({width:950,height:850});
await expect(page.locator('#explanation-pane')).toHaveCSS('position','fixed');
await right.focus();await right.press('ArrowRight');
expect((await page.locator('#explanation-pane').boundingBox())!.width).toBeLessThan(width);
});
test('type graph nodes can be refocused and member nodes remain navigable', async ({page}) => {
await mockDesktop(page);await page.addInitScript(()=>Object.assign(window,{typeGraph:true}));
await openProject(page);await page.locator('#nav-graph').click();
await expect(page.locator('#graph-title')).toHaveText('demo::Box');
await page.getByRole('button',{name:'type: demo::Base',exact:true}).click();
await page.getByRole('button',{name:'Focus this symbol',exact:true}).click();
await expect(page.locator('#graph-back')).toBeEnabled();
await page.getByRole('button',{name:'function: demo::Box::run',exact:true}).click();
await expect(page.getByRole('button',{name:'Open source',exact:true})).toBeVisible();
});