From 35902b6b4d997164f37a86a81cc5af82bea458aa Mon Sep 17 00:00:00 2001 From: Steve Biedermann Date: Thu, 5 Nov 2026 09:04:53 +0100 Subject: [PATCH] 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> --- CHANGELOG.md | 3 + README.md | 6 +- crates/runa-world/src/main.rs | 396 +++++++++++++++++++++++++++++----- docs/architecture.md | 4 + 4 files changed, 356 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2ba61b..c9fcc32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 8ac7a43..3490f78 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/crates/runa-world/src/main.rs b/crates/runa-world/src/main.rs index d8d5e66..cc3ad25 100644 --- a/crates/runa-world/src/main.rs +++ b/crates/runa-world/src/main.rs @@ -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, + files: BTreeMap, +} + +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 { + 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, sources: Vec, @@ -101,6 +158,7 @@ struct WorldApp { sources: Vec, source_ids: BTreeMap, selected_actor: Option, + actor_file: ActorFile, actor: Option, execution: Option, logs: Vec, @@ -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()); + } } diff --git a/docs/architecture.md b/docs/architecture.md index 0849eee..26016a9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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