feat: add source history tools to world client

Add committed revision browsing, syntax-highlighted source editing, revision previews, and confirmed rollbacks.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-11-04 05:11:36 +01:00
parent fc33332e7d
commit 2f9d500ca3
4 changed files with 307 additions and 14 deletions

View File

@ -11,6 +11,8 @@ All notable changes to this project will be documented in this file.
views.
- Extend the graphical client with source editing, compilation diagnostics,
source commits, and explicit image-event collection.
- Add committed source history browsing, revision preview, and confirmed
rollback to the graphical source editor.
### Changed

View File

@ -88,7 +88,9 @@ actor browsing and inspection, message sending, lifecycle controls, explicit
scheduler ticks, bounded debugger stepping, source breakpoints,
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:
explicit history commits, and revision preview/rollback. Rollback requires
confirmation and creates a new current source version without deleting history.
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

View File

@ -12,7 +12,7 @@ use runa_client::WebSocketClient;
use runa_core::{ActorRef, StepMode, Value};
use runa_protocol::{
ActorSnapshotDto, ActorStatusDto, Command, EventDto, ExecutionInspectionDto, LogEntry, Reply,
SourceDiagnostic, SourceFileDto, SystemMessageDto,
SourceCommitDto, SourceDiagnostic, SourceFileDto, SystemMessageDto,
};
#[derive(Parser)]
@ -51,6 +51,15 @@ enum WorkerUpdate {
submitted_source: String,
},
SourceDiagnostics(Vec<SourceDiagnostic>),
SourceHistory {
path: String,
history: Vec<SourceCommitDto>,
},
RevisionSource {
path: String,
revision: String,
source: String,
},
Events(Vec<EventDto>),
Snapshot(Box<WorkerSnapshot>),
}
@ -96,6 +105,10 @@ struct WorldApp {
source_draft: String,
source_diagnostics: Vec<SourceDiagnostic>,
source_dirty: bool,
source_history: Vec<SourceCommitDto>,
selected_revision: Option<String>,
revision_preview: Option<(String, String)>,
rollback_confirmation: Option<(String, String)>,
events: Vec<EventDto>,
}
@ -134,6 +147,10 @@ impl WorldApp {
source_draft: String::new(),
source_diagnostics: Vec::new(),
source_dirty: false,
source_history: Vec::new(),
selected_revision: None,
revision_preview: None,
rollback_confirmation: None,
events: Vec::new(),
}
}
@ -164,10 +181,16 @@ impl WorldApp {
self.source = None;
self.loading_source = Some(path.to_owned());
self.source_diagnostics.clear();
self.source_history.clear();
self.selected_revision = None;
self.revision_preview = None;
self.source_dirty = false;
self.submit(Command::ReadSource {
path: path.to_owned(),
});
self.submit(Command::SourceHistory {
path: path.to_owned(),
});
}
fn read_worker_updates(&mut self) {
@ -186,6 +209,9 @@ impl WorldApp {
self.loading_source = None;
self.source_dirty = false;
self.source_diagnostics.clear();
self.source_history.clear();
self.selected_revision = None;
self.revision_preview = None;
self.events.clear();
}
Ok(WorkerUpdate::SourceLoaded(source)) => {
@ -213,6 +239,27 @@ impl WorldApp {
Ok(WorkerUpdate::SourceDiagnostics(diagnostics)) => {
self.source_diagnostics = diagnostics;
}
Ok(WorkerUpdate::SourceHistory { path, history }) => {
if self.source_path() != Some(path.as_str()) {
continue;
}
if let Some(selected) = &self.selected_revision
&& !history.iter().any(|entry| &entry.revision == selected)
{
self.selected_revision = None;
self.revision_preview = None;
}
self.source_history = history;
}
Ok(WorkerUpdate::RevisionSource {
path,
revision,
source,
}) => {
if self.source_path() == Some(path.as_str()) {
self.revision_preview = Some((revision, source));
}
}
Ok(WorkerUpdate::Events(events)) => {
self.events.extend(events);
if self.events.len() > 200 {
@ -566,7 +613,7 @@ impl WorldApp {
});
}
fn draw_source_editor(&mut self, ui: &mut egui::Ui) {
fn draw_source_editor(&mut self, context: &egui::Context, ui: &mut egui::Ui) {
ui.heading("Source editor");
ui.horizontal(|ui| {
ui.label("Source file");
@ -624,20 +671,114 @@ impl WorldApp {
egui::Button::new("Commit"),
)
.clicked()
&& let Some(source) = &self.source
&& let Some(path) = self.source.as_ref().map(|source| source.path.clone())
{
self.submit(Command::CommitSource {
path: source.path.clone(),
});
self.submit(Command::CommitSource { path: path.clone() });
self.submit(Command::SourceHistory { path });
}
});
ui.collapsing(
format!("Committed history ({})", self.source_history.len()),
|ui| {
if ui.button("Refresh history").clicked()
&& let Some(source) = &self.source
{
self.submit(Command::SourceHistory {
path: source.path.clone(),
});
}
if self.source_history.is_empty() {
ui.label("No committed revisions yet.");
}
egui::ComboBox::from_id_salt("source_revision")
.selected_text(
self.selected_revision
.as_deref()
.unwrap_or("Choose a revision"),
)
.show_ui(ui, |ui| {
for item in &self.source_history {
ui.selectable_value(
&mut self.selected_revision,
Some(item.revision.clone()),
format!("{} · {}", item.revision, item.source_id),
);
}
});
ui.horizontal(|ui| {
let has_revision = self.selected_revision.is_some();
if ui
.add_enabled(has_revision, egui::Button::new("Preview"))
.clicked()
&& let (Some(source), Some(revision)) =
(&self.source, &self.selected_revision)
{
self.submit(Command::RevisionSource {
path: source.path.clone(),
revision: revision.clone(),
});
}
if ui
.add_enabled(
has_revision && !self.source_dirty,
egui::Button::new("Roll back to revision"),
)
.clicked()
&& let (Some(source), Some(revision)) =
(&self.source, &self.selected_revision)
{
self.rollback_confirmation = Some((source.path.clone(), revision.clone()));
}
});
if let Some((revision, preview)) = &self.revision_preview {
ui.label(format!("Previewing {revision}"));
egui::ScrollArea::vertical()
.max_height(180.0)
.show(ui, |ui| {
ui.monospace(preview);
});
}
},
);
if let Some((path, revision)) = self.rollback_confirmation.clone() {
egui::Window::new("Confirm source rollback")
.collapsible(false)
.resizable(false)
.show(context, |ui| {
ui.label(format!(
"Replace `{path}` with committed revision {revision}? This writes a new current source version; it does not delete history."
));
ui.horizontal(|ui| {
if ui
.add_enabled(!self.source_dirty, egui::Button::new("Roll back"))
.clicked()
{
self.submit(Command::RollbackSource {
path: path.clone(),
revision: revision.clone(),
});
self.submit(Command::SourceHistory { path });
self.rollback_confirmation = None;
}
if ui.button("Cancel").clicked() {
self.rollback_confirmation = None;
}
});
});
}
if self.source.is_none() {
ui.label("Choose a source file to inspect or edit.");
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.source_draft)
.font(egui::TextStyle::Monospace)
.code_editor()
.layouter(&mut layouter)
.desired_width(f32::INFINITY)
.desired_rows(24),
);
@ -692,7 +833,7 @@ impl eframe::App for WorldApp {
ui.separator();
match self.view {
WorldView::Actors => self.draw_inspector(ui),
WorldView::Sources => self.draw_source_editor(ui),
WorldView::Sources => self.draw_source_editor(context, ui),
WorldView::Events => self.draw_events(ui),
}
});
@ -769,12 +910,29 @@ fn client_worker(requests: Receiver<WorkerRequest>, updates: Sender<WorkerUpdate
Command::CheckSource { .. } => "check_source",
Command::GetEvents => "get_events",
Command::CommitSource { .. } => "commit_source",
Command::SourceHistory { .. } => "source_history",
Command::RevisionSource { .. } => "revision_source",
Command::RollbackSource { .. } => "rollback_source",
_ => "command",
};
let submitted_source = match &command {
Command::WriteSource { source, .. } => Some(source.clone()),
_ => None,
};
let revision = match &command {
Command::RevisionSource { revision, .. }
| Command::RollbackSource { revision, .. } => Some(revision.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()),
_ => None,
};
match request_reply(connected, command) {
Ok(reply) => {
let update = match (operation, reply) {
@ -791,6 +949,20 @@ fn client_worker(requests: Receiver<WorkerRequest>, updates: Sender<WorkerUpdate
("check_source", Reply::SourceChecked { diagnostics }) => {
Some(WorkerUpdate::SourceDiagnostics(diagnostics))
}
("source_history", Reply::SourceHistory(history)) => source_path
.map(|path| WorkerUpdate::SourceHistory { path, history }),
("revision_source", Reply::BehaviorSource { source }) => {
match (source_path, revision) {
(Some(path), Some(revision)) => {
Some(WorkerUpdate::RevisionSource {
path,
revision,
source,
})
}
_ => None,
}
}
("get_events", Reply::Events(events)) => {
Some(WorkerUpdate::Events(events))
}
@ -800,6 +972,13 @@ fn client_worker(requests: Receiver<WorkerRequest>, updates: Sender<WorkerUpdate
commit.revision
)))
}
("rollback_source", Reply::Source(source)) => {
let submitted_source = source.source.clone();
Some(WorkerUpdate::SourceSaved {
source,
submitted_source,
})
}
(_, reply) => Some(WorkerUpdate::Notice(format!(
"Completed with unexpected response: {reply:?}"
))),
@ -1031,6 +1210,96 @@ fn show_json(ui: &mut egui::Ui, value: &Value) {
}
}
fn rune_layout_job(source: &str) -> egui::text::LayoutJob {
let mut job = egui::text::LayoutJob::default();
let mut cursor = 0;
while cursor < source.len() {
let remaining = &source[cursor..];
let (end, color) = if remaining.starts_with("//") {
(
source[cursor..]
.find('\n')
.map_or(source.len(), |offset| cursor + offset),
egui::Color32::from_rgb(125, 155, 125),
)
} else if remaining.starts_with('"') {
let mut end = cursor + 1;
let mut escaped = false;
for character in source[end..].chars() {
end += character.len_utf8();
if character == '"' && !escaped {
break;
}
escaped = character == '\\' && !escaped;
if character != '\\' {
escaped = false;
}
}
(end, egui::Color32::from_rgb(220, 170, 110))
} else {
let character = remaining.chars().next().expect("cursor is within source");
if character.is_ascii_alphabetic() || character == '_' {
let mut end = cursor + character.len_utf8();
for next in source[end..].chars() {
if next.is_ascii_alphanumeric() || next == '_' {
end += next.len_utf8();
} else {
break;
}
}
let token = &source[cursor..end];
let color = if matches!(
token,
"pub"
| "fn"
| "let"
| "mut"
| "if"
| "else"
| "loop"
| "while"
| "match"
| "return"
| "async"
| "await"
| "select"
) {
egui::Color32::from_rgb(190, 145, 245)
} else {
egui::Color32::from_rgb(215, 215, 220)
};
(end, color)
} else if character.is_ascii_digit() {
let mut end = cursor + character.len_utf8();
for next in source[end..].chars() {
if next.is_ascii_digit() {
end += next.len_utf8();
} else {
break;
}
}
(end, egui::Color32::from_rgb(130, 185, 225))
} else {
(
cursor + character.len_utf8(),
egui::Color32::from_rgb(215, 215, 220),
)
}
};
job.append(
&source[cursor..end],
0.0,
egui::TextFormat {
font_id: egui::FontId::monospace(14.0),
color,
..Default::default()
},
);
cursor = end;
}
job
}
fn to_json_value(value: &Value) -> serde_json::Value {
match value {
Value::Null => serde_json::Value::Null,
@ -1096,4 +1365,23 @@ mod tests {
let json = serde_json::to_string(&to_json_value(&value)).unwrap();
assert_eq!(parse_value(&json).unwrap(), value);
}
#[test]
fn rune_highlighting_preserves_source_and_marks_keywords_and_comments() {
let source = "pub fn handle() { // note\n let n = 12;\n}";
let layout = rune_layout_job(source);
assert_eq!(layout.text, source);
assert!(
layout
.sections
.iter()
.any(|section| &source[section.byte_range.clone()] == "pub")
);
assert!(
layout
.sections
.iter()
.any(|section| &source[section.byte_range.clone()] == "// note")
);
}
}

View File

@ -223,11 +223,12 @@ 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, 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.
diagnostics, explicit source commits, committed-revision previews, and
confirmed rollback. 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