diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cf97d8b..96966e1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ mod commands; mod config; mod notifications; mod sessions; +mod snippets; mod stats; mod temp_manager; mod tray; @@ -18,6 +19,7 @@ use commands::load_saved_achievements; use commands::*; use notifications::*; use sessions::*; +use snippets::*; use tauri::Manager; use temp_manager::create_shared_temp_manager; use tray::{setup_tray, should_minimize_to_tray}; @@ -111,6 +113,11 @@ pub fn run() { delete_session, search_sessions, clear_all_sessions, + list_snippets, + save_snippet, + delete_snippet, + get_snippet_categories, + reset_default_snippets, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/snippets.rs b/src-tauri/src/snippets.rs new file mode 100644 index 0000000..5ebf22d --- /dev/null +++ b/src-tauri/src/snippets.rs @@ -0,0 +1,226 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use tauri::AppHandle; +use tauri_plugin_store::StoreExt; + +const SNIPPETS_STORE_KEY: &str = "snippets"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Snippet { + pub id: String, + pub name: String, + pub content: String, + pub category: String, + pub is_default: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +fn get_default_snippets() -> Vec { + let now = Utc::now(); + vec![ + Snippet { + id: "default-explain-code".to_string(), + name: "Explain this code".to_string(), + content: "Please explain what this code does, step by step:".to_string(), + category: "Code Review".to_string(), + is_default: true, + created_at: now, + updated_at: now, + }, + Snippet { + id: "default-fix-error".to_string(), + name: "Fix this error".to_string(), + content: "I'm getting the following error. Can you help me fix it?".to_string(), + category: "Debugging".to_string(), + is_default: true, + created_at: now, + updated_at: now, + }, + Snippet { + id: "default-write-tests".to_string(), + name: "Write tests".to_string(), + content: "Please write unit tests for this code with good coverage:".to_string(), + category: "Testing".to_string(), + is_default: true, + created_at: now, + updated_at: now, + }, + Snippet { + id: "default-refactor".to_string(), + name: "Refactor for clarity".to_string(), + content: "Please refactor this code to improve readability and maintainability:".to_string(), + category: "Code Review".to_string(), + is_default: true, + created_at: now, + updated_at: now, + }, + Snippet { + id: "default-optimize".to_string(), + name: "Optimize performance".to_string(), + content: "Please analyze this code for performance issues and suggest optimizations:".to_string(), + category: "Performance".to_string(), + is_default: true, + created_at: now, + updated_at: now, + }, + Snippet { + id: "default-review-pr".to_string(), + name: "Review PR".to_string(), + content: "Please review this pull request and provide feedback on code quality, potential issues, and suggestions for improvement.".to_string(), + category: "Code Review".to_string(), + is_default: true, + created_at: now, + updated_at: now, + }, + Snippet { + id: "default-add-comments".to_string(), + name: "Add documentation".to_string(), + content: "Please add clear documentation comments to this code explaining what it does:".to_string(), + category: "Documentation".to_string(), + is_default: true, + created_at: now, + updated_at: now, + }, + Snippet { + id: "default-security-review".to_string(), + name: "Security review".to_string(), + content: "Please review this code for security vulnerabilities and suggest fixes:".to_string(), + category: "Security".to_string(), + is_default: true, + created_at: now, + updated_at: now, + }, + ] +} + +fn load_all_snippets(app: &AppHandle) -> Result, String> { + let store = app + .store("hikari-snippets.json") + .map_err(|e| e.to_string())?; + + match store.get(SNIPPETS_STORE_KEY) { + Some(value) => { + let mut snippets: Vec = + serde_json::from_value(value.clone()).map_err(|e| e.to_string())?; + + // Ensure default snippets exist (in case new ones were added in an update) + let defaults = get_default_snippets(); + for default in defaults { + if !snippets.iter().any(|s| s.id == default.id) { + snippets.push(default); + } + } + + Ok(snippets) + } + None => Ok(get_default_snippets()), + } +} + +fn save_all_snippets(app: &AppHandle, snippets: &[Snippet]) -> Result<(), String> { + let store = app + .store("hikari-snippets.json") + .map_err(|e| e.to_string())?; + + let value = serde_json::to_value(snippets).map_err(|e| e.to_string())?; + store.set(SNIPPETS_STORE_KEY, value); + store.save().map_err(|e| e.to_string())?; + + Ok(()) +} + +#[tauri::command] +pub async fn list_snippets(app: AppHandle) -> Result, String> { + let mut snippets = load_all_snippets(&app)?; + + // Sort by category, then by name + snippets.sort_by(|a, b| { + let cat_cmp = a.category.cmp(&b.category); + if cat_cmp == std::cmp::Ordering::Equal { + a.name.cmp(&b.name) + } else { + cat_cmp + } + }); + + Ok(snippets) +} + +#[tauri::command] +pub async fn save_snippet(app: AppHandle, snippet: Snippet) -> Result<(), String> { + let mut snippets = load_all_snippets(&app)?; + + // Update existing or add new + if let Some(existing) = snippets.iter_mut().find(|s| s.id == snippet.id) { + // Don't allow editing default snippets' is_default flag + let mut updated = snippet; + updated.is_default = existing.is_default; + *existing = updated; + } else { + snippets.push(snippet); + } + + save_all_snippets(&app, &snippets) +} + +#[tauri::command] +pub async fn delete_snippet(app: AppHandle, snippet_id: String) -> Result<(), String> { + let mut snippets = load_all_snippets(&app)?; + + // Don't allow deleting default snippets + if snippets + .iter() + .any(|s| s.id == snippet_id && s.is_default) + { + return Err("Cannot delete default snippets".to_string()); + } + + snippets.retain(|s| s.id != snippet_id); + save_all_snippets(&app, &snippets) +} + +#[tauri::command] +pub async fn get_snippet_categories(app: AppHandle) -> Result, String> { + let snippets = load_all_snippets(&app)?; + let mut categories: Vec = snippets.iter().map(|s| s.category.clone()).collect(); + categories.sort(); + categories.dedup(); + Ok(categories) +} + +#[tauri::command] +pub async fn reset_default_snippets(app: AppHandle) -> Result<(), String> { + let mut snippets = load_all_snippets(&app)?; + + // Remove all default snippets + snippets.retain(|s| !s.is_default); + + // Add fresh default snippets + snippets.extend(get_default_snippets()); + + save_all_snippets(&app, &snippets) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_snippets_exist() { + let defaults = get_default_snippets(); + assert!(!defaults.is_empty()); + assert!(defaults.iter().all(|s| s.is_default)); + } + + #[test] + fn test_default_snippets_have_required_fields() { + let defaults = get_default_snippets(); + for snippet in defaults { + assert!(!snippet.id.is_empty()); + assert!(!snippet.name.is_empty()); + assert!(!snippet.content.is_empty()); + assert!(!snippet.category.is_empty()); + } + } +} diff --git a/src/lib/components/InputBar.svelte b/src/lib/components/InputBar.svelte index 1fe7461..5df9435 100644 --- a/src/lib/components/InputBar.svelte +++ b/src/lib/components/InputBar.svelte @@ -25,6 +25,7 @@ type SlashCommand, } from "$lib/commands/slashCommands"; import AttachmentPreview from "$lib/components/AttachmentPreview.svelte"; + import SnippetLibraryPanel from "$lib/components/SnippetLibraryPanel.svelte"; import type { Attachment } from "$lib/types/messages"; const INPUT_HISTORY_KEY = "hikari-input-history"; @@ -39,6 +40,7 @@ let selectedCommandIndex = $state(0); let attachments = $state([]); let isDragging = $state(false); + let showSnippetLibrary = $state(false); // Input history state let inputHistory = $state([]); @@ -617,6 +619,16 @@ User: ${formattedMessage}`; } } + function handleSnippetInsert(content: string): void { + // Insert snippet at cursor position or append to input + if (inputValue.trim()) { + inputValue = inputValue + "\n\n" + content; + } else { + inputValue = content; + } + userHasTyped = true; + } + function handleKeyDown(event: KeyboardEvent) { // Handle command menu navigation if (showCommandMenu && matchingCommands.length > 0) { @@ -723,6 +735,29 @@ User: ${formattedMessage}`;
+ + {/if} +

+ {#if isCreating} + Create Snippet + {:else if editingSnippet} + Edit Snippet + {:else} + Snippet Library + {/if} +

+
+
+ {#if !editingSnippet && !isCreating} + + {/if} + +
+ + + {#if editingSnippet || isCreating} +
+
+
+ + +
+ +
+ +
+ {#if showNewCategoryInput} + + {:else} + + {/if} + +
+
+ +
+ + +
+ +
+ + +
+
+
+ {:else} +
+
+

+ Categories +

+
+ + {#each $categories as category (category)} + + {/each} +
+
+ +
+ {#if $isLoading} +
+
Loading snippets...
+
+ {:else if $snippets.length === 0} +
+ + + +

No snippets in this category

+ +
+ {:else} +
+ {#each $snippets as snippet (snippet.id)} +
+
+
+
+

{snippet.name}

+ {#if snippet.is_default} + + Default + + {/if} + {snippet.category} +
+

+ {snippet.content} +

+
+
+ + + {#if !snippet.is_default} + {#if showDeleteConfirm === snippet.id} +
+ + +
+ {:else} + + {/if} + {/if} +
+
+
+ {/each} +
+ {/if} +
+
+ {/if} + + + + diff --git a/src/lib/stores/snippets.ts b/src/lib/stores/snippets.ts new file mode 100644 index 0000000..b8e901b --- /dev/null +++ b/src/lib/stores/snippets.ts @@ -0,0 +1,138 @@ +import { writable, derived } from "svelte/store"; +import { invoke } from "@tauri-apps/api/core"; + +export interface Snippet { + id: string; + name: string; + content: string; + category: string; + is_default: boolean; + created_at: string; + updated_at: string; +} + +function createSnippetsStore() { + const snippets = writable([]); + const categories = writable([]); + const isLoading = writable(false); + const selectedCategory = writable(null); + + const filteredSnippets = derived( + [snippets, selectedCategory], + ([$snippets, $selectedCategory]) => { + if (!$selectedCategory) { + return $snippets; + } + return $snippets.filter((s) => s.category === $selectedCategory); + } + ); + + async function loadSnippets(): Promise { + isLoading.set(true); + try { + const [snippetList, categoryList] = await Promise.all([ + invoke("list_snippets"), + invoke("get_snippet_categories"), + ]); + snippets.set(snippetList); + categories.set(categoryList); + } catch (error) { + console.error("Failed to load snippets:", error); + } finally { + isLoading.set(false); + } + } + + async function saveSnippet(snippet: Snippet): Promise { + try { + await invoke("save_snippet", { snippet }); + await loadSnippets(); + return true; + } catch (error) { + console.error("Failed to save snippet:", error); + return false; + } + } + + async function createSnippet(name: string, content: string, category: string): Promise { + const now = new Date().toISOString(); + const snippet: Snippet = { + id: `custom-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, + name, + content, + category, + is_default: false, + created_at: now, + updated_at: now, + }; + return saveSnippet(snippet); + } + + async function updateSnippet( + id: string, + name: string, + content: string, + category: string + ): Promise { + const currentSnippets = await invoke("list_snippets"); + const existing = currentSnippets.find((s) => s.id === id); + + if (!existing) { + console.error("Snippet not found for update"); + return false; + } + + const updated: Snippet = { + ...existing, + name, + content, + category, + updated_at: new Date().toISOString(), + }; + + return saveSnippet(updated); + } + + async function deleteSnippet(snippetId: string): Promise { + try { + await invoke("delete_snippet", { snippetId }); + await loadSnippets(); + return true; + } catch (error) { + console.error("Failed to delete snippet:", error); + return false; + } + } + + async function resetDefaults(): Promise { + try { + await invoke("reset_default_snippets"); + await loadSnippets(); + return true; + } catch (error) { + console.error("Failed to reset default snippets:", error); + return false; + } + } + + function setSelectedCategory(category: string | null): void { + selectedCategory.set(category); + } + + return { + snippets: { subscribe: snippets.subscribe }, + categories: { subscribe: categories.subscribe }, + filteredSnippets: { subscribe: filteredSnippets.subscribe }, + isLoading: { subscribe: isLoading.subscribe }, + selectedCategory: { subscribe: selectedCategory.subscribe }, + loadSnippets, + saveSnippet, + createSnippet, + updateSnippet, + deleteSnippet, + resetDefaults, + setSelectedCategory, + }; +} + +export const snippetsStore = createSnippetsStore();