Add session persistence and Cmd+K command palette

Workspace auto-saves every 30s and on exit, restores all tabs on
launch. Sessions persist drawing elements, SVG source, canvas state,
and metadata to ~/Library/Application Support/agcanvas/workspace.json.
Manual save via Cmd+S.

Command palette (Cmd+K) with fuzzy search over all commands: session
management, tool switching, view toggles, canvas operations. Arrow
keys to navigate, Enter to execute, Esc to dismiss.
This commit is contained in:
David Ibia
2026-02-09 17:44:22 +01:00
parent 43f1beea16
commit 233cb5798c
8 changed files with 707 additions and 28 deletions

View File

@@ -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 - **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 - **Sessions/Tabs** — Multiple canvases in tabs, each with independent state, creator tracking (human vs agent), descriptions, and timestamps
- **Pan/Zoom** — Smooth canvas navigation - **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 ### AI Agent Integration
- **MCP Server** — `agcanvas-mcp` bridge for Claude Code, OpenCode, and Codex - **MCP Server** — `agcanvas-mcp` bridge for Claude Code, OpenCode, and Codex
@@ -132,6 +134,8 @@ agcanvas-mcp --help
| Paste SVG | Cmd+V | | Paste SVG | Cmd+V |
| New Tab | Cmd+T | | New Tab | Cmd+T |
| Close Tab | Cmd+W | | Close Tab | Cmd+W |
| Save workspace | Cmd+S |
| Command palette | Cmd+K |
| Reset zoom | Cmd+0 | | Reset zoom | Cmd+0 |
## MCP Server (AI Agent Integration) ## MCP Server (AI Agent Integration)
@@ -338,6 +342,8 @@ crates/
│ ├── main.rs # Entry point, window setup │ ├── main.rs # Entry point, window setup
│ ├── app.rs # Main app state, UI, toolbar, drawing interaction │ ├── app.rs # Main app state, UI, toolbar, drawing interaction
│ ├── session.rs # Session/tab state management │ ├── 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 │ ├── element_tree.rs # Structured element representation
│ ├── clipboard.rs # System clipboard integration │ ├── clipboard.rs # System clipboard integration
│ ├── mermaid.rs # Mermaid → SVG rendering │ ├── mermaid.rs # Mermaid → SVG rendering
@@ -372,6 +378,7 @@ crates/
| `arboard` | Clipboard access | | `arboard` | Clipboard access |
| `tokio-tungstenite` | WebSocket (both server and client) | | `tokio-tungstenite` | WebSocket (both server and client) |
| `rmcp` | MCP server SDK (Anthropic official) | | `rmcp` | MCP server SDK (Anthropic official) |
| `dirs` | Platform data directory paths |
| `serde`/`serde_json` | Serialization | | `serde`/`serde_json` | Serialization |
## Roadmap ## Roadmap
@@ -383,6 +390,8 @@ crates/
- [x] MCP server bridge for AI coding tools - [x] MCP server bridge for AI coding tools
- [x] Agent draw commands (modify canvas from agent) - [x] Agent draw commands (modify canvas from agent)
- [x] Session metadata (creator tracking, descriptions, timestamps, sorting) - [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) - [ ] Real code generation (not just stubs)
- [ ] Export to file - [ ] Export to file
- [ ] Diff view (before/after agent changes) - [ ] Diff view (before/after agent changes)

View File

@@ -39,6 +39,9 @@ mermaid-rs-renderer = { version = "0.1.2", default-features = false }
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Filesystem paths
dirs = "5.0"
# Error handling # Error handling
anyhow = "1.0" anyhow = "1.0"
thiserror = "1.0" thiserror = "1.0"

View File

