struct graph
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
mod constraints;
|
||||
mod graph;
|
||||
mod relationships;
|
||||
mod types;
|
||||
pub use graph::ConceptGraph;
|
||||
pub use relationships::GraphOptions;
|
||||
mod transport;
|
||||
|
||||
@@ -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,11 +240,13 @@ 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
|
||||
let symbol_node = matches!(
|
||||
node.kind.as_str(),
|
||||
"concept" | "function" | "type" | "field" | "usage"
|
||||
) && node.label
|
||||
!= format!(
|
||||
"{}:{}",
|
||||
node.location.path,
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
460
crates/cex-core/src/analysis/types.rs
Normal file
460
crates/cex-core/src/analysis/types.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
5
examples/types/CMakeLists.txt
Normal file
5
examples/types/CMakeLists.txt
Normal 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
2
examples/types/main.cpp
Normal file
@@ -0,0 +1,2 @@
|
||||
#include "types.hpp"
|
||||
int main() { demo::Box<int> box(1); box.run(2); }
|
||||
2
examples/types/meson.build
Normal file
2
examples/types/meson.build
Normal 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
25
examples/types/types.hpp
Normal 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 };
|
||||
}
|
||||
@@ -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';} };
|
||||
}
|
||||
|
||||
@@ -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
46
frontend/resize.ts
Normal 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();
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user