diff --git a/README.md b/README.md index 64e634a..0e76bc9 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ agcanvas bridges the gap between visual design and code generation. It's a **col - **Mermaid Diagrams** — Write Mermaid syntax, render as SVG on canvas - **Sessions/Tabs** — Multiple canvases in tabs, each with independent state, creator tracking (human vs agent), descriptions, and timestamps - **Pan/Zoom** — Smooth canvas navigation +- **Session Persistence** — Auto-saves workspace to `~/Library/Application Support/agcanvas/`, restores all tabs on launch +- **Command Palette** — Cmd+K to search and execute any command with fuzzy matching ### AI Agent Integration - **MCP Server** — `agcanvas-mcp` bridge for Claude Code, OpenCode, and Codex @@ -132,6 +134,8 @@ agcanvas-mcp --help | Paste SVG | Cmd+V | | New Tab | Cmd+T | | Close Tab | Cmd+W | +| Save workspace | Cmd+S | +| Command palette | Cmd+K | | Reset zoom | Cmd+0 | ## MCP Server (AI Agent Integration) @@ -338,6 +342,8 @@ crates/ │ ├── main.rs # Entry point, window setup │ ├── app.rs # Main app state, UI, toolbar, drawing interaction │ ├── session.rs # Session/tab state management +│ ├── persistence.rs # Workspace save/load (~/.agcanvas/) +│ ├── command_palette.rs # Cmd+K command palette with fuzzy search │ ├── element_tree.rs # Structured element representation │ ├── clipboard.rs # System clipboard integration │ ├── mermaid.rs # Mermaid → SVG rendering @@ -372,6 +378,7 @@ crates/ | `arboard` | Clipboard access | | `tokio-tungstenite` | WebSocket (both server and client) | | `rmcp` | MCP server SDK (Anthropic official) | +| `dirs` | Platform data directory paths | | `serde`/`serde_json` | Serialization | ## Roadmap @@ -383,6 +390,8 @@ crates/ - [x] MCP server bridge for AI coding tools - [x] Agent draw commands (modify canvas from agent) - [x] Session metadata (creator tracking, descriptions, timestamps, sorting) +- [x] Session persistence (auto-save/restore workspace) +- [x] Command palette (Cmd+K) - [ ] Real code generation (not just stubs) - [ ] Export to file - [ ] Diff view (before/after agent changes) diff --git a/crates/agcanvas/Cargo.toml b/crates/agcanvas/Cargo.toml index ad6323d..5705d7b 100644 --- a/crates/agcanvas/Cargo.toml +++ b/crates/agcanvas/Cargo.toml @@ -39,6 +39,9 @@ mermaid-rs-renderer = { version = "0.1.2", default-features = false } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +# Filesystem paths +dirs = "5.0" + # Error handling anyhow = "1.0" thiserror = "1.0" diff --git a/crates/agcanvas/src/app.rs b/crates/agcanvas/src/app.rs index da33f58..577b7a7 100644 --- a/crates/agcanvas/src/app.rs +++ b/crates/agcanvas/src/app.rs @@ -1,11 +1,13 @@ use crate::agent::{AgentServer, DrawingCommand, GuiEvent, SessionCommand}; use crate::canvas::{CanvasInteraction, CanvasState}; use crate::clipboard::ClipboardManager; +use crate::command_palette::{CommandId, CommandPalette}; use crate::drawing::{ draw_creation_preview, draw_elements, draw_selection, find_handle_at_screen_pos, screen_to_canvas, DragState, DrawingElement, Shape, ShapeStyle, Tool, }; use crate::mermaid::render_mermaid_to_svg; +use crate::persistence::{self, SavedSession, SavedWorkspace}; use crate::session::{Session, SessionCreator, SessionStore}; use crate::svg::{parse_svg, SvgRenderer}; use egui::{Color32, ColorImage, TextureOptions}; @@ -36,6 +38,8 @@ pub struct AgCanvasApp { text_input_buffer: String, text_input_pos: Option, last_drawing_sync: std::time::Instant, + last_auto_save: std::time::Instant, + command_palette: CommandPalette, } impl AgCanvasApp { @@ -76,9 +80,13 @@ impl AgCanvasApp { text_input_buffer: String::new(), text_input_pos: None, last_drawing_sync: std::time::Instant::now(), + last_auto_save: std::time::Instant::now(), + command_palette: CommandPalette::new(), }; - app.create_session(); + if !app.restore_workspace(&cc.egui_ctx) { + app.create_session(); + } app } @@ -180,6 +188,7 @@ impl AgCanvasApp { session.element_tree = Some(tree.clone()); session.description_text = tree.to_semantic_description(); session.svg_renderer = Some(SvgRenderer::new(usvg_tree)); + session.svg_source = Some(svg_data.to_string()); session.svg_texture = None; session .canvas_state @@ -383,6 +392,133 @@ impl AgCanvasApp { }); } + fn build_saved_workspace(&self) -> SavedWorkspace { + let sessions: Vec = self + .sessions + .iter() + .map(|s| SavedSession { + id: s.id.clone(), + name: s.name.clone(), + canvas_state: s.canvas_state.clone(), + svg_source: s.svg_source.clone(), + drawing_elements: s.drawing_elements.clone(), + description: s.description.clone(), + created_by: s.created_by.clone(), + created_at: s.created_at, + }) + .collect(); + SavedWorkspace::new(self.active_session_idx, self.session_counter, sessions) + } + + fn save_workspace(&mut self) { + let workspace = self.build_saved_workspace(); + match persistence::save_workspace(&workspace) { + Ok(()) => self.set_status(format!("Saved {} session(s)", workspace.sessions.len())), + Err(e) => self.set_status(format!("Save failed: {}", e)), + } + } + + fn restore_workspace(&mut self, ctx: &egui::Context) -> bool { + let workspace = match persistence::load_workspace() { + Ok(Some(w)) if !w.sessions.is_empty() => w, + Ok(_) => return false, + Err(e) => { + tracing::warn!("Failed to load workspace: {}", e); + return false; + } + }; + + self.session_counter = workspace.session_counter; + + for saved in &workspace.sessions { + let mut session = Session::new( + saved.id.clone(), + saved.name.clone(), + saved.created_by.clone(), + ); + session.canvas_state = saved.canvas_state.clone(); + session.drawing_elements = saved.drawing_elements.clone(); + session.description = saved.description.clone(); + session.created_at = saved.created_at; + + if let Some(svg_data) = &saved.svg_source { + if let Ok((tree, usvg_tree)) = parse_svg(svg_data) { + session.description_text = tree.to_semantic_description(); + session.svg_renderer = Some(SvgRenderer::new(usvg_tree)); + session.element_tree = Some(tree); + session.svg_source = Some(svg_data.clone()); + } + } + + let info = session.info(); + let tree_clone = session.element_tree.clone(); + self.sessions.push(session); + + let sessions_handle = self.sessions_handle.clone(); + let info_clone = info; + std::thread::spawn(move || { + let rt = Runtime::new().unwrap(); + rt.block_on(async { + let mut store = sessions_handle.write().await; + store.add_session(info_clone, tree_clone); + }); + }); + } + + self.active_session_idx = workspace + .active_session_idx + .min(self.sessions.len().saturating_sub(1)); + + if let Some(session) = self.sessions.get(self.active_session_idx) { + let sid = session.id.clone(); + let sessions_handle = self.sessions_handle.clone(); + std::thread::spawn(move || { + let rt = Runtime::new().unwrap(); + rt.block_on(async { + let mut store = sessions_handle.write().await; + store.set_active(&sid); + }); + }); + } + + let count = self.sessions.len(); + self.set_status(format!("Restored {} session(s)", count)); + ctx.request_repaint(); + true + } + + fn execute_command(&mut self, cmd: CommandId, ctx: &egui::Context) { + match cmd { + CommandId::NewTab => self.create_session(), + CommandId::CloseTab => { + let idx = self.active_session_idx; + self.close_session(idx); + } + CommandId::SaveWorkspace => self.save_workspace(), + CommandId::ClearCanvas => self.clear_canvas(), + CommandId::PasteSvg => self.handle_paste(ctx), + CommandId::PasteMermaid => self.show_mermaid_dialog = true, + CommandId::ToolSelect => self.active_session_mut().active_tool = Tool::Select, + CommandId::ToolRectangle => self.active_session_mut().active_tool = Tool::Rectangle, + CommandId::ToolEllipse => self.active_session_mut().active_tool = Tool::Ellipse, + CommandId::ToolLine => self.active_session_mut().active_tool = Tool::Line, + CommandId::ToolArrow => self.active_session_mut().active_tool = Tool::Arrow, + CommandId::ToolText => self.active_session_mut().active_tool = Tool::Text, + CommandId::ResetZoom => self.active_session_mut().canvas_state.reset(), + CommandId::FitToView => { + let session = self.active_session_mut(); + if let Some(renderer) = &session.svg_renderer { + let (w, h) = renderer.size(); + session + .canvas_state + .fit_to_rect(egui::vec2(w, h), ctx.screen_rect().size() * 0.8); + } + } + CommandId::ToggleTreePanel => self.show_tree_panel = !self.show_tree_panel, + CommandId::ToggleDescription => self.show_description = !self.show_description, + } + } + fn handle_drawing_input(&mut self, response: &egui::Response, canvas_center: egui::Pos2) { let session = self.active_session_mut(); let offset = session.canvas_state.offset; @@ -582,44 +718,63 @@ impl eframe::App for AgCanvasApp { let mut paste = false; let mut new_tab = false; let mut close_tab = false; + let mut save_workspace = false; + let mut toggle_palette = false; let mut delete_selected = false; let mut tool_switch: Option = None; + let palette_open = self.command_palette.visible; + ctx.input(|i| { - if i.modifiers.command && i.key_pressed(egui::Key::V) { - paste = true; + if i.modifiers.command && i.key_pressed(egui::Key::K) { + toggle_palette = true; } - if i.modifiers.command && i.key_pressed(egui::Key::T) { - new_tab = true; + if i.modifiers.command && i.key_pressed(egui::Key::S) { + save_workspace = true; } - if i.modifiers.command && i.key_pressed(egui::Key::W) { - close_tab = true; - } - if i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace) { - delete_selected = true; - } - if i.key_pressed(egui::Key::Escape) { - tool_switch = Some(Tool::Select); - } - if !i.modifiers.command { - if i.key_pressed(egui::Key::V) { + if !palette_open { + if i.modifiers.command && i.key_pressed(egui::Key::V) { + paste = true; + } + if i.modifiers.command && i.key_pressed(egui::Key::T) { + new_tab = true; + } + if i.modifiers.command && i.key_pressed(egui::Key::W) { + close_tab = true; + } + if i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace) { + delete_selected = true; + } + if i.key_pressed(egui::Key::Escape) { tool_switch = Some(Tool::Select); } - if i.key_pressed(egui::Key::R) { - tool_switch = Some(Tool::Rectangle); - } - if i.key_pressed(egui::Key::E) { - tool_switch = Some(Tool::Ellipse); - } - if i.key_pressed(egui::Key::L) { - tool_switch = Some(Tool::Line); - } - if i.key_pressed(egui::Key::A) { - tool_switch = Some(Tool::Arrow); + if !i.modifiers.command { + if i.key_pressed(egui::Key::V) { + tool_switch = Some(Tool::Select); + } + if i.key_pressed(egui::Key::R) { + tool_switch = Some(Tool::Rectangle); + } + if i.key_pressed(egui::Key::E) { + tool_switch = Some(Tool::Ellipse); + } + if i.key_pressed(egui::Key::L) { + tool_switch = Some(Tool::Line); + } + if i.key_pressed(egui::Key::A) { + tool_switch = Some(Tool::Arrow); + } } } }); + if toggle_palette { + self.command_palette.toggle(); + } + if save_workspace { + self.save_workspace(); + } + if paste { self.handle_paste(ctx); } @@ -1057,6 +1212,10 @@ impl eframe::App for AgCanvasApp { } }); + if let Some(cmd) = self.command_palette.show(ctx) { + self.execute_command(cmd, ctx); + } + if self.last_drawing_sync.elapsed().as_millis() > 500 { self.last_drawing_sync = std::time::Instant::now(); self.sync_drawing_elements_to_store(); @@ -1072,8 +1231,25 @@ impl eframe::App for AgCanvasApp { }); } + if self.last_auto_save.elapsed().as_secs() > 30 { + self.last_auto_save = std::time::Instant::now(); + let workspace = self.build_saved_workspace(); + std::thread::spawn(move || { + if let Err(e) = persistence::save_workspace(&workspace) { + tracing::warn!("Auto-save failed: {}", e); + } + }); + } + ctx.request_repaint(); } + + fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { + let workspace = self.build_saved_workspace(); + if let Err(e) = persistence::save_workspace(&workspace) { + tracing::error!("Failed to save workspace on exit: {}", e); + } + } } fn configure_fonts(ctx: &egui::Context) { diff --git a/crates/agcanvas/src/canvas/state.rs b/crates/agcanvas/src/canvas/state.rs index 87ed07b..1eb2800 100644 --- a/crates/agcanvas/src/canvas/state.rs +++ b/crates/agcanvas/src/canvas/state.rs @@ -1,6 +1,7 @@ use egui::{Pos2, Vec2}; +use serde::{Deserialize, Serialize}; -#[derive(Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct CanvasState { pub offset: Vec2, pub zoom: f32, diff --git a/crates/agcanvas/src/command_palette.rs b/crates/agcanvas/src/command_palette.rs new file mode 100644 index 0000000..89a255d --- /dev/null +++ b/crates/agcanvas/src/command_palette.rs @@ -0,0 +1,353 @@ +use egui::{Color32, Key}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum CommandId { + NewTab, + CloseTab, + SaveWorkspace, + ClearCanvas, + PasteSvg, + PasteMermaid, + ToolSelect, + ToolRectangle, + ToolEllipse, + ToolLine, + ToolArrow, + ToolText, + ResetZoom, + FitToView, + ToggleTreePanel, + ToggleDescription, +} + +#[derive(Debug, Clone)] +pub struct PaletteCommand { + pub id: CommandId, + pub label: String, + pub shortcut: Option, + pub category: &'static str, +} + +impl PaletteCommand { + pub fn new(id: CommandId, label: &str, shortcut: Option<&str>, category: &'static str) -> Self { + Self { + id, + label: label.to_string(), + shortcut: shortcut.map(|s| s.to_string()), + category, + } + } +} + +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::SaveWorkspace, + "Save Workspace", + Some("Cmd+S"), + "Session", + ), + PaletteCommand::new(CommandId::ClearCanvas, "Clear Canvas", None, "Canvas"), + PaletteCommand::new(CommandId::PasteSvg, "Paste SVG", Some("Cmd+V"), "Canvas"), + PaletteCommand::new( + CommandId::PasteMermaid, + "Paste Mermaid Diagram", + None, + "Canvas", + ), + PaletteCommand::new(CommandId::ToolSelect, "Select Tool", Some("V"), "Tool"), + PaletteCommand::new( + CommandId::ToolRectangle, + "Rectangle Tool", + Some("R"), + "Tool", + ), + PaletteCommand::new(CommandId::ToolEllipse, "Ellipse Tool", Some("E"), "Tool"), + PaletteCommand::new(CommandId::ToolLine, "Line Tool", Some("L"), "Tool"), + PaletteCommand::new(CommandId::ToolArrow, "Arrow Tool", Some("A"), "Tool"), + PaletteCommand::new(CommandId::ToolText, "Text Tool", Some("T"), "Tool"), + PaletteCommand::new(CommandId::ResetZoom, "Reset Zoom", Some("Cmd+0"), "View"), + PaletteCommand::new(CommandId::FitToView, "Fit to View", None, "View"), + PaletteCommand::new( + CommandId::ToggleTreePanel, + "Toggle Element Tree Panel", + None, + "View", + ), + PaletteCommand::new( + CommandId::ToggleDescription, + "Toggle Description Panel", + None, + "View", + ), + ] +} + +pub struct CommandPalette { + pub visible: bool, + pub query: String, + pub selected_idx: usize, + commands: Vec, + filtered: Vec, +} + +impl CommandPalette { + pub fn new() -> Self { + let commands = all_commands(); + let filtered: Vec = (0..commands.len()).collect(); + Self { + visible: false, + query: String::new(), + selected_idx: 0, + commands, + filtered, + } + } + + pub fn open(&mut self) { + self.visible = true; + self.query.clear(); + self.selected_idx = 0; + self.update_filter(); + } + + pub fn close(&mut self) { + self.visible = false; + self.query.clear(); + self.selected_idx = 0; + } + + pub fn toggle(&mut self) { + if self.visible { + self.close(); + } else { + self.open(); + } + } + + fn update_filter(&mut self) { + if self.query.is_empty() { + self.filtered = (0..self.commands.len()).collect(); + } else { + let query_lower = self.query.to_lowercase(); + self.filtered = self + .commands + .iter() + .enumerate() + .filter(|(_, cmd)| fuzzy_match(&cmd.label, &query_lower)) + .map(|(i, _)| i) + .collect(); + } + if self.selected_idx >= self.filtered.len() { + self.selected_idx = 0; + } + } + + pub fn show(&mut self, ctx: &egui::Context) -> Option { + if !self.visible { + return None; + } + + let mut executed: Option = None; + let mut should_close = false; + + let screen = ctx.screen_rect(); + let palette_width = (screen.width() * 0.5).clamp(300.0, 500.0); + let palette_x = (screen.width() - palette_width) / 2.0; + let palette_y = screen.height() * 0.15; + + egui::Area::new(egui::Id::new("command_palette_backdrop")) + .fixed_pos(screen.min) + .order(egui::Order::Foreground) + .show(ctx, |ui| { + let response = ui.allocate_response(screen.size(), egui::Sense::click()); + if response.clicked() { + should_close = true; + } + ui.painter().rect_filled( + screen, + 0.0, + Color32::from_rgba_unmultiplied(0, 0, 0, 120), + ); + }); + + egui::Area::new(egui::Id::new("command_palette")) + .fixed_pos(egui::pos2(palette_x, palette_y)) + .order(egui::Order::Foreground) + .show(ctx, |ui| { + egui::Frame::popup(ui.style()) + .fill(Color32::from_gray(30)) + .stroke(egui::Stroke::new(1.0, Color32::from_gray(60))) + .rounding(8.0) + .inner_margin(8.0) + .show(ui, |ui| { + ui.set_width(palette_width); + + let text_edit = egui::TextEdit::singleline(&mut self.query) + .desired_width(palette_width - 16.0) + .font(egui::TextStyle::Body) + .hint_text("Type a command..."); + let response = ui.add(text_edit); + response.request_focus(); + + if response.changed() { + self.update_filter(); + } + + ctx.input(|i| { + if i.key_pressed(Key::Escape) { + should_close = true; + } + if i.key_pressed(Key::ArrowDown) && !self.filtered.is_empty() { + self.selected_idx = (self.selected_idx + 1) % self.filtered.len(); + } + if i.key_pressed(Key::ArrowUp) && !self.filtered.is_empty() { + self.selected_idx = if self.selected_idx == 0 { + self.filtered.len() - 1 + } else { + self.selected_idx - 1 + }; + } + if i.key_pressed(Key::Enter) && !self.filtered.is_empty() { + let cmd_idx = self.filtered[self.selected_idx]; + executed = Some(self.commands[cmd_idx].id); + should_close = true; + } + }); + + ui.add_space(4.0); + ui.separator(); + + let max_visible = 10; + egui::ScrollArea::vertical() + .max_height(max_visible as f32 * 28.0) + .show(ui, |ui| { + if self.filtered.is_empty() { + ui.label( + egui::RichText::new("No matching commands") + .color(Color32::from_gray(100)), + ); + } + + for (display_idx, &cmd_idx) in self.filtered.iter().enumerate() { + let cmd = &self.commands[cmd_idx]; + let is_selected = display_idx == self.selected_idx; + + let bg = if is_selected { + Color32::from_gray(50) + } else { + Color32::TRANSPARENT + }; + + let frame = egui::Frame::none() + .fill(bg) + .rounding(4.0) + .inner_margin(egui::Margin::symmetric(8.0, 4.0)); + + let resp = frame + .show(ui, |ui| { + ui.set_width(palette_width - 32.0); + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(&cmd.label) + .color(Color32::WHITE), + ); + ui.with_layout( + egui::Layout::right_to_left( + egui::Align::Center, + ), + |ui| { + if let Some(shortcut) = &cmd.shortcut { + ui.label( + egui::RichText::new(shortcut) + .small() + .color(Color32::from_gray(100)), + ); + } + ui.label( + egui::RichText::new(cmd.category) + .small() + .color(Color32::from_gray(80)), + ); + }, + ); + }); + }) + .response; + + if resp.clicked() { + executed = Some(cmd.id); + should_close = true; + } + if resp.hovered() { + self.selected_idx = display_idx; + } + } + }); + }); + }); + + if should_close { + self.close(); + } + + executed + } +} + +fn fuzzy_match(text: &str, query: &str) -> bool { + let text_lower = text.to_lowercase(); + let mut text_chars = text_lower.chars(); + for qchar in query.chars() { + loop { + match text_chars.next() { + Some(tc) if tc == qchar => break, + Some(_) => continue, + None => return false, + } + } + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fuzzy_match_exact() { + assert!(fuzzy_match("New Tab", "new tab")); + } + + #[test] + fn fuzzy_match_subsequence() { + assert!(fuzzy_match("New Tab", "ntb")); + } + + #[test] + fn fuzzy_match_no_match() { + assert!(!fuzzy_match("New Tab", "xyz")); + } + + #[test] + fn fuzzy_match_empty_query() { + assert!(fuzzy_match("Anything", "")); + } + + #[test] + fn palette_filters_commands() { + let mut palette = CommandPalette::new(); + palette.open(); + palette.query = "rect".to_string(); + palette.update_filter(); + assert!(palette.filtered.len() >= 1); + let matched: Vec<_> = palette + .filtered + .iter() + .map(|&i| palette.commands[i].id) + .collect(); + assert!(matched.contains(&CommandId::ToolRectangle)); + } +} diff --git a/crates/agcanvas/src/main.rs b/crates/agcanvas/src/main.rs index 0d6b121..ce88e94 100644 --- a/crates/agcanvas/src/main.rs +++ b/crates/agcanvas/src/main.rs @@ -2,9 +2,11 @@ mod agent; mod app; mod canvas; mod clipboard; +mod command_palette; mod drawing; mod element_tree; mod mermaid; +mod persistence; mod session; mod svg; diff --git a/crates/agcanvas/src/persistence.rs b/crates/agcanvas/src/persistence.rs new file mode 100644 index 0000000..2378d8c --- /dev/null +++ b/crates/agcanvas/src/persistence.rs @@ -0,0 +1,132 @@ +use crate::canvas::CanvasState; +use crate::drawing::DrawingElement; +use crate::session::SessionCreator; +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SavedSession { + pub id: String, + pub name: String, + pub canvas_state: CanvasState, + pub svg_source: Option, + pub drawing_elements: Vec, + pub description: Option, + pub created_by: SessionCreator, + pub created_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SavedWorkspace { + pub version: u32, + pub active_session_idx: usize, + pub session_counter: usize, + pub sessions: Vec, +} + +impl SavedWorkspace { + const CURRENT_VERSION: u32 = 1; + + pub fn new( + active_session_idx: usize, + session_counter: usize, + sessions: Vec, + ) -> Self { + Self { + version: Self::CURRENT_VERSION, + active_session_idx, + session_counter, + sessions, + } + } +} + +fn data_dir() -> Result { + let base = dirs::data_dir() + .or_else(dirs::home_dir) + .ok_or_else(|| anyhow::anyhow!("Cannot determine home directory"))?; + Ok(base.join("agcanvas")) +} + +fn workspace_path() -> Result { + Ok(data_dir()?.join("workspace.json")) +} + +pub fn save_workspace(workspace: &SavedWorkspace) -> Result<()> { + let path = workspace_path()?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + let tmp_path = path.with_extension("json.tmp"); + let json = serde_json::to_string_pretty(workspace)?; + std::fs::write(&tmp_path, &json)?; + std::fs::rename(&tmp_path, &path)?; + + tracing::debug!("Saved workspace: {} sessions", workspace.sessions.len()); + Ok(()) +} + +pub fn load_workspace() -> Result> { + let path = workspace_path()?; + if !path.exists() { + return Ok(None); + } + + let json = std::fs::read_to_string(&path)?; + let workspace: SavedWorkspace = serde_json::from_str(&json)?; + + if workspace.version > SavedWorkspace::CURRENT_VERSION { + tracing::warn!( + "Workspace file version {} is newer than supported version {}", + workspace.version, + SavedWorkspace::CURRENT_VERSION + ); + } + + tracing::info!("Loaded workspace: {} sessions", workspace.sessions.len()); + Ok(Some(workspace)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip_empty_workspace() { + let workspace = SavedWorkspace::new(0, 1, Vec::new()); + let json = serde_json::to_string(&workspace).unwrap(); + let loaded: SavedWorkspace = serde_json::from_str(&json).unwrap(); + assert_eq!(loaded.version, 1); + assert_eq!(loaded.sessions.len(), 0); + assert_eq!(loaded.session_counter, 1); + } + + #[test] + fn round_trip_workspace_with_session() { + let session = SavedSession { + id: "session-1".to_string(), + name: "Test".to_string(), + canvas_state: CanvasState::default(), + svg_source: Some("".to_string()), + drawing_elements: Vec::new(), + description: Some("A test session".to_string()), + created_by: SessionCreator::Human, + created_at: 1234567890, + }; + let workspace = SavedWorkspace::new(0, 1, vec![session]); + let json = serde_json::to_string(&workspace).unwrap(); + let loaded: SavedWorkspace = serde_json::from_str(&json).unwrap(); + assert_eq!(loaded.sessions.len(), 1); + assert_eq!(loaded.sessions[0].name, "Test"); + assert_eq!( + loaded.sessions[0].svg_source.as_deref(), + Some("") + ); + assert_eq!( + loaded.sessions[0].description.as_deref(), + Some("A test session") + ); + } +} diff --git a/crates/agcanvas/src/session.rs b/crates/agcanvas/src/session.rs index 201bea4..013bb33 100644 --- a/crates/agcanvas/src/session.rs +++ b/crates/agcanvas/src/session.rs @@ -83,6 +83,7 @@ pub struct Session { pub svg_renderer: Option, pub svg_texture: Option, pub element_tree: Option, + pub svg_source: Option, pub description_text: String, pub drawing_elements: Vec, @@ -104,6 +105,7 @@ impl Session { svg_renderer: None, svg_texture: None, element_tree: None, + svg_source: None, description_text: String::new(), drawing_elements: Vec::new(), selected_element_id: None, @@ -137,6 +139,7 @@ impl Session { self.svg_renderer = None; self.svg_texture = None; self.element_tree = None; + self.svg_source = None; self.description_text.clear(); self.drawing_elements.clear(); self.selected_element_id = None;