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
|
### 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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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");
|
for path in &self.sources {
|
||||||
egui::ComboBox::from_id_salt("spawn_behavior")
|
tree.insert(path, path);
|
||||||
.selected_text(if self.spawn_behavior.is_empty() {
|
}
|
||||||
"Choose behavior"
|
egui::ScrollArea::vertical()
|
||||||
} else {
|
.id_salt("source_tree")
|
||||||
&self.spawn_behavior
|
.max_height((ui.available_height() - 150.0).max(100.0))
|
||||||
})
|
.show(ui, |ui| {
|
||||||
.show_ui(ui, |ui| {
|
if self.sources.is_empty() {
|
||||||
for path in &self.sources {
|
ui.label("No image sources.");
|
||||||
ui.selectable_value(&mut self.spawn_behavior, path.clone(), path);
|
}
|
||||||
}
|
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()
|
||||||
let actors = self.actors.clone();
|
.id_salt("actor_tree")
|
||||||
for actor in actors {
|
.show(ui, |ui| {
|
||||||
let label = format!("{} · {}", actor.actor_ref, status_label(&actor.status));
|
if self.actors.is_empty() {
|
||||||
if ui
|
ui.label("No running actors.");
|
||||||
.selectable_label(
|
|
||||||
self.selected_actor.as_deref() == Some(&actor.actor_ref),
|
|
||||||
label,
|
|
||||||
)
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
self.choose_actor(Some(actor.actor_ref));
|
|
||||||
}
|
}
|
||||||
|
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) {
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue