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 ### 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 - Pretty-print actor state and mailbox JSON with two-space indentation while
preserving exact integers and actor-reference markers. preserving exact integers and actor-reference markers.
- Rename the actor's virtual log file to `actor.log` for automatic VS Code log - 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 pass `--connect 127.0.0.1:4000` to select another loopback address. It provides
actor browsing and inspection, message sending, lifecycle controls, explicit actor browsing and inspection, message sending, lifecycle controls, explicit
scheduler ticks, bounded debugger stepping, source breakpoints, 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, editing image sources with optimistic conflict checks, compilation diagnostics,
explicit history commits, and revision preview/rollback. Rollback requires explicit history commits, and revision preview/rollback. Rollback requires
confirmation and creates a new current source version without deleting history. 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(); let args = Args::parse();
eframe::run_native( eframe::run_native(
"Runa World", "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)))), Box::new(move |creation| Ok(Box::new(WorldApp::new(creation, args.connect)))),
) )
} }
@ -82,6 +87,58 @@ enum WorldView {
Events, 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 { struct WorkerSnapshot {
actors: Vec<ActorSnapshotDto>, actors: Vec<ActorSnapshotDto>,
sources: Vec<String>, sources: Vec<String>,
@ -101,6 +158,7 @@ struct WorldApp {
sources: Vec<String>, sources: Vec<String>,
source_ids: BTreeMap<String, String>, source_ids: BTreeMap<String, String>,
selected_actor: Option<String>, selected_actor: Option<String>,
actor_file: ActorFile,
actor: Option<ActorSnapshotDto>, actor: Option<ActorSnapshotDto>,
execution: Option<ExecutionInspectionDto>, execution: Option<ExecutionInspectionDto>,
logs: Vec<LogEntry>, logs: Vec<LogEntry>,
@ -150,6 +208,7 @@ impl WorldApp {
sources: Vec::new(), sources: Vec::new(),
source_ids: BTreeMap::new(), source_ids: BTreeMap::new(),
selected_actor: None, selected_actor: None,
actor_file: ActorFile::Metadata,
actor: None, actor: None,
execution: None, execution: None,
logs: Vec::new(), logs: Vec::new(),
@ -196,6 +255,7 @@ impl WorldApp {
return; return;
} }
self.selected_actor = actor_ref.clone(); self.selected_actor = actor_ref.clone();
self.actor_file = ActorFile::Metadata;
self.actor = None; self.actor = None;
self.execution = None; self.execution = None;
self.logs.clear(); self.logs.clear();
@ -208,11 +268,17 @@ impl WorldApp {
} }
fn select_source(&mut self, path: &str) { 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 { if self.source_dirty {
self.notice = self.notice =
"Save or discard the current source draft before opening another file".to_owned(); "Save or discard the current source draft before opening another file".to_owned();
return; return;
} }
self.view = WorldView::Sources;
self.spawn_behavior = path.to_owned();
self.source = None; self.source = None;
self.loading_source = Some(path.to_owned()); self.loading_source = Some(path.to_owned());
self.source_diagnostics.clear(); self.source_diagnostics.clear();
@ -465,24 +531,37 @@ impl WorldApp {
} }
} }
fn draw_actor_list(&mut self, ui: &mut egui::Ui) { fn draw_source_tree(&mut self, ui: &mut egui::Ui) {
ui.heading("Actors"); ui.heading("Sources");
ui.horizontal(|ui| { let mut tree = SourceTree::default();
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 { for path in &self.sources {
ui.selectable_value(&mut self.spawn_behavior, path.clone(), path); 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| { 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) { match parse_value(&self.spawn_state) {
Ok(state) if !self.spawn_behavior.trim().is_empty() => { Ok(state) if !self.spawn_behavior.trim().is_empty() => {
self.submit(Command::Spawn { 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); fn draw_actor_list(&mut self, ui: &mut egui::Ui) {
}); ui.heading("Actors");
ui.horizontal(|ui| { ui.collapsing("Scheduler", |ui| {
ui.label("Scheduler ticks"); ui.label("Scheduler ticks");
ui.text_edit_singleline(&mut self.scheduler_ticks); ui.text_edit_singleline(&mut self.scheduler_ticks);
ui.label("Instruction budget"); ui.label("Instruction budget");
@ -524,21 +603,93 @@ impl WorldApp {
} }
}); });
ui.separator(); ui.separator();
egui::ScrollArea::vertical().show(ui, |ui| { 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(); let actors = self.actors.clone();
for actor in actors { for actor in actors {
let label = format!("{} · {}", actor.actor_ref, status_label(&actor.status)); 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 if ui
.selectable_label( .selectable_label(
self.selected_actor.as_deref() == Some(&actor.actor_ref), self.selected_actor.as_deref() == Some(&actor.actor_ref)
label, && self.actor_file == file
&& self.view == WorldView::Actors,
name,
) )
.clicked() .clicked()
{ {
self.choose_actor(Some(actor.actor_ref)); 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) { 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) { fn draw_source_editor(&mut self, context: &egui::Context, ui: &mut egui::Ui) {
ui.heading("Source editor"); ui.heading("Source editor");
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label("Source file"); ui.label(
let selected = self.source_path().unwrap_or("Choose a source"); self.source_path()
egui::ComboBox::from_id_salt("source_file") .unwrap_or("Choose a file in the Sources tree"),
.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);
}
}
});
if self.source_dirty { if self.source_dirty {
ui.colored_label(egui::Color32::YELLOW, "Unsaved draft"); ui.colored_label(egui::Color32::YELLOW, "Unsaved draft");
if ui.button("Discard draft").clicked() { 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) { fn update(&mut self, context: &egui::Context, _frame: &mut eframe::Frame) {
self.read_worker_updates(); self.read_worker_updates();
egui::TopBottomPanel::top("connection").show(context, |ui| self.draw_connection(ui)); 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) .resizable(true)
.default_width(340.0) .default_width(340.0)
.show(context, |ui| self.draw_actor_list(ui)); .show(context, |ui| self.draw_actor_list(ui));
@ -1069,7 +1213,7 @@ impl eframe::App for WorldApp {
self.draw_view_picker(ui); self.draw_view_picker(ui);
ui.separator(); ui.separator();
match self.view { 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::Sources => self.draw_source_editor(context, ui),
WorldView::Events => self.draw_events(ui), WorldView::Events => self.draw_events(ui),
} }
@ -1662,6 +1806,7 @@ mod tests {
sources: Vec::new(), sources: Vec::new(),
source_ids: BTreeMap::new(), source_ids: BTreeMap::new(),
selected_actor: None, selected_actor: None,
actor_file: ActorFile::Metadata,
actor: None, actor: None,
execution: None, execution: None,
logs: Vec::new(), logs: Vec::new(),
@ -1864,4 +2009,151 @@ mod tests {
app.read_worker_updates(); app.read_worker_updates();
assert_eq!(app.missed_events, 6); 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 diagnostics, explicit source commits, committed-revision previews, and
confirmed rollback. The actor inspector can edit an actor-local temporary confirmed rollback. The actor inspector can edit an actor-local temporary
behavior and explicitly update its shared source or reload from that source. 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 The events view polls a bounded, non-destructive event history with an
independent cursor per client; reading events does not consume them for other 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 clients. The server retains up to 4096 events in memory, and this process-local