diff --git a/CHANGELOG.md b/CHANGELOG.md index 88285f7..d0df740 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ All notable changes to this project will be documented in this file. source commits, and explicit image-event collection. - Add committed source history browsing, revision preview, and confirmed rollback to the graphical source editor. +- Add actor-local temporary behavior editing and guarded shared-source updates + to the graphical actor inspector. ### Changed diff --git a/README.md b/README.md index ae365ad..77592cd 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,9 @@ execution-stack inspection, and retained logs. 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. +The actor inspector can also load and edit an actor-local temporary behavior, +save it with source-identity checks, reload the behavior from the image source, +or explicitly confirm updating the shared source from the temporary version. The events view has a manual collection action: collecting events consumes the image's shared event queue, so other clients do not receive events collected by the world client. Rune frame locals and diff --git a/crates/runa-world/src/main.rs b/crates/runa-world/src/main.rs index 5bec463..7d96828 100644 --- a/crates/runa-world/src/main.rs +++ b/crates/runa-world/src/main.rs @@ -51,6 +51,13 @@ enum WorkerUpdate { submitted_source: String, }, SourceDiagnostics(Vec), + ActorBehavior { + actor_ref: String, + path: String, + source_id: String, + source: String, + submitted_source: Option, + }, SourceHistory { path: String, history: Vec, @@ -74,6 +81,7 @@ enum WorldView { struct WorkerSnapshot { actors: Vec, sources: Vec, + source_ids: BTreeMap, actor: Option, execution: Option, logs: Vec, @@ -87,6 +95,7 @@ struct WorldApp { notice: String, actors: Vec, sources: Vec, + source_ids: BTreeMap, selected_actor: Option, actor: Option, execution: Option, @@ -110,6 +119,10 @@ struct WorldApp { revision_preview: Option<(String, String)>, rollback_confirmation: Option<(String, String)>, events: Vec, + actor_behavior: Option, + actor_behavior_draft: String, + actor_behavior_dirty: bool, + update_behavior_confirmation: bool, } impl WorldApp { @@ -129,6 +142,7 @@ impl WorldApp { notice: String::new(), actors: Vec::new(), sources: Vec::new(), + source_ids: BTreeMap::new(), selected_actor: None, actor: None, execution: None, @@ -152,6 +166,10 @@ impl WorldApp { revision_preview: None, rollback_confirmation: None, events: Vec::new(), + actor_behavior: None, + actor_behavior_draft: String::new(), + actor_behavior_dirty: false, + update_behavior_confirmation: false, } } @@ -163,11 +181,20 @@ impl WorldApp { fn choose_actor(&mut self, actor_ref: Option) { if self.selected_actor != actor_ref { + if self.actor_behavior_dirty { + self.notice = + "Save or discard the current actor behavior draft before changing actors" + .to_owned(); + return; + } self.selected_actor = actor_ref.clone(); self.actor = None; self.execution = None; self.logs.clear(); self.breakpoints.clear(); + self.actor_behavior = None; + self.actor_behavior_draft.clear(); + self.actor_behavior_dirty = false; let _ = self.requests.send(WorkerRequest::Select(actor_ref)); } } @@ -202,6 +229,7 @@ impl WorldApp { self.choose_actor(None); self.actors.clear(); self.sources.clear(); + self.source_ids.clear(); self.actor = None; self.execution = None; self.logs.clear(); @@ -213,6 +241,9 @@ impl WorldApp { self.selected_revision = None; self.revision_preview = None; self.events.clear(); + self.actor_behavior = None; + self.actor_behavior_draft.clear(); + self.actor_behavior_dirty = false; } Ok(WorkerUpdate::SourceLoaded(source)) => { if self.loading_source.as_deref() == Some(source.path.as_str()) { @@ -239,6 +270,28 @@ impl WorldApp { Ok(WorkerUpdate::SourceDiagnostics(diagnostics)) => { self.source_diagnostics = diagnostics; } + Ok(WorkerUpdate::ActorBehavior { + actor_ref, + path, + source_id, + source, + submitted_source, + }) => { + if self.selected_actor.as_deref() == Some(&actor_ref) { + if submitted_source + .as_ref() + .is_none_or(|submitted| self.actor_behavior_draft == *submitted) + { + self.actor_behavior_draft.clone_from(&source); + } + self.actor_behavior_dirty = self.actor_behavior_draft != source; + self.actor_behavior = Some(SourceFileDto { + path, + source_id, + source, + }); + } + } Ok(WorkerUpdate::SourceHistory { path, history }) => { if self.source_path() != Some(path.as_str()) { continue; @@ -272,8 +325,20 @@ impl WorldApp { { self.choose_actor(Some(first.actor_ref.clone())); } + if let (Some(previous), Some(current)) = (&self.actor, &snapshot.actor) + && previous.actor_ref == current.actor_ref + && previous.temporary_behavior + && !current.temporary_behavior + { + let _ = + self.requests + .send(WorkerRequest::Command(Command::ActorBehavior { + actor_ref: current.actor_ref.clone(), + })); + } self.actors = snapshot.actors; self.sources = snapshot.sources; + self.source_ids = snapshot.source_ids; self.actor = snapshot.actor; self.execution = snapshot.execution; self.logs = snapshot.logs; @@ -306,9 +371,10 @@ impl WorldApp { ui.label("Image"); ui.text_edit_singleline(&mut self.endpoint); if ui.button("Connect").clicked() { - if self.source_dirty { + if self.source_dirty || self.actor_behavior_dirty { self.notice = - "Save or discard the current source draft before reconnecting".to_owned(); + "Save or discard the current source and behavior drafts before reconnecting" + .to_owned(); } else { match self.endpoint.parse() { Ok(address) => { @@ -479,11 +545,136 @@ impl WorldApp { } }); self.draw_message_sender(ui, &actor.actor_ref); + self.draw_actor_behavior_editor(ui, &actor); self.draw_debugger(ui, &actor); self.draw_logs(ui, &actor.actor_ref); }); } + fn draw_actor_behavior_editor(&mut self, ui: &mut egui::Ui, actor: &ActorSnapshotDto) { + ui.collapsing("Actor behavior", |ui| { + ui.horizontal(|ui| { + ui.label(actor.behavior_path.as_deref().unwrap_or("No source path")); + if ui.button("Load active behavior").clicked() { + self.submit(Command::ActorBehavior { + actor_ref: actor.actor_ref.clone(), + }); + } + if ui + .add_enabled( + !self.actor_behavior_dirty, + egui::Button::new("Reload from source"), + ) + .clicked() + { + self.submit(Command::System { + actor_ref: actor.actor_ref.clone(), + message: SystemMessageDto::Reload, + }); + } + }); + if self.actor_behavior_dirty { + ui.colored_label(egui::Color32::YELLOW, "Unsaved temporary draft"); + if ui.button("Discard draft").clicked() + && let Some(source) = &self.actor_behavior + { + self.actor_behavior_draft.clone_from(&source.source); + self.actor_behavior_dirty = false; + } + } + ui.horizontal(|ui| { + if ui + .add_enabled( + self.actor_behavior.is_some() && self.actor_behavior_dirty, + egui::Button::new("Save as temporary behavior"), + ) + .clicked() + && let Some(source) = &self.actor_behavior + { + self.submit(Command::WriteActorBehavior { + actor_ref: actor.actor_ref.clone(), + expected_source_id: source.source_id.clone(), + source: self.actor_behavior_draft.clone(), + }); + } + if ui + .add_enabled( + actor.temporary_behavior && !self.actor_behavior_dirty, + egui::Button::new("Update shared source"), + ) + .clicked() + { + self.update_behavior_confirmation = true; + } + }); + if self.actor_behavior.is_none() { + ui.label( + "Load the actor's current behavior to edit an actor-local temporary copy.", + ); + return; + } + let mut layouter = |ui: &egui::Ui, source: &str, wrap_width: f32| { + let mut job = rune_layout_job(source); + job.wrap.max_width = wrap_width; + ui.fonts(|fonts| fonts.layout_job(job)) + }; + let response = ui.add( + egui::TextEdit::multiline(&mut self.actor_behavior_draft) + .code_editor() + .layouter(&mut layouter) + .desired_width(f32::INFINITY) + .desired_rows(12), + ); + if response.changed() { + self.actor_behavior_dirty = self + .actor_behavior + .as_ref() + .is_some_and(|source| self.actor_behavior_draft != source.source); + } + }); + if self.update_behavior_confirmation { + egui::Window::new("Confirm shared behavior update") + .collapsible(false) + .resizable(false) + .show(ui.ctx(), |ui| { + ui.label(format!( + "Overwrite `{}` in the image source tree with this actor's temporary behavior?", + self.actor_behavior + .as_ref() + .map_or("unknown source", |source| source.path.as_str()) + )); + ui.horizontal(|ui| { + if ui.button("Update source").clicked() { + let Some(temporary) = &self.actor_behavior else { + self.update_behavior_confirmation = false; + return; + }; + let expected_source_id = self.source_ids.get(&temporary.path).cloned(); + match expected_source_id { + Some(expected_source_id) => self.submit( + Command::UpdateBehaviorSource { + actor_ref: actor.actor_ref.clone(), + path: temporary.path.clone(), + expected_source_id, + temporary_source_id: temporary.source_id.clone(), + }, + ), + None => { + self.notice = + "Could not determine the current source identity; refresh and try again" + .to_owned() + } + } + self.update_behavior_confirmation = false; + } + if ui.button("Cancel").clicked() { + self.update_behavior_confirmation = false; + } + }); + }); + } + } + fn draw_message_sender(&mut self, ui: &mut egui::Ui, actor_ref: &str) { ui.collapsing("Send message", |ui| { ui.horizontal(|ui| { @@ -864,6 +1055,7 @@ fn request_reply(client: &mut WebSocketClient, command: Command) -> Result, updates: Sender) { let mut client = None; let mut selected_actor = None; + let mut selected_behavior_path = None; let mut log_sequence = 0; let mut retained_logs = Vec::new(); let mut next_poll = Instant::now(); @@ -873,6 +1065,7 @@ fn client_worker(requests: Receiver, updates: Sender { client = Some(connected); selected_actor = None; + selected_behavior_path = None; log_sequence = 0; retained_logs.clear(); next_poll = Instant::now(); @@ -887,6 +1080,7 @@ fn client_worker(requests: Receiver, updates: Sender { selected_actor = actor_ref; + selected_behavior_path = None; log_sequence = 0; retained_logs.clear(); next_poll = Instant::now(); @@ -907,6 +1101,9 @@ fn client_worker(requests: Receiver, updates: Sender "read_source", Command::WriteSource { .. } => "write_source", + Command::ActorBehavior { .. } => "actor_behavior", + Command::WriteActorBehavior { .. } => "write_actor_behavior", + Command::UpdateBehaviorSource { .. } => "update_behavior_source", Command::CheckSource { .. } => "check_source", Command::GetEvents => "get_events", Command::CommitSource { .. } => "commit_source", @@ -916,7 +1113,8 @@ fn client_worker(requests: Receiver, updates: Sender "command", }; let submitted_source = match &command { - Command::WriteSource { source, .. } => Some(source.clone()), + Command::WriteSource { source, .. } + | Command::WriteActorBehavior { source, .. } => Some(source.clone()), _ => None, }; let revision = match &command { @@ -924,13 +1122,19 @@ fn client_worker(requests: Receiver, updates: Sender Some(revision.clone()), _ => None, }; + let requested_actor_ref = match &command { + Command::ActorBehavior { actor_ref } + | Command::WriteActorBehavior { actor_ref, .. } => Some(actor_ref.clone()), + _ => None, + }; let source_path = match &command { Command::ReadSource { path } | Command::WriteSource { path, .. } | Command::CommitSource { path } | Command::SourceHistory { path } | Command::RevisionSource { path, .. } - | Command::RollbackSource { path, .. } => Some(path.clone()), + | Command::RollbackSource { path, .. } + | Command::UpdateBehaviorSource { path, .. } => Some(path.clone()), _ => None, }; match request_reply(connected, command) { @@ -946,6 +1150,25 @@ fn client_worker(requests: Receiver, updates: Sender { + Some(WorkerUpdate::ActorBehavior { + actor_ref: requested_actor_ref.unwrap_or_default(), + path: selected_behavior_path.clone().unwrap_or_default(), + source_id, + source, + submitted_source: None, + }) + } + ( + "write_actor_behavior", + Reply::ActorBehavior { source_id, source }, + ) => Some(WorkerUpdate::ActorBehavior { + actor_ref: requested_actor_ref.unwrap_or_default(), + path: selected_behavior_path.clone().unwrap_or_default(), + source_id, + source, + submitted_source, + }), ("check_source", Reply::SourceChecked { diagnostics }) => { Some(WorkerUpdate::SourceDiagnostics(diagnostics)) } @@ -979,6 +1202,12 @@ fn client_worker(requests: Receiver, updates: Sender { + Some(WorkerUpdate::SourceSaved { + source: source.clone(), + submitted_source: source.source, + }) + } (_, reply) => Some(WorkerUpdate::Notice(format!( "Completed with unexpected response: {reply:?}" ))), @@ -1027,9 +1256,18 @@ fn client_worker(requests: Receiver, updates: Sender sources.into_iter().map(|source| source.path).collect(), - Ok(_) => Vec::new(), + let (sources, source_ids) = match request_reply(connected, Command::ListSources) { + Ok(Reply::Sources(sources)) => { + let source_ids = sources + .iter() + .map(|source| (source.path.clone(), source.source_id.clone())) + .collect(); + ( + sources.into_iter().map(|source| source.path).collect(), + source_ids, + ) + } + Ok(_) => (Vec::new(), BTreeMap::new()), Err(error) => { report_call_error(error, &updates, &mut client); continue; @@ -1041,6 +1279,7 @@ fn client_worker(requests: Receiver, updates: Sender, updates: Sender