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
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

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
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

View File

@ -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<SourceDiagnostic>),
Events(Vec<EventDto>),
Snapshot(Box<WorkerSnapshot>),
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum WorldView {
Actors,
Sources,
Events,
}
struct WorkerSnapshot {
actors: Vec<ActorSnapshotDto>,
sources: Vec<String>,
@ -76,6 +90,13 @@ struct WorldApp {
message_json: String,
scheduler_ticks: 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 {
@ -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,11 +236,33 @@ 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() {
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}");
@ -170,6 +271,7 @@ impl WorldApp {
Err(error) => self.notice = format!("Invalid loopback address: {error}"),
}
}
}
ui.separator();
ui.label(&self.status);
});
@ -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<WorkerRequest>, updates: Sender<WorkerUpdate
));
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) {
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)) => {

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
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