diff --git a/crates/agcanvas-mcp/src/main.rs b/crates/agcanvas-mcp/src/main.rs index a0ecb03..f32f12a 100644 --- a/crates/agcanvas-mcp/src/main.rs +++ b/crates/agcanvas-mcp/src/main.rs @@ -8,7 +8,10 @@ use tools::AgCanvasServer; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; #[derive(Parser)] -#[command(name = "agcanvas-mcp", about = "MCP server bridge for Augmented Canvas")] +#[command( + name = "agcanvas-mcp", + about = "MCP server bridge for Augmented Canvas" +)] struct Cli { #[arg(long, default_value = "9876")] port: u16, @@ -26,7 +29,10 @@ async fn main() -> Result<()> { let cli = Cli::parse(); let ws_url = format!("ws://127.0.0.1:{}", cli.port); - tracing::info!("Starting Augmented Canvas MCP server, connecting to {}", ws_url); + tracing::info!( + "Starting Augmented Canvas MCP server, connecting to {}", + ws_url + ); let server = AgCanvasServer::new(ws_url); let service = server.serve(rmcp::transport::stdio()).await?; diff --git a/crates/agcanvas/src/agent/server.rs b/crates/agcanvas/src/agent/server.rs index 0c19e3d..b3844fd 100644 --- a/crates/agcanvas/src/agent/server.rs +++ b/crates/agcanvas/src/agent/server.rs @@ -1,7 +1,6 @@ use super::protocol::{ build_shape, build_style, parse_hex_color, AgentRequest, AgentResponse, CodeGenTarget, - DrawingCommand, GuiEvent, - SessionCommand, + DrawingCommand, GuiEvent, SessionCommand, }; use crate::drawing::{boolean, DrawingElement, Shape, ShapeStyle}; use crate::session::{SessionCreator, SessionStore}; @@ -574,7 +573,10 @@ async fn process_request( let mut selected_elements = Vec::with_capacity(element_ids.len()); for element_id in &element_ids { - match source_elements.iter().find(|element| element.id == *element_id) { + match source_elements + .iter() + .find(|element| element.id == *element_id) + { Some(element) => selected_elements.push(element), None => { return AgentResponse::Error { diff --git a/crates/agcanvas/src/app.rs b/crates/agcanvas/src/app.rs index 7e5e3a6..f898757 100644 --- a/crates/agcanvas/src/app.rs +++ b/crates/agcanvas/src/app.rs @@ -12,6 +12,7 @@ use crate::persistence::{self, SavedSession, SavedWorkspace}; use crate::session::{Session, SessionCreator, SessionStore}; use crate::svg::{parse_svg, SvgRenderer}; use egui::{Color32, ColorImage, TextureOptions}; +use std::path::Path; use std::sync::Arc; use tokio::runtime::Runtime; use tokio::sync::{broadcast, mpsc, RwLock}; @@ -227,6 +228,20 @@ impl AgCanvasApp { } } + fn apply_history_checkout(&mut self, ctx: &egui::Context) { + let _ = ctx; + let idx = self.active_session_idx; + if let Some(svg_data) = self.sessions[idx].svg_source.clone() { + if let Ok((tree, usvg_tree)) = parse_svg(&svg_data) { + let session = &mut self.sessions[idx]; + session.description_text = tree.to_semantic_description(); + session.svg_renderer = Some(SvgRenderer::new(usvg_tree)); + session.element_tree = Some(tree); + session.svg_texture = None; + } + } + } + fn clear_canvas(&mut self) { let session = self.active_session_mut(); let session_id = session.id.clone(); @@ -345,10 +360,8 @@ impl AgCanvasApp { if let Some(session) = self.sessions.iter_mut().find(|s| s.id == session_id) { session.drawing_elements.clear(); session.selected_element_id = None; - session.record_edit( - "Agent: Clear Canvas", - ChangeSource::Agent { name: None }, - ); + session + .record_edit("Agent: Clear Canvas", ChangeSource::Agent { name: None }); } } } @@ -516,6 +529,22 @@ impl AgCanvasApp { let idx = self.active_session_idx; self.close_session(idx); } + CommandId::Undo => { + let idx = self.active_session_idx; + let previous_svg = self.sessions[idx].svg_source.clone(); + if self.active_session_mut().undo() && self.sessions[idx].svg_source != previous_svg + { + self.apply_history_checkout(ctx); + } + } + CommandId::Redo => { + let idx = self.active_session_idx; + let previous_svg = self.sessions[idx].svg_source.clone(); + if self.active_session_mut().redo() && self.sessions[idx].svg_source != previous_svg + { + self.apply_history_checkout(ctx); + } + } CommandId::SaveWorkspace => self.save_workspace(), CommandId::ClearCanvas => self.clear_canvas(), CommandId::PasteSvg => self.handle_paste(ctx), @@ -760,6 +789,8 @@ impl eframe::App for AgCanvasApp { let mut toggle_palette = false; let mut delete_selected = false; let mut toggle_history = false; + let mut undo = false; + let mut redo = false; let mut tool_switch: Option = None; let palette_open = self.command_palette.visible; @@ -784,6 +815,13 @@ impl eframe::App for AgCanvasApp { if i.modifiers.command && i.key_pressed(egui::Key::H) { toggle_history = true; } + if i.modifiers.command && i.key_pressed(egui::Key::Z) { + if i.modifiers.shift { + redo = true; + } else { + undo = true; + } + } if i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace) { delete_selected = true; } @@ -841,6 +879,77 @@ impl eframe::App for AgCanvasApp { } } + if undo { + let idx = self.active_session_idx; + let previous_svg = self.sessions[idx].svg_source.clone(); + if self.active_session_mut().undo() && self.sessions[idx].svg_source != previous_svg { + self.apply_history_checkout(ctx); + } + } + if redo { + let idx = self.active_session_idx; + let previous_svg = self.sessions[idx].svg_source.clone(); + if self.active_session_mut().redo() && self.sessions[idx].svg_source != previous_svg { + self.apply_history_checkout(ctx); + } + } + + let dropped_files = ctx.input(|i| i.raw.dropped_files.clone()); + for file in dropped_files { + let is_svg = file + .path + .as_ref() + .and_then(|path| path.extension().and_then(|ext| ext.to_str())) + .map(|ext| ext.eq_ignore_ascii_case("svg")) + .or_else(|| { + if file.name.is_empty() { + None + } else { + Path::new(&file.name) + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.eq_ignore_ascii_case("svg")) + } + }) + .unwrap_or(false); + + if !is_svg { + continue; + } + + let svg_result = if let Some(bytes) = file.bytes.as_ref() { + String::from_utf8(bytes.to_vec()).map_err(|e| e.to_string()) + } else if let Some(path) = file.path.as_ref() { + std::fs::read_to_string(path).map_err(|e| e.to_string()) + } else { + Err("Dropped SVG file has no readable source".to_string()) + }; + + match svg_result { + Ok(svg_string) => { + self.load_svg_data(&svg_string, ctx); + let file_name = file + .path + .as_ref() + .and_then(|path| path.file_name()) + .and_then(|name| name.to_str()) + .map(str::to_string) + .or_else(|| { + if file.name.is_empty() { + None + } else { + Some(file.name.clone()) + } + }) + .unwrap_or_else(|| "(dropped svg)".to_string()); + self.set_status(format!("Loaded SVG from file: {}", file_name)); + } + Err(e) => { + self.set_status(format!("Failed to load dropped SVG: {}", e)); + } + } + } + if self.active_session().svg_texture.is_none() && self.active_session().svg_renderer.is_some() { @@ -874,6 +983,28 @@ impl eframe::App for AgCanvasApp { ui.close_menu(); } }); + ui.menu_button("Edit", |ui| { + if ui.button("Undo (Cmd+Z)").clicked() { + let idx = self.active_session_idx; + let previous_svg = self.sessions[idx].svg_source.clone(); + if self.active_session_mut().undo() + && self.sessions[idx].svg_source != previous_svg + { + self.apply_history_checkout(ctx); + } + ui.close_menu(); + } + if ui.button("Redo (Cmd+Shift+Z)").clicked() { + let idx = self.active_session_idx; + let previous_svg = self.sessions[idx].svg_source.clone(); + if self.active_session_mut().redo() + && self.sessions[idx].svg_source != previous_svg + { + self.apply_history_checkout(ctx); + } + ui.close_menu(); + } + }); ui.menu_button("View", |ui| { if ui .checkbox(&mut self.show_tree_panel, "Element Tree") @@ -887,7 +1018,10 @@ impl eframe::App for AgCanvasApp { { ui.close_menu(); } - if ui.checkbox(&mut self.show_history_panel, "History").clicked() { + if ui + .checkbox(&mut self.show_history_panel, "History") + .clicked() + { ui.close_menu(); } ui.separator(); @@ -1082,23 +1216,10 @@ impl eframe::App for AgCanvasApp { if let Some(node_id) = checkout_node_id { let idx = self.active_session_idx; - let previous_svg = self.sessions[idx] - .history - .current_snapshot() - .svg_source - .as_deref() - .map(str::to_string); + let previous_svg = self.sessions[idx].svg_source.clone(); self.sessions[idx].checkout_history(node_id); if self.sessions[idx].svg_source != previous_svg { - if let Some(svg_data) = self.sessions[idx].svg_source.clone() { - if let Ok((tree, usvg_tree)) = parse_svg(&svg_data) { - let session = &mut self.sessions[idx]; - session.description_text = tree.to_semantic_description(); - session.svg_renderer = Some(SvgRenderer::new(usvg_tree)); - session.element_tree = Some(tree); - session.svg_texture = None; - } - } + self.apply_history_checkout(ctx); } } @@ -1456,11 +1577,8 @@ fn render_history_tree( } else if is_active { ui.painter().circle_filled(center, dot_radius, active_fill); } else { - ui.painter().circle_stroke( - center, - dot_radius, - egui::Stroke::new(1.5, inactive_stroke), - ); + ui.painter() + .circle_stroke(center, dot_radius, egui::Stroke::new(1.5, inactive_stroke)); } let source_prefix = match node.source { diff --git a/crates/agcanvas/src/command_palette.rs b/crates/agcanvas/src/command_palette.rs index 910eeed..dc90169 100644 --- a/crates/agcanvas/src/command_palette.rs +++ b/crates/agcanvas/src/command_palette.rs @@ -4,6 +4,8 @@ use egui::{Color32, Key}; pub enum CommandId { NewTab, CloseTab, + Undo, + Redo, SaveWorkspace, ClearCanvas, PasteSvg, @@ -44,6 +46,13 @@ pub fn all_commands() -> Vec { vec![ PaletteCommand::new(CommandId::NewTab, "New Tab", Some("Cmd+T"), "Session"), PaletteCommand::new(CommandId::CloseTab, "Close Tab", Some("Cmd+W"), "Session"), + PaletteCommand::new(CommandId::Undo, "Undo (Cmd+Z)", Some("Cmd+Z"), "Edit"), + PaletteCommand::new( + CommandId::Redo, + "Redo (Cmd+Shift+Z)", + Some("Cmd+Shift+Z"), + "Edit", + ), PaletteCommand::new( CommandId::SaveWorkspace, "Save Workspace", diff --git a/crates/agcanvas/src/history.rs b/crates/agcanvas/src/history.rs index 1fc8f67..f9e5701 100644 --- a/crates/agcanvas/src/history.rs +++ b/crates/agcanvas/src/history.rs @@ -41,6 +41,7 @@ pub struct HistoryNode { pub id: NodeId, pub parent: Option, pub children: Vec, + pub last_active_child: Option, pub label: String, pub source: ChangeSource, pub timestamp: Instant, @@ -60,6 +61,7 @@ impl HistoryTree { id: root, parent: None, children: Vec::new(), + last_active_child: None, label: "Initial State".to_string(), source: ChangeSource::Human, timestamp: Instant::now(), @@ -85,6 +87,7 @@ impl HistoryTree { id, parent: Some(parent), children: Vec::new(), + last_active_child: None, label, source, timestamp: Instant::now(), @@ -92,6 +95,7 @@ impl HistoryTree { }; self.nodes[parent.0].children.push(id); + self.nodes[parent.0].last_active_child = Some(id); self.nodes.push(node); self.current = id; id @@ -99,12 +103,54 @@ impl HistoryTree { pub fn checkout(&mut self, id: NodeId) -> &DocumentSnapshot { assert!(id.0 < self.nodes.len(), "invalid history node id"); + let mut target_ancestors = std::collections::HashSet::new(); + let mut cursor = Some(id); + while let Some(node_id) = cursor { + target_ancestors.insert(node_id); + cursor = self.nodes[node_id.0].parent; + } + + let mut cursor = self.current; + while !target_ancestors.contains(&cursor) { + if let Some(parent) = self.nodes[cursor.0].parent { + self.nodes[parent.0].last_active_child = Some(cursor); + cursor = parent; + } else { + break; + } + } + let lca = cursor; + + let mut path_down = Vec::new(); + let mut cursor = id; + while cursor != lca { + path_down.push(cursor); + if let Some(parent) = self.nodes[cursor.0].parent { + cursor = parent; + } else { + break; + } + } + path_down.reverse(); + + let mut parent = lca; + for child in path_down { + self.nodes[parent.0].last_active_child = Some(child); + parent = child; + } + self.current = id; &self.nodes[id.0].snapshot } - pub fn current_snapshot(&self) -> &DocumentSnapshot { - &self.nodes[self.current.0].snapshot + pub fn undo(&mut self) -> Option<&DocumentSnapshot> { + let parent = self.nodes[self.current.0].parent?; + Some(self.checkout(parent)) + } + + pub fn redo(&mut self) -> Option<&DocumentSnapshot> { + let child = self.nodes[self.current.0].last_active_child?; + Some(self.checkout(child)) } pub fn node(&self, id: NodeId) -> &HistoryNode { @@ -154,6 +200,7 @@ mod tests { assert_eq!(id, NodeId(1)); assert_eq!(tree.current, NodeId(1)); assert_eq!(tree.node(NodeId(0)).children, vec![NodeId(1)]); + assert_eq!(tree.node(NodeId(0)).last_active_child, Some(NodeId(1))); assert_eq!(tree.node(NodeId(1)).parent, Some(NodeId(0))); } @@ -218,6 +265,91 @@ mod tests { assert_eq!(tree.path_to_root(n2), vec![n2, n1, tree.root]); } + #[test] + fn test_undo_moves_to_parent_and_stops_at_root() { + let mut tree = HistoryTree::new(DocumentSnapshot::new_empty()); + let n1 = tree.push( + "n1".to_string(), + ChangeSource::Human, + DocumentSnapshot::from_state(&[], Some("")), + ); + let n2 = tree.push( + "n2".to_string(), + ChangeSource::Human, + DocumentSnapshot::from_state(&[], Some("")), + ); + + assert_eq!(tree.current, n2); + let undo_1 = tree.undo().and_then(|s| s.svg_source.as_deref()); + assert_eq!(undo_1, Some("")); + assert_eq!(tree.current, n1); + + let undo_2 = tree.undo().and_then(|s| s.svg_source.as_deref()); + assert_eq!(undo_2, None); + assert_eq!(tree.current, tree.root); + + assert!(tree.undo().is_none()); + assert_eq!(tree.current, tree.root); + } + + #[test] + fn test_redo_follows_last_active_child() { + let mut tree = HistoryTree::new(DocumentSnapshot::new_empty()); + let n1 = tree.push( + "n1".to_string(), + ChangeSource::Human, + DocumentSnapshot::from_state(&[], Some("")), + ); + let n2 = tree.push( + "n2".to_string(), + ChangeSource::Human, + DocumentSnapshot::from_state(&[], Some("")), + ); + + assert_eq!(tree.current, n2); + let _ = tree.undo(); + assert_eq!(tree.current, n1); + + let redo = tree.redo().and_then(|s| s.svg_source.as_deref()); + assert_eq!(redo, Some("")); + assert_eq!(tree.current, n2); + } + + #[test] + fn test_redo_after_fork_tracks_most_recent_branch() { + let mut tree = HistoryTree::new(DocumentSnapshot::new_empty()); + let fork = tree.push( + "fork".to_string(), + ChangeSource::Human, + DocumentSnapshot::from_state(&[], Some("")), + ); + let branch_a = tree.push( + "branch_a".to_string(), + ChangeSource::Human, + DocumentSnapshot::from_state(&[], Some("")), + ); + + let _ = tree.checkout(fork); + let branch_b = tree.push( + "branch_b".to_string(), + ChangeSource::Human, + DocumentSnapshot::from_state(&[], Some("")), + ); + + let _ = tree.undo(); + assert_eq!(tree.current, fork); + let redo_b = tree.redo().and_then(|s| s.svg_source.as_deref()); + assert_eq!(redo_b, Some("")); + assert_eq!(tree.current, branch_b); + + let _ = tree.checkout(branch_a); + let _ = tree.undo(); + assert_eq!(tree.current, fork); + let redo_a = tree.redo().and_then(|s| s.svg_source.as_deref()); + assert_eq!(redo_a, Some("")); + assert_eq!(tree.current, branch_a); + } + #[test] fn test_snapshot_preserves_elements() { let element = DrawingElement::new( diff --git a/crates/agcanvas/src/session.rs b/crates/agcanvas/src/session.rs index 60b840b..65a602e 100644 --- a/crates/agcanvas/src/session.rs +++ b/crates/agcanvas/src/session.rs @@ -185,6 +185,38 @@ impl Session { self.selected_element_id = None; self.drag_state = DragState::default(); } + + pub fn undo(&mut self) -> bool { + if let Some(snapshot) = self.history.undo().cloned() { + self.drawing_elements = (*snapshot.drawing_elements).clone(); + self.svg_source = snapshot.svg_source.map(|s| s.to_string()); + self.element_tree = None; + self.svg_renderer = None; + self.svg_texture = None; + self.description_text.clear(); + self.selected_element_id = None; + self.drag_state = DragState::default(); + true + } else { + false + } + } + + pub fn redo(&mut self) -> bool { + if let Some(snapshot) = self.history.redo().cloned() { + self.drawing_elements = (*snapshot.drawing_elements).clone(); + self.svg_source = snapshot.svg_source.map(|s| s.to_string()); + self.element_tree = None; + self.svg_renderer = None; + self.svg_texture = None; + self.description_text.clear(); + self.selected_element_id = None; + self.drag_state = DragState::default(); + true + } else { + false + } + } } #[derive(Debug, Clone)]