diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e1bc2d..88285f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 09a1ce8..ae365ad 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/crates/runa-world/src/main.rs b/crates/runa-world/src/main.rs index 666215c..5bec463 100644 --- a/crates/runa-world/src/main.rs +++ b/crates/runa-world/src/main.rs @@ -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), + SourceHistory { + path: String, + history: Vec, + }, + RevisionSource { + path: String, + revision: String, + source: String, + }, Events(Vec), Snapshot(Box), } @@ -96,6 +105,10 @@ struct WorldApp { source_draft: String, source_diagnostics: Vec, source_dirty: bool, + source_history: Vec, + selected_revision: Option, + revision_preview: Option<(String, String)>, + rollback_confirmation: Option<(String, String)>, events: Vec, } @@ -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, updates: Sender "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, updates: Sender { 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, updates: Sender { + 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") + ); + } } diff --git a/docs/architecture.md b/docs/architecture.md index 6194925..1cdf7b1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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