feat: add source and actor trees to world side panels

Replace behavior dropdowns with a hierarchical source browser on the left and actor inspection files on the right. Preserve draft guards and cover tree navigation with unit and rendered click tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-11-05 09:04:53 +01:00
parent e446d43b91
commit 35902b6b4d
4 changed files with 356 additions and 53 deletions

View File

@ -18,6 +18,9 @@ All notable changes to this project will be documented in this file.
### Changed
- Replace the graphical client's behavior dropdowns with a left-hand source file
tree and a right-hand actor tree with metadata, state, mailbox, log, and behavior
entries.
- Pretty-print actor state and mailbox JSON with two-space indentation while
preserving exact integers and actor-reference markers.
- Rename the actor's virtual log file to `actor.log` for automatic VS Code log

View File

@ -86,7 +86,11 @@ The world client connects to `127.0.0.1:3737` by default. Set `RUNA_CONNECT` or
pass `--connect 127.0.0.1:4000` to select another loopback address. It provides
actor browsing and inspection, message sending, lifecycle controls, explicit
scheduler ticks, bounded debugger stepping, source breakpoints,
execution-stack inspection, and retained logs. Its source view supports
execution-stack inspection, and retained logs. A resizable source file tree on
the left opens behavior files and selects the source used by Spawn. A resizable
actor tree on the right expands actors into metadata/inspector, state, mailbox,
log, and behavior entries, opening the corresponding view in the center.
Its source view supports
editing image sources with optimistic conflict checks, compilation diagnostics,
explicit history commits, and revision preview/rollback. Rollback requires
confirmation and creates a new current source version without deleting history.

View File

