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:
parent
e446d43b91
commit
35902b6b4d
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue