feat: extend graphical world source tools

Add image source editing with compile diagnostics and explicit source commits, plus manual event collection.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-11-04 04:56:26 +01:00
parent 97585a6f10
commit fc33332e7d
4 changed files with 287 additions and 15 deletions

View File

@ -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 - Add an egui/eframe graphical client for browsing and inspecting actors in a
running image, with message, lifecycle, breakpoint, stepping, stack, and log running image, with message, lifecycle, breakpoint, stepping, stack, and log
views. views.
- Extend the graphical client with source editing, compilation diagnostics,
source commits, and explicit image-event collection.
### Changed ### Changed

View File

@ -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 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. Rune frame locals and expression execution-stack inspection, and retained logs. Its source view supports
evaluation are not available. The client does not create or embed a separate editing image sources with optimistic conflict checks, compilation diagnostics,
image. 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, 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 use `cargo run -p runa-cli -- --connect 127.0.0.1:4000 repl` and start the server with

View File

@ -11,8 +11,8 @@ use eframe::egui;
use runa_client::WebSocketClient; use runa_client::WebSocketClient;
use runa_core::{ActorRef, StepMode, Value}; use runa_core::{ActorRef, StepMode, Value};
use runa_protocol::{ use runa_protocol::{
ActorSnapshotDto, ActorStatusDto, Command, ExecutionInspectionDto, LogEntry, Reply, ActorSnapshotDto, ActorStatusDto, Command, EventDto, ExecutionInspectionDto, LogEntry, Reply,
SystemMessageDto, SourceDiagnostic, SourceFileDto, SystemMessageDto,
}; };
#[derive(Parser)] #[derive(Parser)]
@ -45,9 +45,23 @@ enum WorkerUpdate {
Status(String), Status(String),
Notice(String), Notice(String),
Reset, Reset,
SourceLoaded(SourceFileDto),
SourceSaved {
source: SourceFileDto,
submitted_source: String,
},
SourceDiagnostics(Vec<SourceDiagnostic>),
Events(Vec<EventDto>),
Snapshot(Box<WorkerSnapshot>), Snapshot(Box<WorkerSnapshot>),
} }
#[derive(Clone, Copy, PartialEq, Eq)]
enum WorldView {
Actors,
Sources,
Events,
}
struct WorkerSnapshot { struct WorkerSnapshot {
actors: Vec<ActorSnapshotDto>, actors: Vec<ActorSnapshotDto>,
sources: Vec<String>, sources: Vec<String>,
@ -76,6 +90,13 @@ struct WorldApp {
message_json: String, message_json: String,
scheduler_ticks: String, scheduler_ticks: String,
scheduler_budget: String, scheduler_budget: String,
view: WorldView,
source: Option<SourceFileDto>,
loading_source: Option<String>,
source_draft: String,
source_diagnostics: Vec<SourceDiagnostic>,
source_dirty: bool,
events: Vec<EventDto>,
} }
impl WorldApp { impl WorldApp {
@ -107,6 +128,13 @@ impl WorldApp {
message_json: "{\"op\":\"inc\"}".to_owned(), message_json: "{\"op\":\"inc\"}".to_owned(),
scheduler_ticks: "1".to_owned(), scheduler_ticks: "1".to_owned(),
scheduler_budget: "1000".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) { fn read_worker_updates(&mut self) {
loop { loop {
match self.updates.try_recv() { match self.updates.try_recv() {
@ -139,6 +182,42 @@ impl WorldApp {
self.actor = None; self.actor = None;
self.execution = None; self.execution = None;
self.logs.clear(); 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)) => { Ok(WorkerUpdate::Snapshot(snapshot)) => {
if self.selected_actor.is_none() 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) { fn draw_connection(&mut self, ui: &mut egui::Ui) {
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label("Image"); ui.label("Image");
ui.text_edit_singleline(&mut self.endpoint); ui.text_edit_singleline(&mut self.endpoint);
if ui.button("Connect").clicked() { if ui.button("Connect").clicked() {
match self.endpoint.parse() { if self.source_dirty {
Ok(address) => { self.notice =
self.status = format!("Connecting to {address}"); "Save or discard the current source draft before reconnecting".to_owned();
let _ = self.requests.send(WorkerRequest::Connect(address)); } 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(); 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 { impl eframe::App for WorldApp {
@ -473,7 +687,15 @@ impl eframe::App for WorldApp {
.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));
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)); context.request_repaint_after(Duration::from_millis(100));
} }
} }
@ -541,9 +763,50 @@ fn client_worker(requests: Receiver<WorkerRequest>, updates: Sender<WorkerUpdate
)); ));
continue; continue;
}; };
let operation = match &command {
Command::ReadSource { .. } => "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) { match request_reply(connected, command) {
Ok(reply) => { 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(); next_poll = Instant::now();
} }
Err(CallError::Protocol(error)) => { Err(CallError::Protocol(error)) => {

View File

@ -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 The first native client milestone uses egui/eframe and connects to the existing
loopback image over the shared WebSocket protocol. It currently provides an loopback image over the shared WebSocket protocol. It currently provides an
actor browser and inspector with message, lifecycle, log, breakpoint, bounded actor browser and inspector with message, lifecycle, log, breakpoint, bounded
step, and execution-stack controls. Network requests run on a worker thread, step, and execution-stack controls, plus a source editor with compilation
not the UI frame loop. This is a client foundation, not yet a Rune-composed diagnostics and explicit source commits. Image events can be collected
graphical world; frame locals and expression evaluation remain unavailable. 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 ## 8. Unified Programmatic Image API