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
|
||||
- **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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<egui::Pos2>,
|
||||
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<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) {
|
||||
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<Tool> = 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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
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 canvas;
|
||||
mod clipboard;
|
||||
mod command_palette;
|
||||
mod drawing;
|
||||
mod element_tree;
|
||||
mod mermaid;
|
||||
mod persistence;
|
||||
mod session;
|
||||
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_texture: Option<TextureHandle>,
|
||||
pub element_tree: Option<ElementTree>,
|
||||
pub svg_source: Option<String>,
|
||||
pub description_text: String,
|
||||
|
||||
pub drawing_elements: Vec<DrawingElement>,
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user