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:
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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(),
|
||||||
};
|
};
|
||||||
|
|
||||||
app.create_session();
|
if !app.restore_workspace(&cc.egui_ctx) {
|
||||||
|
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,44 +718,63 @@ 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::V) {
|
if i.modifiers.command && i.key_pressed(egui::Key::K) {
|
||||||
paste = true;
|
toggle_palette = true;
|
||||||
}
|
}
|
||||||
if i.modifiers.command && i.key_pressed(egui::Key::T) {
|
if i.modifiers.command && i.key_pressed(egui::Key::S) {
|
||||||
new_tab = true;
|
save_workspace = true;
|
||||||
}
|
}
|
||||||
if i.modifiers.command && i.key_pressed(egui::Key::W) {
|
if !palette_open {
|
||||||
close_tab = true;
|
if i.modifiers.command && i.key_pressed(egui::Key::V) {
|
||||||
}
|
paste = true;
|
||||||
if i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace) {
|
}
|
||||||
delete_selected = true;
|
if i.modifiers.command && i.key_pressed(egui::Key::T) {
|
||||||
}
|
new_tab = true;
|
||||||
if i.key_pressed(egui::Key::Escape) {
|
}
|
||||||
tool_switch = Some(Tool::Select);
|
if i.modifiers.command && i.key_pressed(egui::Key::W) {
|
||||||
}
|
close_tab = true;
|
||||||
if !i.modifiers.command {
|
}
|
||||||
if i.key_pressed(egui::Key::V) {
|
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);
|
tool_switch = Some(Tool::Select);
|
||||||
}
|
}
|
||||||
if i.key_pressed(egui::Key::R) {
|
if !i.modifiers.command {
|
||||||
tool_switch = Some(Tool::Rectangle);
|
if i.key_pressed(egui::Key::V) {
|
||||||
}
|
tool_switch = Some(Tool::Select);
|
||||||
if i.key_pressed(egui::Key::E) {
|
}
|
||||||
tool_switch = Some(Tool::Ellipse);
|
if i.key_pressed(egui::Key::R) {
|
||||||
}
|
tool_switch = Some(Tool::Rectangle);
|
||||||
if i.key_pressed(egui::Key::L) {
|
}
|
||||||
tool_switch = Some(Tool::Line);
|
if i.key_pressed(egui::Key::E) {
|
||||||
}
|
tool_switch = Some(Tool::Ellipse);
|
||||||
if i.key_pressed(egui::Key::A) {
|
}
|
||||||
tool_switch = Some(Tool::Arrow);
|
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 {
|
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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
353
crates/agcanvas/src/command_palette.rs
Normal file
353
crates/agcanvas/src/command_palette.rs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
132
crates/agcanvas/src/persistence.rs
Normal file
132
crates/agcanvas/src/persistence.rs
Normal 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user