feat: add undo/redo (Cmd+Z/Cmd+Shift+Z) and SVG file drag-and-drop

- Undo walks to parent node in history tree, redo follows
  last-active-child for correct branch tracking after forks
- HistoryTree tracks branch recency via last_active_child field,
  updated on push() and checkout() path transitions
- SVG files can be dragged from Finder onto the canvas window
- Edit menu with Undo/Redo items, command palette entries
- 3 new tests: undo, redo, redo-after-fork branch behavior
- 29 tests passing, clippy clean
This commit is contained in:
David Ibia
2026-02-10 00:23:41 +01:00
parent ce2079ad95
commit 1929023409
6 changed files with 331 additions and 32 deletions

View File

@@ -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?;

View File

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

View File

@@ -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<Tool> = 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 {

View File

@@ -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<PaletteCommand> {
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",

View File

@@ -41,6 +41,7 @@ pub struct HistoryNode {
pub id: NodeId,
pub parent: Option<NodeId>,
pub children: Vec<NodeId>,
pub last_active_child: Option<NodeId>,
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("<svg id='n1'/>")),
);
let n2 = tree.push(
"n2".to_string(),
ChangeSource::Human,
DocumentSnapshot::from_state(&[], Some("<svg id='n2'/>")),
);
assert_eq!(tree.current, n2);
let undo_1 = tree.undo().and_then(|s| s.svg_source.as_deref());
assert_eq!(undo_1, Some("<svg id='n1'/>"));
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("<svg id='n1'/>")),
);
let n2 = tree.push(
"n2".to_string(),
ChangeSource::Human,
DocumentSnapshot::from_state(&[], Some("<svg id='n2'/>")),
);
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("<svg id='n2'/>"));
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("<svg id='fork'/>")),
);
let branch_a = tree.push(
"branch_a".to_string(),
ChangeSource::Human,
DocumentSnapshot::from_state(&[], Some("<svg id='a'/>")),
);
let _ = tree.checkout(fork);
let branch_b = tree.push(
"branch_b".to_string(),
ChangeSource::Human,
DocumentSnapshot::from_state(&[], Some("<svg id='b'/>")),
);
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("<svg id='b'/>"));
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("<svg id='a'/>"));
assert_eq!(tree.current, branch_a);
}
#[test]
fn test_snapshot_preserves_elements() {
let element = DrawingElement::new(

View File

@@ -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)]