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:
parent
97585a6f10
commit
fc33332e7d
|
|
@ -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
|
||||
|
||||
|
|
|
|||
10
README.md
10
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
|
||||
|
|
|
|||
|
|
@ -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)) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue