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};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[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 {
|
struct Cli {
|
||||||
#[arg(long, default_value = "9876")]
|
#[arg(long, default_value = "9876")]
|
||||||
port: u16,
|
port: u16,
|
||||||
@@ -26,7 +29,10 @@ async fn main() -> Result<()> {
|
|||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
let ws_url = format!("ws://127.0.0.1:{}", cli.port);
|
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 server = AgCanvasServer::new(ws_url);
|
||||||
let service = server.serve(rmcp::transport::stdio()).await?;
|
let service = server.serve(rmcp::transport::stdio()).await?;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
use super::protocol::{
|
use super::protocol::{
|
||||||
build_shape, build_style, parse_hex_color, AgentRequest, AgentResponse, CodeGenTarget,
|
build_shape, build_style, parse_hex_color, AgentRequest, AgentResponse, CodeGenTarget,
|
||||||
DrawingCommand, GuiEvent,
|
DrawingCommand, GuiEvent, SessionCommand,
|
||||||
SessionCommand,
|
|
||||||
};
|
};
|
||||||
use crate::drawing::{boolean, DrawingElement, Shape, ShapeStyle};
|
use crate::drawing::{boolean, DrawingElement, Shape, ShapeStyle};
|
||||||
use crate::session::{SessionCreator, SessionStore};
|
use crate::session::{SessionCreator, SessionStore};
|
||||||
@@ -574,7 +573,10 @@ async fn process_request(
|
|||||||
|
|
||||||
let mut selected_elements = Vec::with_capacity(element_ids.len());
|
let mut selected_elements = Vec::with_capacity(element_ids.len());
|
||||||
for element_id in &element_ids {
|
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),
|
Some(element) => selected_elements.push(element),
|
||||||
None => {
|
None => {
|
||||||
return AgentResponse::Error {
|
return AgentResponse::Error {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use crate::persistence::{self, SavedSession, SavedWorkspace};
|
|||||||
use crate::session::{Session, SessionCreator, SessionStore};
|
use crate::session::{Session, SessionCreator, SessionStore};
|
||||||
use crate::svg::{parse_svg, SvgRenderer};
|
use crate::svg::{parse_svg, SvgRenderer};
|
||||||
use egui::{Color32, ColorImage, TextureOptions};
|
use egui::{Color32, ColorImage, TextureOptions};
|
||||||
|
use std::path::Path;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::runtime::Runtime;
|
use tokio::runtime::Runtime;
|
||||||
use tokio::sync::{broadcast, mpsc, RwLock};
|
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) {
|
fn clear_canvas(&mut self) {
|
||||||
let session = self.active_session_mut();
|
let session = self.active_session_mut();
|
||||||
let session_id = session.id.clone();
|
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) {
|
if let Some(session) = self.sessions.iter_mut().find(|s| s.id == session_id) {
|
||||||
session.drawing_elements.clear();
|
session.drawing_elements.clear();
|
||||||
session.selected_element_id = None;
|
session.selected_element_id = None;
|
||||||
session.record_edit(
|
session
|
||||||
"Agent: Clear Canvas",
|
.record_edit("Agent: Clear Canvas", ChangeSource::Agent { name: None });
|
||||||
ChangeSource::Agent { name: None },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -516,6 +529,22 @@ impl AgCanvasApp {
|
|||||||
let idx = self.active_session_idx;
|
let idx = self.active_session_idx;
|
||||||
self.close_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::SaveWorkspace => self.save_workspace(),
|
||||||
CommandId::ClearCanvas => self.clear_canvas(),
|
CommandId::ClearCanvas => self.clear_canvas(),
|
||||||
CommandId::PasteSvg => self.handle_paste(ctx),
|
CommandId::PasteSvg => self.handle_paste(ctx),
|
||||||
@@ -760,6 +789,8 @@ impl eframe::App for AgCanvasApp {
|
|||||||
let mut toggle_palette = false;
|
let mut toggle_palette = false;
|
||||||
let mut delete_selected = false;
|
let mut delete_selected = false;
|
||||||
let mut toggle_history = false;
|
let mut toggle_history = false;
|
||||||
|
let mut undo = false;
|
||||||
|
let mut redo = false;
|
||||||
let mut tool_switch: Option<Tool> = None;
|
let mut tool_switch: Option<Tool> = None;
|
||||||
|
|
||||||
let palette_open = self.command_palette.visible;
|
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) {
|
if i.modifiers.command && i.key_pressed(egui::Key::H) {
|
||||||
toggle_history = true;
|
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) {
|
if i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace) {
|
||||||
delete_selected = true;
|
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()
|
if self.active_session().svg_texture.is_none()
|
||||||
&& self.active_session().svg_renderer.is_some()
|
&& self.active_session().svg_renderer.is_some()
|
||||||
{
|
{
|
||||||
@@ -874,6 +983,28 @@ impl eframe::App for AgCanvasApp {
|
|||||||
ui.close_menu();
|
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| {
|
ui.menu_button("View", |ui| {
|
||||||
if ui
|
if ui
|
||||||
.checkbox(&mut self.show_tree_panel, "Element Tree")
|
.checkbox(&mut self.show_tree_panel, "Element Tree")
|
||||||
@@ -887,7 +1018,10 @@ impl eframe::App for AgCanvasApp {
|
|||||||
{
|
{
|
||||||
ui.close_menu();
|
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.close_menu();
|
||||||
}
|
}
|
||||||
ui.separator();
|
ui.separator();
|
||||||
@@ -1082,23 +1216,10 @@ impl eframe::App for AgCanvasApp {
|
|||||||
|
|
||||||
if let Some(node_id) = checkout_node_id {
|
if let Some(node_id) = checkout_node_id {
|
||||||
let idx = self.active_session_idx;
|
let idx = self.active_session_idx;
|
||||||
let previous_svg = self.sessions[idx]
|
let previous_svg = self.sessions[idx].svg_source.clone();
|
||||||
.history
|
|
||||||
.current_snapshot()
|
|
||||||
.svg_source
|
|
||||||
.as_deref()
|
|
||||||
.map(str::to_string);
|
|
||||||
self.sessions[idx].checkout_history(node_id);
|
self.sessions[idx].checkout_history(node_id);
|
||||||
if self.sessions[idx].svg_source != previous_svg {
|
if self.sessions[idx].svg_source != previous_svg {
|
||||||
if let Some(svg_data) = self.sessions[idx].svg_source.clone() {
|
self.apply_history_checkout(ctx);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1456,11 +1577,8 @@ fn render_history_tree(
|
|||||||
} else if is_active {
|
} else if is_active {
|
||||||
ui.painter().circle_filled(center, dot_radius, active_fill);
|
ui.painter().circle_filled(center, dot_radius, active_fill);
|
||||||
} else {
|
} else {
|
||||||
ui.painter().circle_stroke(
|
ui.painter()
|
||||||
center,
|
.circle_stroke(center, dot_radius, egui::Stroke::new(1.5, inactive_stroke));
|
||||||
dot_radius,
|
|
||||||
egui::Stroke::new(1.5, inactive_stroke),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let source_prefix = match node.source {
|
let source_prefix = match node.source {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ use egui::{Color32, Key};
|
|||||||
pub enum CommandId {
|
pub enum CommandId {
|
||||||
NewTab,
|
NewTab,
|
||||||
CloseTab,
|
CloseTab,
|
||||||
|
Undo,
|
||||||
|
Redo,
|
||||||
SaveWorkspace,
|
SaveWorkspace,
|
||||||
ClearCanvas,
|
ClearCanvas,
|
||||||
PasteSvg,
|
PasteSvg,
|
||||||
@@ -44,6 +46,13 @@ pub fn all_commands() -> Vec<PaletteCommand> {
|
|||||||
vec![
|
vec![
|
||||||
PaletteCommand::new(CommandId::NewTab, "New Tab", Some("Cmd+T"), "Session"),
|
PaletteCommand::new(CommandId::NewTab, "New Tab", Some("Cmd+T"), "Session"),
|
||||||
PaletteCommand::new(CommandId::CloseTab, "Close Tab", Some("Cmd+W"), "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(
|
PaletteCommand::new(
|
||||||
CommandId::SaveWorkspace,
|
CommandId::SaveWorkspace,
|
||||||
"Save Workspace",
|
"Save Workspace",
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ pub struct HistoryNode {
|
|||||||
pub id: NodeId,
|
pub id: NodeId,
|
||||||
pub parent: Option<NodeId>,
|
pub parent: Option<NodeId>,
|
||||||
pub children: Vec<NodeId>,
|
pub children: Vec<NodeId>,
|
||||||
|
pub last_active_child: Option<NodeId>,
|
||||||
pub label: String,
|
pub label: String,
|
||||||
pub source: ChangeSource,
|
pub source: ChangeSource,
|
||||||
pub timestamp: Instant,
|
pub timestamp: Instant,
|
||||||
@@ -60,6 +61,7 @@ impl HistoryTree {
|
|||||||
id: root,
|
id: root,
|
||||||
parent: None,
|
parent: None,
|
||||||
children: Vec::new(),
|
children: Vec::new(),
|
||||||
|
last_active_child: None,
|
||||||
label: "Initial State".to_string(),
|
label: "Initial State".to_string(),
|
||||||
source: ChangeSource::Human,
|
source: ChangeSource::Human,
|
||||||
timestamp: Instant::now(),
|
timestamp: Instant::now(),
|
||||||
@@ -85,6 +87,7 @@ impl HistoryTree {
|
|||||||
id,
|
id,
|
||||||
parent: Some(parent),
|
parent: Some(parent),
|
||||||
children: Vec::new(),
|
children: Vec::new(),
|
||||||
|
last_active_child: None,
|
||||||
label,
|
label,
|
||||||
source,
|
source,
|
||||||
timestamp: Instant::now(),
|
timestamp: Instant::now(),
|
||||||
@@ -92,6 +95,7 @@ impl HistoryTree {
|
|||||||
};
|
};
|
||||||
|
|
||||||
self.nodes[parent.0].children.push(id);
|
self.nodes[parent.0].children.push(id);
|
||||||
|
self.nodes[parent.0].last_active_child = Some(id);
|
||||||
self.nodes.push(node);
|
self.nodes.push(node);
|
||||||
self.current = id;
|
self.current = id;
|
||||||
id
|
id
|
||||||
@@ -99,12 +103,54 @@ impl HistoryTree {
|
|||||||
|
|
||||||
pub fn checkout(&mut self, id: NodeId) -> &DocumentSnapshot {
|
pub fn checkout(&mut self, id: NodeId) -> &DocumentSnapshot {
|
||||||
assert!(id.0 < self.nodes.len(), "invalid history node id");
|
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.current = id;
|
||||||
&self.nodes[id.0].snapshot
|
&self.nodes[id.0].snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn current_snapshot(&self) -> &DocumentSnapshot {
|
pub fn undo(&mut self) -> Option<&DocumentSnapshot> {
|
||||||
&self.nodes[self.current.0].snapshot
|
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 {
|
pub fn node(&self, id: NodeId) -> &HistoryNode {
|
||||||
@@ -154,6 +200,7 @@ mod tests {
|
|||||||
assert_eq!(id, NodeId(1));
|
assert_eq!(id, NodeId(1));
|
||||||
assert_eq!(tree.current, NodeId(1));
|
assert_eq!(tree.current, NodeId(1));
|
||||||
assert_eq!(tree.node(NodeId(0)).children, vec![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)));
|
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]);
|
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]
|
#[test]
|
||||||
fn test_snapshot_preserves_elements() {
|
fn test_snapshot_preserves_elements() {
|
||||||
let element = DrawingElement::new(
|
let element = DrawingElement::new(
|
||||||
|
|||||||
@@ -185,6 +185,38 @@ impl Session {
|
|||||||
self.selected_element_id = None;
|
self.selected_element_id = None;
|
||||||
self.drag_state = DragState::default();
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
|
|||||||
Reference in New Issue
Block a user