dwarves-gui/src/graph_source.rs
//! **The graph SOURCE pane** — click a node label in a graph, read the source behind it.
//!
//! The owner, 2026-08-25: *"dataview-call edges ser underbar ut. lägg till labels klickbara
//! och då visar du källkoden bakom hela grafen eller i en pane till höger."*
//!
//! ## One question to the room
//! The graph pane is [`facett_graph3d::Graph3D`], mounted by
//! [`facett_warehousedeck::WarehouseDeck`]; the Data view reads back which node was
//! clicked. This pane then ASKS nornir's code room, through the forge, for that node:
//! `Knowledge.Source { moniker }` answers where the item is (repo, file, its whole span)
//! and the TEXT of the span — read by the server from the commit the room was consumed
//! at, so the line numbers are the lines the spans count.
//!
//! Nothing is resolved here and no file is read here. The pane used to keep its own
//! index of `scip_occurrences` / `symbol_facts` rows, pick a definition by name, and
//! read the lines from a checkout on the machine the UI runs on. Those tables have no
//! writer, a name is not an identity, and the laptop's tree is not the server's.
//!
//! ## Node ids are NOT all symbols
//! A node means what its pane says it means:
//!
//! | graph pane | node is | the pane shows |
//! |---|---|---|
//! | the code room's call graph | an item of the room (it carries its moniker) | the item's span and text |
//! | `dep_graph_edges` | a REPO name | that a repo has no single source span |
//!
//! ## States that must never draw alike
//! 1. **nothing selected** — says so and names the gesture that fills it;
//! 2. **asking** — the question is in flight, off the UI thread;
//! 3. **an answer that is not a measurement** (`not_consumed`, `unreachable`, a node with
//! no span …) — the state as a chip and the reader's sentence, no code;
//! 4. **located, text missing** — the file and span, and why the text could not be read;
//! 5. **source** — the only state that paints code, under `repo/file:start-end @ commit`.
use std::sync::Arc;
use crate::room::{self, Pending, RoomAnswer, RoomSource};
/// The surface this pane asks.
pub const SURFACE: &str = "Knowledge.Source";
/// Lines of source the pane asks for per item. A span longer than this comes back cut,
/// and the answer says so (`truncated`) — the pane prints it.
pub const MAX_SLICE_LINES: usize = 400;
/// Default width of the right-hand source panel, in points. Resizable from
/// [`WIDTH_RANGE`] — the owner asked for "att fönstret kan bli större".
pub const DEFAULT_WIDTH: f32 = 460.0;
/// The drag range of the source panel.
pub const WIDTH_RANGE: std::ops::RangeInclusive<f32> = 260.0..=1400.0;
/// What KIND of thing a graph pane's node labels are. Derived from the pane's source,
/// never guessed from the label's own text.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NodeKind {
/// An item of the code room (the call graph).
Symbol,
/// A repo name (`dep_graph_edges`).
Repo,
}
/// The node kind a warehouse edge TABLE's labels carry, or `None` for a table this pane
/// has no reading of. (The code room's call graph is not a table: its nodes are
/// [`NodeKind::Symbol`] by construction.)
pub fn node_kind_for(table: &str) -> Option<NodeKind> {
match table {
"dep_graph_edges" => Some(NodeKind::Repo),
_ => None,
}
}
/// One clicked node: its label, the room's id for it when it has one, and what it is.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NodeRef {
pub label: String,
/// The room's moniker of the item (`None` for a node that is not an item).
pub moniker: Option<String>,
pub kind: NodeKind,
}
/// What the pane is showing for the selection.
#[derive(Debug, Clone, PartialEq)]
pub enum Resolution {
/// No node has been clicked yet.
Idle,
/// A repo node: there is no single source span to show.
RepoNode { node: String },
/// The question is in flight.
Asking { node: String },
/// The room's answer (any state).
Answered { node: String, answer: RoomAnswer },
}
impl Resolution {
/// A stable state name for the robot oracle + the render gate.
pub fn kind(&self) -> String {
match self {
Resolution::Idle => "idle".into(),
Resolution::RepoNode { .. } => "repo_node".into(),
Resolution::Asking { .. } => "asking".into(),
Resolution::Answered { answer, .. } if !answer.is_measured() => answer.state.clone(),
Resolution::Answered { answer, .. } if answer.rows("lines").is_empty() => {
"located_no_text".into()
}
Resolution::Answered { .. } => "source".into(),
}
}
/// The selected node, if any.
pub fn node(&self) -> Option<&str> {
match self {
Resolution::Idle => None,
Resolution::RepoNode { node }
| Resolution::Asking { node }
| Resolution::Answered { node, .. } => Some(node),
}
}
}
/// `repo/file:start-end @ commit` of an answer that located the item.
pub fn heading(a: &RoomAnswer) -> Option<String> {
let b = &a.body;
let (repo, file) = (b["repo"].as_str()?, b["file"].as_str()?);
let (start, end) = (
b["start_line"].as_u64()?,
b["end_line"].as_u64().unwrap_or(0),
);
let span = if end > start {
format!("{start}-{end}")
} else {
start.to_string()
};
Some(match b["sha"].as_str() {
Some(sha) => format!("{repo}/{file}:{span} @ {}", &sha[..sha.len().min(10)]),
None => format!("{repo}/{file}:{span}"),
})
}
// ── the pane ──────────────────────────────────────────────────────────────────
/// **The right-hand source pane.** Holds the selection and the room's answer for it;
/// [`GraphSourcePane::view`] paints it and [`GraphSourcePane::show_beside`] puts it in a
/// RESIZABLE right panel with the graph wall on its left.
pub struct GraphSourcePane {
/// Where the pane asks. `None` ⇒ the host gave it no room: a selection then says so.
room: Option<Arc<dyn RoomSource>>,
selection: Option<NodeRef>,
resolution: Resolution,
pending: Option<Pending<RoomAnswer>>,
/// Panel width in points — observable so a test can prove the drag range is live.
width: f32,
/// The repo the ask is scoped to (`Knowledge.Source { repo }`) — set by
/// [`select_scoped`](Self::select_scoped); `None` for a plain [`select`](Self::select).
scope_repo: Option<String>,
/// Inclusive line ranges (file lines) drawn HIGHLIGHTED — a group twin's
/// statement group inside its function. Empty = nothing highlighted.
highlight: Vec<(u64, u64)>,
/// Scroll the first highlighted line into view on the next paint.
scroll_pending: bool,
}
impl Default for GraphSourcePane {
fn default() -> Self {
Self::new()
}
}
impl GraphSourcePane {
pub fn new() -> Self {
Self {
room: None,
selection: None,
resolution: Resolution::Idle,
pending: None,
width: DEFAULT_WIDTH,
scope_repo: None,
highlight: Vec::new(),
scroll_pending: false,
}
}
/// The pane asking `room`.
pub fn with_room(mut self, room: Arc<dyn RoomSource>) -> Self {
self.room = Some(room);
self
}
/// The wall was read again (a new warehouse generation): the answer on screen is
/// about the previous state, so the selection is asked again.
pub fn reread(&mut self) {
if let Some(sel) = self.selection.take() {
self.pending = None;
self.resolution = Resolution::Idle;
self.select(sel);
}
}
/// **The click.** Select `node` and ask the room for its source, off the UI thread.
/// Selecting the same node twice is idempotent, so the deck poll may call this every
/// frame without a second question.
pub fn select(&mut self, node: NodeRef) {
if self.selection.as_ref() == Some(&node) && self.scope_repo.is_none() && self.highlight.is_empty() {
return;
}
self.scope_repo = None;
self.highlight.clear();
self.scroll_pending = false;
self.ask(node);
}
/// **The click, scoped**: ask for `node` IN `repo` (the server opens that repo's
/// room, so a path names the right item) and draw `highlight` (inclusive file
/// line ranges) marked, scrolled to its first line. Idempotent like
/// [`select`](Self::select).
pub fn select_scoped(&mut self, node: NodeRef, repo: Option<String>, highlight: Vec<(u64, u64)>) {
if self.selection.as_ref() == Some(&node) && self.scope_repo == repo && self.highlight == highlight {
return;
}
self.scope_repo = repo;
self.scroll_pending = !highlight.is_empty();
self.highlight = highlight;
self.ask(node);
}
/// Is file line `line` in a highlighted range?
pub fn is_highlighted(&self, line: u64) -> bool {
self.highlight.iter().any(|(a, b)| (*a..=*b).contains(&line))
}
fn ask(&mut self, node: NodeRef) {
self.pending = None;
self.resolution = match (&node.kind, &self.room) {
(NodeKind::Repo, _) => Resolution::RepoNode {
node: node.label.clone(),
},
(NodeKind::Symbol, None) => Resolution::Answered {
node: node.label.clone(),
answer: {
let mut a = RoomAnswer::pending(SURFACE, "(no room source)");
a.note = "this pane was given no code room to ask".to_string();
a
},
},
(NodeKind::Symbol, Some(room)) => {
let (room, ask, repo) = (Arc::clone(room), node.clone(), self.scope_repo.clone());
self.pending = Some(Pending::spawn("graph-source-ask", move || {
let mut params = match &ask.moniker {
Some(m) => {
serde_json::json!({ "moniker": m, "max_lines": MAX_SLICE_LINES })
}
None => {
serde_json::json!({ "name": ask.label, "max_lines": MAX_SLICE_LINES })
}
};
if let Some(r) = repo {
params["repo"] = serde_json::json!(r);
}
room.ask(SURFACE, ¶ms)
}));
Resolution::Asking {
node: node.label.clone(),
}
}
};
self.selection = Some(node);
}
/// Clear the selection (a click on empty canvas).
pub fn clear(&mut self) {
self.selection = None;
self.pending = None;
self.resolution = Resolution::Idle;
}
fn land(&mut self, res: Result<RoomAnswer, ()>) {
let Some(sel) = &self.selection else { return };
let answer = res.unwrap_or_else(|()| match &self.room {
Some(r) => room::worker_died(SURFACE, &**r),
None => RoomAnswer::pending(SURFACE, "(no room source)"),
});
self.resolution = Resolution::Answered {
node: sel.label.clone(),
answer,
};
}
/// Non-blocking: land the in-flight answer if the worker finished. Called once/frame.
pub fn poll(&mut self) {
let Some(p) = &self.pending else { return };
if let Some(res) = p.try_take() {
self.pending = None;
self.land(res);
}
}
/// Blocking: wait for the in-flight answer (a scripted caller; the window never does).
pub fn wait(&mut self) {
if let Some(p) = self.pending.take() {
let res = p.wait();
self.land(res);
}
}
pub fn is_pending(&self) -> bool {
self.pending.is_some()
}
pub fn resolution(&self) -> &Resolution {
&self.resolution
}
/// The panel's current width in points.
pub fn width(&self) -> f32 {
self.width
}
/// The robot / test oracle. Every branch the render can take is readable as data.
pub fn state_json(&self) -> serde_json::Value {
let answer = match &self.resolution {
Resolution::Answered { answer, .. } => Some(answer),
_ => None,
};
let lines = answer.map(|a| a.rows("lines")).unwrap_or(&[]);
let first = answer.and_then(|a| a.body["start_line"].as_u64()).unwrap_or(1);
let highlighted = answer
.filter(|a| a.is_measured())
.map(|_| (0..lines.len() as u64).filter(|i| self.is_highlighted(first + i)).count());
serde_json::json!({
"kind": "graph_source",
"state": self.resolution.kind(),
"node": self.resolution.node(),
"moniker": self.selection.as_ref().and_then(|s| s.moniker.clone()),
// where the room says the item is (null until an answer located it)
"heading": answer.and_then(heading),
"repo": answer.and_then(|a| a.body["repo"].as_str()),
"file": answer.and_then(|a| a.body["file"].as_str()),
"span": answer.and_then(|a| Some([a.body["start_line"].as_u64()?, a.body["end_line"].as_u64()?])),
"sha": answer.and_then(|a| a.body["sha"].as_str()),
"path": answer.and_then(|a| a.body["node"]["path"].as_str()),
// the text as the server read it: how many lines, and the first and last
"lines": answer.filter(|a| a.is_measured()).map(|_| lines.len()),
"first_line": lines.first().and_then(|l| l.as_str()),
"last_line": lines.last().and_then(|l| l.as_str()),
"truncated": answer.is_some_and(|a| a.body["truncated"].as_bool() == Some(true)),
"room": answer.map(RoomAnswer::state_json),
"pending": self.pending.is_some(),
"width": self.width,
// the repo the ask was scoped to, and the marked lines (a group twin's group)
"repo_scope": self.scope_repo,
"highlight": self.highlight.iter().map(|(a, b)| [*a, *b]).collect::<Vec<_>>(),
"highlighted_lines": highlighted,
})
}
/// The id of the right-hand panel inside `ui`. ONE writer, so the render gate asks for
/// the resize handle under exactly the id [`show_beside`](Self::show_beside) creates —
/// a test that spells the id again is a test of its own spelling.
pub fn panel_id(ui: &egui::Ui) -> egui::Id {
ui.id().with("graph_source_panel")
}
/// The id egui registers the panel's DRAG HANDLE under. Present in a frame only when
/// the panel was shown `.resizable(true)`; that is what the gate asserts.
pub fn resize_handle_id(ui: &egui::Ui) -> egui::Id {
Self::panel_id(ui).with("__resize")
}
/// **The split.** Paints `graph` on the left and this pane in a RESIZABLE right panel.
/// `egui::Panel::show_inside` is deprecated in egui 0.35 (renamed to `show`), so this
/// uses the current name — the left half is a `CentralPanel`, which is what makes the
/// divider a real drag handle rather than a fixed column.
pub fn show_beside(&mut self, ui: &mut egui::Ui, graph: impl FnOnce(&mut egui::Ui)) {
let id = Self::panel_id(ui);
let inner = egui::Panel::right(id)
.resizable(true)
.default_size(self.width)
.size_range(WIDTH_RANGE)
.show(ui, |ui| {
self.view(ui);
ui.min_rect().width()
});
self.width = inner.inner.max(*WIDTH_RANGE.start());
egui::CentralPanel::default().show(ui, graph);
}
/// Paint the pane's current state — see the module header for the states.
pub fn view(&mut self, ui: &mut egui::Ui) {
self.poll();
if self.pending.is_some() {
ui.ctx().request_repaint();
}
ui.horizontal_wrapped(|ui| {
ui.strong("📄 source");
ui.label(
egui::RichText::new("click a node label in a graph pane")
.small()
.weak(),
);
});
ui.separator();
match &self.resolution {
Resolution::Idle => {
ui.label(
egui::RichText::new(NO_SELECTION)
.italics()
.color(ui.visuals().weak_text_color()),
);
}
Resolution::RepoNode { node } => {
ui.colored_label(
egui::Color32::from_rgb(0xc8, 0x8a, 0x22),
format!("{REPO_NODE} — {node}"),
);
}
Resolution::Asking { node } => {
ui.horizontal(|ui| {
ui.spinner();
ui.label(format!("{ASKING} — {node}"));
});
}
Resolution::Answered { node, answer } => {
ui.label(egui::RichText::new(node).small().monospace());
if let Some(h) = heading(answer) {
ui.label(egui::RichText::new(h).monospace().strong());
}
// what the answer rests on: state, evidence, the room it was read from
room::callout(ui, "source", answer);
let lines = answer.rows("lines");
if !answer.is_measured() || lines.is_empty() {
return;
}
let candidates = answer.body["candidates"].as_array().map_or(0, Vec::len);
if candidates > 1 {
ui.label(
egui::RichText::new(format!("1 of {candidates} nodes share this name — showing the first definition with a span"))
.small()
.color(egui::Color32::from_rgb(0xc8, 0x8a, 0x22)),
);
}
ui.separator();
let first = answer.body["start_line"].as_u64().unwrap_or(1);
let width = ui.available_width();
let mark = egui::Color32::from_rgba_unmultiplied(0xc8, 0x8a, 0x22, 0x50);
let mut scroll = self.scroll_pending;
egui::ScrollArea::both()
.auto_shrink([false, false])
.show(ui, |ui| {
ui.set_min_width(width);
for (i, text) in lines.iter().enumerate() {
let n = first + i as u64;
let lit = self.highlight.iter().any(|(a, b)| (*a..=*b).contains(&n));
let row = ui.horizontal(|ui| {
let mut num = egui::RichText::new(format!("{n:>5}")).monospace();
let mut code = egui::RichText::new(text.as_str().unwrap_or("")).monospace();
if lit {
num = num.strong().background_color(mark);
code = code.background_color(mark);
} else {
num = num.weak();
}
ui.label(num);
ui.label(code);
});
if lit && scroll {
row.response.scroll_to_me(Some(egui::Align::Center));
scroll = false;
}
}
});
self.scroll_pending = scroll;
}
}
}
}
/// The non-source headings. Public because the render gate asserts on the exact strings
/// the AccessKit tree carries.
pub const NO_SELECTION: &str = "no node selected";
pub const ASKING: &str = "asking the code room for the source";
pub const REPO_NODE: &str =
"a repo node — a repo has no single source span; click an item of the code room's call graph";
#[cfg(test)]
mod tests {
use super::*;
use crate::room::testing::{refusal, room as room_id, CannedRoom};
use serde_json::json;
const MONIKER: &str = "rust-analyzer cargo shop 0.1.0 pay/charge().";
fn located() -> serde_json::Value {
json!({
"state": "complete",
"node": { "path": "shop::pay::charge", "moniker": MONIKER, "kind": "fn", "crate": "shop", "repo": "shop", "file": "src/pay.rs", "line": 7, "end_line": 9, "defined": true },
"candidates": [{ "path": "shop::pay::charge" }],
"repo": "shop", "file": "src/pay.rs", "start_line": 7, "end_line": 9, "sha": "0123456789abcdef0123",
"lines": ["pub fn charge(cart: &Cart) -> Receipt {", " audit(cart);", "}"],
"file_lines": 48, "truncated": false,
"evidence": { "missing": [], "notes": [] },
"room": room_id("w", "shop", 2, 31, 60),
})
}
fn node() -> NodeRef {
NodeRef {
label: "shop::pay::charge".into(),
moniker: Some(MONIKER.into()),
kind: NodeKind::Symbol,
}
}
fn pane(src: &Arc<CannedRoom>) -> GraphSourcePane {
GraphSourcePane::new().with_room(src.clone())
}
/// The pane shows the SERVER's span and text for the clicked item — asked by the
/// item's moniker, not by its name.
#[test]
fn a_clicked_item_shows_the_rooms_span_and_text() {
let src = CannedRoom::new(&[(SURFACE, located())]);
let mut p = pane(&src);
assert_eq!(p.state_json()["state"], "idle");
p.select(node());
assert_eq!(p.state_json()["state"], "asking");
p.wait();
let s = p.state_json();
assert_eq!(s["state"], "source");
assert_eq!(s["heading"], "shop/src/pay.rs:7-9 @ 0123456789");
assert_eq!(
(
s["file"].as_str(),
s["span"][0].as_u64(),
s["span"][1].as_u64(),
s["lines"].as_u64()
),
(Some("src/pay.rs"), Some(7), Some(9), Some(3))
);
assert_eq!(
(s["first_line"].as_str(), s["last_line"].as_str()),
(Some("pub fn charge(cart: &Cart) -> Receipt {"), Some("}"))
);
assert_eq!(
s["room"]["room"],
"workspace w · shop@2 (0123456789) · 31 nodes · 60 edges"
);
assert_eq!(
src.asked.lock().unwrap().as_slice(),
&[(
SURFACE.to_string(),
json!({ "moniker": MONIKER, "max_lines": MAX_SLICE_LINES })
)]
);
// the same node again is not a second question
p.select(node());
assert_eq!(src.asked.lock().unwrap().len(), 1);
}
/// Located, text missing (the server could not read the file at that commit): the
/// span is shown, the reader's sentence says why there is no text, and no line is
/// invented.
#[test]
fn a_located_item_without_text_says_why() {
let mut body = located();
body["state"] = json!("partial");
body["lines"] = json!([]);
body["evidence"] = json!({ "missing": [{ "input": "source text", "repo": "shop", "why": "`src/pay.rs` at 0123456789 could not be read from the consumer's clone" }], "notes": [] });
let src = CannedRoom::new(&[(SURFACE, body)]);
let mut p = pane(&src);
p.select(node());
p.wait();
let s = p.state_json();
assert_eq!(
(
s["state"].as_str(),
s["lines"].as_u64(),
s["heading"].as_str()
),
(
Some("located_no_text"),
Some(0),
Some("shop/src/pay.rs:7-9 @ 0123456789")
)
);
assert!(
s["room"]["evidence"]
.as_str()
.unwrap()
.contains("could not be read"),
"{s}"
);
assert!(s["first_line"].is_null());
}
/// A refusal, a node with no span and an unreachable server are each their own
/// state; none shows a location or a line.
#[test]
fn an_answer_that_is_not_a_measurement_shows_its_state_and_no_code() {
for (body, want) in [
(
refusal("not_consumed", "repo `shop` has no consumer offset"),
"not_consumed",
),
(
json!({ "state": "empty_gap", "node": {}, "candidates": [], "lines": [], "evidence": { "missing": [{ "input": "span", "repo": null, "why": "a reference stub: there is no file to show" }], "notes": [] } }),
"empty_gap",
),
] {
let src = CannedRoom::new(&[(SURFACE, body)]);
let mut p = pane(&src);
p.select(node());
p.wait();
let s = p.state_json();
assert_eq!(s["state"], want);
assert!(
s["heading"].is_null() && s["first_line"].is_null() && s["lines"].is_null(),
"{s}"
);
}
}
/// A repo node is not asked about at all, and says what it is.
#[test]
fn a_repo_node_is_not_an_item() {
let src = CannedRoom::new(&[(SURFACE, located())]);
let mut p = pane(&src);
p.select(NodeRef {
label: "znippy".into(),
moniker: None,
kind: NodeKind::Repo,
});
assert_eq!(p.state_json()["state"], "repo_node");
assert!(src.asked.lock().unwrap().is_empty());
p.clear();
assert_eq!(p.state_json()["state"], "idle");
}
/// The rendered surface carries the state's own words (AccessKit), and the code only
/// in the source state.
#[test]
fn the_rendered_pane_says_its_state() {
use egui_kittest::kittest::Queryable;
use egui_kittest::Harness;
let src = CannedRoom::new(&[(SURFACE, located())]);
let p = std::cell::RefCell::new(pane(&src));
let mut h = Harness::builder()
.with_size(egui::vec2(700.0, 500.0))
.build_ui(|ui| p.borrow_mut().view(ui));
h.run();
assert!(h.query_by_label(NO_SELECTION).is_some());
p.borrow_mut().select(node());
p.borrow_mut().wait();
h.run();
assert!(
h.query_by_label("shop/src/pay.rs:7-9 @ 0123456789")
.is_some(),
"the heading is drawn"
);
assert!(
h.query_by_label(" audit(cart);").is_some(),
"the server's text is drawn"
);
assert!(h.query_by_label(NO_SELECTION).is_none());
}
/// The only table whose labels this pane reads as nodes is the repo graph; the call
/// graph is the room's, not a table.
#[test]
fn only_the_repo_graph_table_has_a_node_kind() {
assert_eq!(node_kind_for("dep_graph_edges"), Some(NodeKind::Repo));
for t in [
"call_edges",
"symbol_facts",
"scip_occurrences",
"git_heat_facts",
] {
assert_eq!(node_kind_for(t), None, "{t}");
}
}
}