diff --git a/CHANGELOG.md b/CHANGELOG.md index da14bcd..6e1bc2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ All notable changes to this project will be documented in this file. - Add an egui/eframe graphical client for browsing and inspecting actors in a running image, with message, lifecycle, breakpoint, stepping, stack, and log views. +- Extend the graphical client with source editing, compilation diagnostics, + source commits, and explicit image-event collection. ### Changed diff --git a/README.md b/README.md index 2be8159..09a1ce8 100644 --- a/README.md +++ b/README.md @@ -86,9 +86,13 @@ 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. Rune frame locals and expression -evaluation are not available. The client does not create or embed a separate -image. +execution-stack inspection, and retained logs. Its source view supports +editing image sources with optimistic conflict checks, compilation diagnostics, +and explicit history commits. 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 +expression evaluation are not available. The client does not create or embed a +separate image. The REPL defaults to `127.0.0.1:3737`. To select another loopback endpoint, use `cargo run -p runa-cli -- --connect 127.0.0.1:4000 repl` and start the server with diff --git a/crates/runa-world/src/main.rs b/crates/runa-world/src/main.rs index 7f39d69..666215c 100644 --- a/crates/runa-world/src/main.rs +++ b/crates/runa-world/src/main.rs @@ -11,8 +11,8 @@ use eframe::egui; use runa_client::WebSocketClient; use runa_core::{ActorRef, StepMode, Value}; use runa_protocol::{ - ActorSnapshotDto, ActorStatusDto, Command, ExecutionInspectionDto, LogEntry, Reply, - SystemMessageDto, + ActorSnapshotDto, ActorStatusDto, Command, EventDto, ExecutionInspectionDto, LogEntry, Reply, + SourceDiagnostic, SourceFileDto, SystemMessageDto, }; #[derive(Parser)] @@ -45,9 +45,23 @@ enum WorkerUpdate { Status(String), Notice(String), Reset, + SourceLoaded(SourceFileDto), + SourceSaved { + source: SourceFileDto, + submitted_source: String, + }, + SourceDiagnostics(Vec), + Events(Vec), Snapshot(Box), } +#[derive(Clone, Copy, PartialEq, Eq)] +enum WorldView { + Actors, + Sources, + Events, +} + struct WorkerSnapshot { actors: Vec, sources: Vec, @@ -76,6 +90,13 @@ struct WorldApp { message_json: String, scheduler_ticks: String, scheduler_budget: String, + view: WorldView, + source: Option, + loading_source: Option, + source_draft: String, + source_diagnostics: Vec, + source_dirty: bool, + events: Vec, } impl WorldApp { @@ -107,6 +128,13 @@ impl WorldApp { message_json: "{\"op\":\"inc\"}".to_owned(), scheduler_ticks: "1".to_owned(), scheduler_budget: "1000".to_owned(), + view: WorldView::Actors, + source: None, + loading_source: None, + source_draft: String::new(), + source_diagnostics: Vec::new(), + source_dirty: false, + events: Vec::new(), } } @@ -127,6 +155,21 @@ impl WorldApp { } } + fn select_source(&mut self, path: &str) { + if self.source_dirty { + self.notice = + "Save or discard the current source draft before opening another file".to_owned(); + return; + } + self.source = None; + self.loading_source = Some(path.to_owned()); + self.source_diagnostics.clear(); + self.source_dirty = false; + self.submit(Command::ReadSource { + path: path.to_owned(), + }); + } + fn read_worker_updates(&mut self) { loop { match self.updates.try_recv() { @@ -139,6 +182,42 @@ impl WorldApp { self.actor = None; self.execution = None; self.logs.clear(); + self.source = None; + self.loading_source = None; + self.source_dirty = false; + self.source_diagnostics.clear(); + self.events.clear(); + } + Ok(WorkerUpdate::SourceLoaded(source)) => { + if self.loading_source.as_deref() == Some(source.path.as_str()) { + self.source_draft.clone_from(&source.source); + self.source = Some(source); + self.loading_source = None; + self.source_dirty = false; + self.source_diagnostics.clear(); + } + } + Ok(WorkerUpdate::SourceSaved { + source, + submitted_source, + }) => { + if self.source_path() == Some(source.path.as_str()) { + if self.source_draft == submitted_source { + self.source_draft.clone_from(&source.source); + } + self.source_dirty = self.source_draft != source.source; + self.source = Some(source); + self.source_diagnostics.clear(); + } + } + Ok(WorkerUpdate::SourceDiagnostics(diagnostics)) => { + self.source_diagnostics = diagnostics; + } + Ok(WorkerUpdate::Events(events)) => { + self.events.extend(events); + if self.events.len() > 200 { + self.events.drain(..self.events.len() - 200); + } } Ok(WorkerUpdate::Snapshot(snapshot)) => { if self.selected_actor.is_none() @@ -157,17 +236,40 @@ impl WorldApp { } } + fn source_path(&self) -> Option<&str> { + self.source.as_ref().map(|source| source.path.as_str()) + } + + fn draw_view_picker(&mut self, ui: &mut egui::Ui) { + ui.horizontal(|ui| { + for (view, label) in [ + (WorldView::Actors, "Actors"), + (WorldView::Sources, "Sources"), + (WorldView::Events, "Events"), + ] { + if ui.selectable_label(self.view == view, label).clicked() { + self.view = view; + } + } + }); + } + fn draw_connection(&mut self, ui: &mut egui::Ui) { ui.horizontal(|ui| { ui.label("Image"); ui.text_edit_singleline(&mut self.endpoint); if ui.button("Connect").clicked() { - match self.endpoint.parse() { - Ok(address) => { - self.status = format!("Connecting to {address}"); - let _ = self.requests.send(WorkerRequest::Connect(address)); + if self.source_dirty { + self.notice = + "Save or discard the current source draft before reconnecting".to_owned(); + } else { + match self.endpoint.parse() { + Ok(address) => { + self.status = format!("Connecting to {address}"); + let _ = self.requests.send(WorkerRequest::Connect(address)); + } + Err(error) => self.notice = format!("Invalid loopback address: {error}"), } - Err(error) => self.notice = format!("Invalid loopback address: {error}"), } } ui.separator(); @@ -463,6 +565,118 @@ impl WorldApp { } }); } + + fn draw_source_editor(&mut self, 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); + } + } + }); + if self.source_dirty { + ui.colored_label(egui::Color32::YELLOW, "Unsaved draft"); + if ui.button("Discard draft").clicked() { + if let Some(source) = &self.source { + self.source_draft.clone_from(&source.source); + } + self.source_dirty = false; + self.source_diagnostics.clear(); + } + } + }); + ui.horizontal(|ui| { + if ui + .add_enabled(self.source.is_some(), egui::Button::new("Check")) + .clicked() + { + self.submit(Command::CheckSource { + source: self.source_draft.clone(), + }); + } + if ui + .add_enabled( + self.source_dirty && self.source.is_some(), + egui::Button::new("Save"), + ) + .clicked() + && let Some(source) = &self.source + { + self.submit(Command::WriteSource { + path: source.path.clone(), + expected_source_id: Some(source.source_id.clone()), + source: self.source_draft.clone(), + }); + } + if ui + .add_enabled( + !self.source_dirty && self.source.is_some(), + egui::Button::new("Commit"), + ) + .clicked() + && let Some(source) = &self.source + { + self.submit(Command::CommitSource { + path: source.path.clone(), + }); + } + }); + if self.source.is_none() { + ui.label("Choose a source file to inspect or edit."); + return; + } + let response = ui.add( + egui::TextEdit::multiline(&mut self.source_draft) + .font(egui::TextStyle::Monospace) + .desired_width(f32::INFINITY) + .desired_rows(24), + ); + if response.changed() { + self.source_dirty = self + .source + .as_ref() + .is_some_and(|source| self.source_draft != source.source); + self.source_diagnostics.clear(); + } + ui.separator(); + ui.heading(format!("Diagnostics ({})", self.source_diagnostics.len())); + for diagnostic in &self.source_diagnostics { + let location = diagnostic.range.map_or_else( + || "location unavailable".to_owned(), + |range| format!("{}:{}", range.start.line + 1, range.start.character + 1), + ); + ui.label(format!( + "{:?} at {location}: {}", + diagnostic.severity, diagnostic.message + )); + } + } + + fn draw_events(&mut self, ui: &mut egui::Ui) { + ui.heading("Image events"); + if ui.button("Collect events").clicked() { + self.submit(Command::GetEvents); + } + ui.small( + "Collecting events drains the shared image event queue, so other clients will not receive these events.", + ); + ui.separator(); + egui::ScrollArea::vertical().show(ui, |ui| { + for event in self.events.iter().rev() { + ui.monospace(format!("{event:?}")); + } + }); + } } impl eframe::App for WorldApp { @@ -473,7 +687,15 @@ impl eframe::App for WorldApp { .resizable(true) .default_width(340.0) .show(context, |ui| self.draw_actor_list(ui)); - egui::CentralPanel::default().show(context, |ui| self.draw_inspector(ui)); + egui::CentralPanel::default().show(context, |ui| { + self.draw_view_picker(ui); + ui.separator(); + match self.view { + WorldView::Actors => self.draw_inspector(ui), + WorldView::Sources => self.draw_source_editor(ui), + WorldView::Events => self.draw_events(ui), + } + }); context.request_repaint_after(Duration::from_millis(100)); } } @@ -541,9 +763,50 @@ fn client_worker(requests: Receiver, updates: Sender "read_source", + Command::WriteSource { .. } => "write_source", + Command::CheckSource { .. } => "check_source", + Command::GetEvents => "get_events", + Command::CommitSource { .. } => "commit_source", + _ => "command", + }; + let submitted_source = match &command { + Command::WriteSource { source, .. } => Some(source.clone()), + _ => None, + }; match request_reply(connected, command) { Ok(reply) => { - let _ = updates.send(WorkerUpdate::Notice(format!("Completed: {reply:?}"))); + let update = match (operation, reply) { + ("read_source", Reply::Source(source)) => { + Some(WorkerUpdate::SourceLoaded(source)) + } + ("write_source", Reply::Source(source)) => { + Some(WorkerUpdate::SourceSaved { + source, + submitted_source: submitted_source + .expect("write operation includes its submitted source"), + }) + } + ("check_source", Reply::SourceChecked { diagnostics }) => { + Some(WorkerUpdate::SourceDiagnostics(diagnostics)) + } + ("get_events", Reply::Events(events)) => { + Some(WorkerUpdate::Events(events)) + } + ("commit_source", Reply::SourceCommitted(commit)) => { + Some(WorkerUpdate::Notice(format!( + "Committed source revision {}", + commit.revision + ))) + } + (_, reply) => Some(WorkerUpdate::Notice(format!( + "Completed with unexpected response: {reply:?}" + ))), + }; + if let Some(update) = update { + let _ = updates.send(update); + } next_poll = Instant::now(); } Err(CallError::Protocol(error)) => { diff --git a/docs/architecture.md b/docs/architecture.md index 0bcd8e5..6194925 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -222,9 +222,12 @@ The graphical inspector should support selecting a component, locating its ownin The first native client milestone uses egui/eframe and connects to the existing loopback image over the shared WebSocket protocol. It currently provides an actor browser and inspector with message, lifecycle, log, breakpoint, bounded -step, and execution-stack controls. Network requests run on a worker thread, -not the UI frame loop. This is a client foundation, not yet a Rune-composed -graphical world; frame locals and expression evaluation remain unavailable. +step, and execution-stack controls, plus a source editor with compilation +diagnostics and explicit source commits. Image events can be collected +manually; this operation drains the shared queue and therefore consumes events +for other clients too. Network requests run on a worker thread, not the UI +frame loop. This is a client foundation, not yet a Rune-composed graphical +world; frame locals and expression evaluation remain unavailable. ## 8. Unified Programmatic Image API