@@ -1,11 +1,13 @@
use crate::agent::{AgentServer, DrawingCommand, GuiEvent, SessionCommand}; use crate::agent::{AgentServer, DrawingCommand, GuiEvent, SessionCommand};
use crate::canvas::{CanvasInteraction, CanvasState}; use crate::canvas::{CanvasInteraction, CanvasState};
use crate::clipboard::ClipboardManager; use crate::clipboard::ClipboardManager;
use crate::command_palette::{CommandId, CommandPalette};
use crate::drawing::{ use crate::drawing::{
draw_creation_preview, draw_elements, draw_selection, find_handle_at_screen_pos, draw_creation_preview, draw_elements, draw_selection, find_handle_at_screen_pos,
screen_to_canvas, DragState, DrawingElement, Shape, ShapeStyle, Tool, screen_to_canvas, DragState, DrawingElement, Shape, ShapeStyle, Tool,
}; };
use crate::mermaid::render_mermaid_to_svg; use crate::mermaid::render_mermaid_to_svg;
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};
@@ -36,6 +38,8 @@ pub struct AgCanvasApp {
text_input_buffer: String, text_input_buffer: String,
text_input_pos: Option<egui::Pos2>, text_input_pos: Option<egui::Pos2>,
last_drawing_sync: std::time::Instant, last_drawing_sync: std::time::Instant,
last_auto_save: std::time::Instant,
command_palette: CommandPalette,
} }
impl AgCanvasApp { impl AgCanvasApp {
@@ -76,9 +80,13 @@ impl AgCanvasApp {
text_input_buffer: String::new(), text_input_buffer: String::new(),
text_input_pos: None, text_input_pos: None,
last_drawing_sync: std::time::Instant::now(), last_drawing_sync: std::time::Instant::now(),
last_auto_save: std::time::Instant::now(),
command_palette: CommandPalette::new(),
}; };
if !app.restore_workspace(&cc.egui_ctx) {
app.create_session(); app.create_session();
}
app app
} }
@@ -180,6 +188,7 @@ impl AgCanvasApp {
session.element_tree = Some(tree.clone()); session.element_tree = Some(tree.clone());
session.description_text = tree.to_semantic_description(); session.description_text = tree.to_semantic_description();
session.svg_renderer = Some(SvgRenderer::new(usvg_tree)); session.svg_renderer = Some(SvgRenderer::new(usvg_tree));
session.svg_source = Some(svg_data.to_string());
session.svg_texture = None; session.svg_texture = None;
session session
.canvas_state .canvas_state
@@ -383,6 +392,133 @@ impl AgCanvasApp {
}); });
} }
fn build_saved_workspace(&self) -> SavedWorkspace {
let sessions: Vec<SavedSession> = 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) { fn handle_drawing_input(&mut self, response: &egui::Response, canvas_center: egui::Pos2) {
let session = self.active_session_mut(); let session = self.active_session_mut();
let offset = session.canvas_state.offset; let offset = session.canvas_state.offset;
@@ -582,10 +718,21 @@ impl eframe::App for AgCanvasApp {
let mut paste = false; let mut paste = false;
let mut new_tab = false; let mut new_tab = false;
let mut close_tab = false; let mut close_tab = false;
let mut save_workspace = false;
let mut toggle_palette = false;
let mut delete_selected = false; let mut delete_selected = false;
let mut tool_switch: Option<Tool> = None; let mut tool_switch: Option<Tool> = None;
let palette_open = self.command_palette.visible;
ctx.input(|i| { ctx.input(|i| {
if i.modifiers.command && i.key_pressed(egui::Key::K) {
toggle_palette = true;
}
if i.modifiers.command && i.key_pressed(egui::Key::S) {
save_workspace = true;
}
if !palette_open {
if i.modifiers.command && i.key_pressed(egui::Key::V) { if i.modifiers.command && i.key_pressed(egui::Key::V) {
paste = true; paste = true;
} }
@@ -618,8 +765,16 @@ impl eframe::App for AgCanvasApp {
tool_switch = Some(Tool::Arrow); tool_switch = Some(Tool::Arrow);
} }
} }
}
}); });
if toggle_palette {
self.command_palette.toggle();
}
if save_workspace {
self.save_workspace();
}
if paste { if paste {
self.handle_paste(ctx); 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 { if self.last_drawing_sync.elapsed().as_millis() > 500 {
self.last_drawing_sync = std::time::Instant::now(); self.last_drawing_sync = std::time::Instant::now();
self.sync_drawing_elements_to_store(); 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(); 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) { fn configure_fonts(ctx: &egui::Context) {

View File

@@ -1,6 +1,7 @@
use egui::{Pos2, Vec2}; use egui::{Pos2, Vec2};
use serde::{Deserialize, Serialize};
#[derive(Clone)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CanvasState { pub struct CanvasState {
pub offset: Vec2, pub offset: Vec2,
pub zoom: f32, pub zoom: f32,

View File

@@ -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<String>,
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<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::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<PaletteCommand>,
filtered: Vec<usize>,
}
impl CommandPalette {
pub fn new() -> Self {
let commands = all_commands();
let filtered: Vec<usize> = (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<CommandId> {
if !self.visible {
return None;
}
let mut executed: Option<CommandId> = 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));
}
}

View File

@@ -2,9 +2,11 @@ mod agent;
mod app; mod app;
mod canvas; mod canvas;
mod clipboard; mod clipboard;
mod command_palette;
mod drawing; mod drawing;
mod element_tree; mod element_tree;
mod mermaid; mod mermaid;
mod persistence;
mod session; mod session;
mod svg; mod svg;

View File

@@ -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<String>,
pub drawing_elements: Vec<DrawingElement>,
pub description: Option<String>,
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<SavedSession>,
}
impl SavedWorkspace {
const CURRENT_VERSION: u32 = 1;
pub fn new(
active_session_idx: usize,
session_counter: usize,
sessions: Vec<SavedSession>,
) -> Self {
Self {
version: Self::CURRENT_VERSION,
active_session_idx,
session_counter,
sessions,
}
}
}
fn data_dir() -> Result<PathBuf> {
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<PathBuf> {
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<Option<SavedWorkspace>> {
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("<svg></svg>".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("<svg></svg>")
);
assert_eq!(
loaded.sessions[0].description.as_deref(),
Some("A test session")
);
}
}

View File

@@ -83,6 +83,7 @@ pub struct Session {
pub svg_renderer: Option<SvgRenderer>, pub svg_renderer: Option<SvgRenderer>,
pub svg_texture: Option<TextureHandle>, pub svg_texture: Option<TextureHandle>,
pub element_tree: Option<ElementTree>, pub element_tree: Option<ElementTree>,
pub svg_source: Option<String>,
pub description_text: String, pub description_text: String,
pub drawing_elements: Vec<DrawingElement>, pub drawing_elements: Vec<DrawingElement>,
@@ -104,6 +105,7 @@ impl Session {
svg_renderer: None, svg_renderer: None,
svg_texture: None, svg_texture: None,
element_tree: None, element_tree: None,
svg_source: None,
description_text: String::new(), description_text: String::new(),
drawing_elements: Vec::new(), drawing_elements: Vec::new(),
selected_element_id: None, selected_element_id: None,
@@ -137,6 +139,7 @@ impl Session {
self.svg_renderer = None; self.svg_renderer = None;
self.svg_texture = None; self.svg_texture = None;
self.element_tree = None; self.element_tree = None;
self.svg_source = None;
self.description_text.clear(); self.description_text.clear();
self.drawing_elements.clear(); self.drawing_elements.clear();
self.selected_element_id = None; self.selected_element_id = None;