@ -29,7 +29,12 @@ fn main() -> eframe::Result<()> {
let args = Args::parse();
eframe::run_native(
"Runa World",
eframe::NativeOptions::default(),
eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([1280.0, 800.0])
.with_min_inner_size([960.0, 600.0]),
..Default::default()
},
Box::new(move |creation| Ok(Box::new(WorldApp::new(creation, args.connect)))),
)
}
@ -82,6 +87,58 @@ enum WorldView {
Events,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum ActorFile {
Metadata,
State,
Mailbox,
Logs,
Behavior,
}
#[derive(Default)]
struct SourceTree {
directories: BTreeMap<String, SourceTree>,
files: BTreeMap<String, String>,
}
impl SourceTree {
fn insert(&mut self, path: &str, full_path: &str) {
if let Some((directory, remainder)) = path.split_once('/') {
self.directories
.entry(directory.to_owned())
.or_default()
.insert(remainder, full_path);
} else {
self.files.insert(path.to_owned(), full_path.to_owned());
}
}
fn show(&self, ui: &mut egui::Ui, selected: Option<&str>) -> Option<String> {
let mut clicked = None;
for (name, directory) in &self.directories {
ui.push_id(name, |ui| {
egui::CollapsingHeader::new(name)
.default_open(true)
.show(ui, |ui| {
if let Some(path) = directory.show(ui, selected) {
clicked = Some(path);
}
});
});
}
for (name, path) in &self.files {
if ui
.selectable_label(selected == Some(path.as_str()), name)
.clicked()
{
clicked = Some(path.clone());
}
}
clicked
}
}
struct WorkerSnapshot {
actors: Vec<ActorSnapshotDto>,
sources: Vec<String>,
@ -101,6 +158,7 @@ struct WorldApp {
sources: Vec<String>,
source_ids: BTreeMap<String, String>,
selected_actor: Option<String>,
actor_file: ActorFile,
actor: Option<ActorSnapshotDto>,
execution: Option<ExecutionInspectionDto>,
logs: Vec<LogEntry>,
@ -150,6 +208,7 @@ impl WorldApp {
sources: Vec::new(),
source_ids: BTreeMap::new(),
selected_actor: None,
actor_file: ActorFile::Metadata,
actor: None,
execution: None,
logs: Vec::new(),
@ -196,6 +255,7 @@ impl WorldApp {
return;
}
self.selected_actor = actor_ref.clone();
self.actor_file = ActorFile::Metadata;
self.actor = None;
self.execution = None;
self.logs.clear();
@ -208,11 +268,17 @@ impl WorldApp {
}
fn select_source(&mut self, path: &str) {
if self.source_path() == Some(path) || self.loading_source.as_deref() == Some(path) {
self.view = WorldView::Sources;
return;
}
if self.source_dirty {
self.notice =
"Save or discard the current source draft before opening another file".to_owned();
return;
}
self.view = WorldView::Sources;
self.spawn_behavior = path.to_owned();
self.source = None;
self.loading_source = Some(path.to_owned());
self.source_diagnostics.clear();
@ -465,24 +531,37 @@ impl WorldApp {
}
}
fn draw_actor_list(&mut self, ui: &mut egui::Ui) {
ui.heading("Actors");
ui.horizontal(|ui| {
ui.label("Sources");
egui::ComboBox::from_id_salt("spawn_behavior")
.selected_text(if self.spawn_behavior.is_empty() {
"Choose behavior"
} else {
&self.spawn_behavior
})
.show_ui(ui, |ui| {
for path in &self.sources {
ui.selectable_value(&mut self.spawn_behavior, path.clone(), path);
}
});
fn draw_source_tree(&mut self, ui: &mut egui::Ui) {
ui.heading("Sources");
let mut tree = SourceTree::default();
for path in &self.sources {
tree.insert(path, path);
}
egui::ScrollArea::vertical()
.id_salt("source_tree")
.max_height((ui.available_height() - 150.0).max(100.0))
.show(ui, |ui| {
if self.sources.is_empty() {
ui.label("No image sources.");
}
let selected = self.loading_source.as_deref().or(self.source_path());
if let Some(path) = tree.show(ui, selected) {
self.select_source(&path);
}
});
ui.separator();
ui.label(if self.spawn_behavior.is_empty() {
"Select a source to spawn an actor"
} else {
&self.spawn_behavior
});
ui.label("Initial state (JSON)");
ui.text_edit_singleline(&mut self.spawn_state);
ui.horizontal(|ui| {
if ui.button("Spawn").clicked() {
if ui
.add_enabled(!self.spawn_behavior.is_empty(), egui::Button::new("Spawn"))
.clicked()
{
match parse_value(&self.spawn_state) {
Ok(state) if !self.spawn_behavior.trim().is_empty() => {
self.submit(Command::Spawn {
@ -496,11 +575,11 @@ impl WorldApp {
}
}
});
ui.horizontal(|ui| {
ui.label("Initial state");
ui.text_edit_singleline(&mut self.spawn_state);
});
ui.horizontal(|ui| {
}
fn draw_actor_list(&mut self, ui: &mut egui::Ui) {
ui.heading("Actors");
ui.collapsing("Scheduler", |ui| {
ui.label("Scheduler ticks");
ui.text_edit_singleline(&mut self.scheduler_ticks);
ui.label("Instruction budget");
@ -524,21 +603,93 @@ impl WorldApp {
}
});
ui.separator();
egui::ScrollArea::vertical().show(ui, |ui| {
let actors = self.actors.clone();
for actor in actors {
let label = format!("{} · {}", actor.actor_ref, status_label(&actor.status));
if ui
.selectable_label(
self.selected_actor.as_deref() == Some(&actor.actor_ref),
label,
)
.clicked()
{
self.choose_actor(Some(actor.actor_ref));
egui::ScrollArea::vertical()
.id_salt("actor_tree")
.show(ui, |ui| {
if self.actors.is_empty() {
ui.label("No running actors.");
}
let actors = self.actors.clone();
for actor in actors {
let label = format!("{} · {}", actor.actor_ref, status_label(&actor.status));
let response = egui::CollapsingHeader::new(label)
.id_salt(&actor.actor_ref)
.show(ui, |ui| {
for (name, file) in [
("meta.json", ActorFile::Metadata),
("state.json", ActorFile::State),
("mailbox.json", ActorFile::Mailbox),
("actor.log", ActorFile::Logs),
("behavior", ActorFile::Behavior),
] {
if ui
.selectable_label(
self.selected_actor.as_deref() == Some(&actor.actor_ref)
&& self.actor_file == file
&& self.view == WorldView::Actors,
name,
)
.clicked()
{
self.open_actor_file(&actor.actor_ref, file);
}
}
});
if response.header_response.clicked() {
self.open_actor_file(&actor.actor_ref, ActorFile::Metadata);
}
}
});
}
fn open_actor_file(&mut self, actor_ref: &str, file: ActorFile) {
self.choose_actor(Some(actor_ref.to_owned()));
if self.selected_actor.as_deref() != Some(actor_ref) {
return;
}
self.actor_file = file;
self.view = WorldView::Actors;
if file == ActorFile::Behavior && self.actor_behavior.is_none() {
self.submit(Command::ActorBehavior {
actor_ref: actor_ref.to_owned(),
});
}
}
fn draw_actor_file(&mut self, ui: &mut egui::Ui) {
if self.actor_file == ActorFile::Metadata {
self.draw_inspector(ui);
return;
}
let Some(actor) = self.actor.clone() else {
ui.label("Loading actor...");
return;
};
ui.heading(format!("Actor {}", actor.actor_ref));
match self.actor_file {
ActorFile::Metadata => unreachable!(),
ActorFile::State => {
ui.heading("state.json");
show_json(ui, &actor.state);
}
});
ActorFile::Mailbox => {
ui.heading("mailbox.json");
if let Some(message) = &actor.reserved {
ui.label("Reserved");
show_json(ui, message);
}
for (index, message) in actor.queued.iter().enumerate() {
ui.label(format!("Queued {index}"));
show_json(ui, message);
}
if actor.reserved.is_none() && actor.queued.is_empty() {
ui.label("Mailbox is empty.");
}
self.draw_message_sender(ui, &actor.actor_ref);
}
ActorFile::Logs => self.draw_logs(ui, &actor.actor_ref),
ActorFile::Behavior => self.draw_actor_behavior_editor(ui, &actor),
}
}
fn draw_inspector(&mut self, ui: &mut egui::Ui) {
@ -854,21 +1005,10 @@ impl WorldApp {
fn draw_source_editor(&mut self, context: &egui::Context, ui: &mut egui::Ui) {
ui.heading("Source editor");
ui.horizontal(|ui| {
ui.label("Source file");
let selected = self.source_path().unwrap_or("Choose a source");
egui::ComboBox::from_id_salt("source_file")
.selected_text(selected)
.show_ui(ui, |ui| {
let paths = self.sources.clone();
for path in paths {
if ui
.selectable_label(self.source_path() == Some(&path), &path)
.clicked()
{
self.select_source(&path);
}
}
});
ui.label(
self.source_path()
.unwrap_or("Choose a file in the Sources tree"),
);
if self.source_dirty {
ui.colored_label(egui::Color32::YELLOW, "Unsaved draft");
if ui.button("Discard draft").clicked() {
@ -1061,7 +1201,11 @@ impl eframe::App for WorldApp {
fn update(&mut self, context: &egui::Context, _frame: &mut eframe::Frame) {
self.read_worker_updates();
egui::TopBottomPanel::top("connection").show(context, |ui| self.draw_connection(ui));
egui::SidePanel::left("actor_list")
egui::SidePanel::left("source_tree_panel")
.resizable(true)
.default_width(260.0)
.show(context, |ui| self.draw_source_tree(ui));
egui::SidePanel::right("actor_tree_panel")
.resizable(true)
.default_width(340.0)
.show(context, |ui| self.draw_actor_list(ui));
@ -1069,7 +1213,7 @@ impl eframe::App for WorldApp {
self.draw_view_picker(ui);
ui.separator();
match self.view {
WorldView::Actors => self.draw_inspector(ui),
WorldView::Actors => self.draw_actor_file(ui),
WorldView::Sources => self.draw_source_editor(context, ui),
WorldView::Events => self.draw_events(ui),
}
@ -1662,6 +1806,7 @@ mod tests {
sources: Vec::new(),
source_ids: BTreeMap::new(),
selected_actor: None,
actor_file: ActorFile::Metadata,
actor: None,
execution: None,
logs: Vec::new(),
@ -1864,4 +2009,151 @@ mod tests {
app.read_worker_updates();
assert_eq!(app.missed_events, 6);
}
#[test]
fn source_tree_preserves_nested_paths_and_same_named_files() {
let mut tree = SourceTree::default();
for path in [
"actors/counter.rn",
"tools/counter.rn",
"main.rn",
"actors/nested/log.rn",
] {
tree.insert(path, path);
}
assert_eq!(tree.files["main.rn"], "main.rn");
assert_eq!(
tree.directories["actors"].files["counter.rn"],
"actors/counter.rn"
);
assert_eq!(
tree.directories["tools"].files["counter.rn"],
"tools/counter.rn"
);
assert_eq!(
tree.directories["actors"].directories["nested"].files["log.rn"],
"actors/nested/log.rn"
);
}
#[test]
fn source_tree_selection_opens_editor_and_selects_spawn_behavior() {
let (mut app, requests, _) = test_app();
app.select_source("actors/counter.rn");
assert!(app.view == WorldView::Sources);
assert_eq!(app.spawn_behavior, "actors/counter.rn");
assert_eq!(app.loading_source.as_deref(), Some("actors/counter.rn"));
assert!(matches!(
requests.try_recv(),
Ok(WorkerRequest::Command(Command::ReadSource { path }))
if path == "actors/counter.rn"
));
assert!(matches!(
requests.try_recv(),
Ok(WorkerRequest::Command(Command::SourceHistory { path }))
if path == "actors/counter.rn"
));
app.source_dirty = true;
app.select_source("actors/other.rn");
assert_eq!(app.spawn_behavior, "actors/counter.rn");
assert!(requests.try_recv().is_err());
assert!(app.notice.contains("Save or discard"));
}
#[test]
fn clicking_a_rendered_source_file_opens_its_full_path() {
let (mut app, requests, _) = test_app();
app.sources = vec!["actors/counter.rn".to_owned()];
let context = egui::Context::default();
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(1280.0, 800.0),
)),
..Default::default()
};
let output = context.run(input.clone(), |context| {
egui::SidePanel::left("test_sources").show(context, |ui| app.draw_source_tree(ui));
});
let position = output
.shapes
.iter()
.find_map(|shape| {
if let egui::Shape::Text(text) = &shape.shape
&& text.galley.job.text == "counter.rn"
{
Some(text.pos + text.galley.size() / 2.0)
} else {
None
}
})
.expect("source tree should render the filename");
let _ = context.run(
egui::RawInput {
events: vec![
egui::Event::PointerMoved(position),
egui::Event::PointerButton {
pos: position,
button: egui::PointerButton::Primary,
pressed: true,
modifiers: egui::Modifiers::NONE,
},
egui::Event::PointerButton {
pos: position,
button: egui::PointerButton::Primary,
pressed: false,
modifiers: egui::Modifiers::NONE,
},
],
..input
},
|context| {
egui::SidePanel::left("test_sources").show(context, |ui| app.draw_source_tree(ui));
},
);
assert!(app.view == WorldView::Sources);
assert_eq!(app.spawn_behavior, "actors/counter.rn");
assert!(matches!(
requests.try_recv(),
Ok(WorkerRequest::Command(Command::ReadSource { path }))
if path == "actors/counter.rn"
));
}
#[test]
fn actor_tree_files_select_inspector_without_losing_behavior_drafts() {
let (mut app, requests, _) = test_app();
let first = ActorRef::new().to_string();
let second = ActorRef::new().to_string();
app.open_actor_file(&first, ActorFile::Behavior);
assert!(app.view == WorldView::Actors);
assert!(app.actor_file == ActorFile::Behavior);
assert!(matches!(
requests.try_recv(),
Ok(WorkerRequest::Select(Some(actor))) if actor == first
));
assert!(matches!(
requests.try_recv(),
Ok(WorkerRequest::Command(Command::ActorBehavior { actor_ref }))
if actor_ref == first
));
app.actor_behavior = Some(SourceFileDto {
path: "actors/counter.rn".to_owned(),
source_id: "v1".to_owned(),
source: "original".to_owned(),
});
app.actor_behavior_draft = "unsaved".to_owned();
app.actor_behavior_dirty = true;
app.open_actor_file(&first, ActorFile::State);
assert!(app.actor_file == ActorFile::State);
app.open_actor_file(&first, ActorFile::Behavior);
assert!(requests.try_recv().is_err());
app.open_actor_file(&second, ActorFile::Logs);
assert_eq!(app.selected_actor.as_deref(), Some(first.as_str()));
assert!(app.actor_file == ActorFile::Behavior);
assert_eq!(app.actor_behavior_draft, "unsaved");
assert!(requests.try_recv().is_err());
}
}

View File

@ -226,6 +226,10 @@ step, and execution-stack controls, plus a source editor with compilation
diagnostics, explicit source commits, committed-revision previews, and
confirmed rollback. The actor inspector can edit an actor-local temporary
behavior and explicitly update its shared source or reload from that source.
Resizable side panels provide a source file tree on the left and an actor tree
on the right. Source files open in the central editor and select the behavior
for spawning; actor entries expand into metadata/inspector, state, mailbox, log,
and behavior views.
The events view polls a bounded, non-destructive event history with an
independent cursor per client; reading events does not consume them for other
clients. The server retains up to 4096 events in memory, and this process-local