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:
@@ -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?;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user