generated from nhcarrigan/template
feat: add snippet library for prompt templates
- Add Rust backend with persistent storage for snippets - Include 8 default snippets across categories (Code Review, Debugging, Testing, etc.) - Create SnippetLibraryPanel component with category filtering - Support create/edit/delete operations for custom snippets - Default snippets can be edited but not deleted - Add snippet button to InputBar for quick access Closes #22
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
fn get_default_snippets() -> Vec<Snippet> {
|
||||
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<Vec<Snippet>, 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<Snippet> =
|
||||
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<Vec<Snippet>, 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<Vec<String>, String> {
|
||||
let snippets = load_all_snippets(&app)?;
|
||||
let mut categories: Vec<String> = 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Attachment[]>([]);
|
||||
let isDragging = $state(false);
|
||||
let showSnippetLibrary = $state(false);
|
||||
|
||||
// Input history state
|
||||
let inputHistory = $state<string[]>([]);
|
||||
@@ -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}`;
|
||||
</div>
|
||||
|
||||
<div class="button-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showSnippetLibrary = true)}
|
||||
class="attach-button"
|
||||
title="Snippet Library"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
|
||||
<polyline points="14,2 14,8 20,8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
<line x1="10" y1="9" x2="8" y2="9" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" onclick={handleFilePicker} class="attach-button" title="Attach files">
|
||||
<svg
|
||||
width="20"
|
||||
@@ -769,6 +804,13 @@ User: ${formattedMessage}`;
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{#if showSnippetLibrary}
|
||||
<SnippetLibraryPanel
|
||||
onClose={() => (showSnippetLibrary = false)}
|
||||
onInsert={handleSnippetInsert}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.input-bar {
|
||||
display: flex;
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { snippetsStore, type Snippet } from "$lib/stores/snippets";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onInsert: (content: string) => void;
|
||||
}
|
||||
|
||||
const { onClose, onInsert }: Props = $props();
|
||||
|
||||
let editingSnippet = $state<Snippet | null>(null);
|
||||
let isCreating = $state(false);
|
||||
let showDeleteConfirm = $state<string | null>(null);
|
||||
|
||||
let editName = $state("");
|
||||
let editContent = $state("");
|
||||
let editCategory = $state("");
|
||||
let newCategoryInput = $state("");
|
||||
let showNewCategoryInput = $state(false);
|
||||
|
||||
const snippets = $derived(snippetsStore.filteredSnippets);
|
||||
const categories = $derived(snippetsStore.categories);
|
||||
const selectedCategory = $derived(snippetsStore.selectedCategory);
|
||||
const isLoading = $derived(snippetsStore.isLoading);
|
||||
|
||||
onMount(() => {
|
||||
snippetsStore.loadSnippets();
|
||||
});
|
||||
|
||||
function handleSelectCategory(category: string | null): void {
|
||||
snippetsStore.setSelectedCategory(category);
|
||||
}
|
||||
|
||||
function handleInsert(snippet: Snippet): void {
|
||||
onInsert(snippet.content);
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleStartCreate(): void {
|
||||
isCreating = true;
|
||||
editingSnippet = null;
|
||||
editName = "";
|
||||
editContent = "";
|
||||
editCategory = $categories.length > 0 ? $categories[0] : "Custom";
|
||||
showNewCategoryInput = false;
|
||||
newCategoryInput = "";
|
||||
}
|
||||
|
||||
function handleStartEdit(snippet: Snippet): void {
|
||||
editingSnippet = snippet;
|
||||
isCreating = false;
|
||||
editName = snippet.name;
|
||||
editContent = snippet.content;
|
||||
editCategory = snippet.category;
|
||||
showNewCategoryInput = false;
|
||||
newCategoryInput = "";
|
||||
}
|
||||
|
||||
function handleCancelEdit(): void {
|
||||
editingSnippet = null;
|
||||
isCreating = false;
|
||||
showNewCategoryInput = false;
|
||||
newCategoryInput = "";
|
||||
}
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
const finalCategory =
|
||||
showNewCategoryInput && newCategoryInput.trim() ? newCategoryInput.trim() : editCategory;
|
||||
|
||||
if (!editName.trim() || !editContent.trim() || !finalCategory) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCreating) {
|
||||
await snippetsStore.createSnippet(editName.trim(), editContent.trim(), finalCategory);
|
||||
} else if (editingSnippet) {
|
||||
await snippetsStore.updateSnippet(
|
||||
editingSnippet.id,
|
||||
editName.trim(),
|
||||
editContent.trim(),
|
||||
finalCategory
|
||||
);
|
||||
}
|
||||
|
||||
handleCancelEdit();
|
||||
}
|
||||
|
||||
async function handleDelete(snippetId: string): Promise<void> {
|
||||
await snippetsStore.deleteSnippet(snippetId);
|
||||
showDeleteConfirm = null;
|
||||
}
|
||||
|
||||
function toggleNewCategory(): void {
|
||||
showNewCategoryInput = !showNewCategoryInput;
|
||||
if (showNewCategoryInput) {
|
||||
newCategoryInput = "";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||
onclick={onClose}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => e.key === "Escape" && onClose()}
|
||||
>
|
||||
<div
|
||||
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-4xl w-full max-h-[80vh] overflow-hidden flex flex-col"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-labelledby="snippet-library-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
|
||||
<div class="flex items-center gap-3">
|
||||
{#if editingSnippet || isCreating}
|
||||
<button
|
||||
onclick={handleCancelEdit}
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
aria-label="Back to list"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<h2 id="snippet-library-title" class="text-xl font-semibold text-[var(--text-primary)]">
|
||||
{#if isCreating}
|
||||
Create Snippet
|
||||
{:else if editingSnippet}
|
||||
Edit Snippet
|
||||
{:else}
|
||||
Snippet Library
|
||||
{/if}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if !editingSnippet && !isCreating}
|
||||
<button
|
||||
onclick={handleStartCreate}
|
||||
class="px-3 py-1.5 text-sm font-medium bg-[var(--accent-primary)] text-white rounded hover:bg-[var(--accent-primary)]/80 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
New Snippet
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if editingSnippet || isCreating}
|
||||
<div class="p-6 flex-1 overflow-y-auto">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
for="snippet-name"
|
||||
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="snippet-name"
|
||||
type="text"
|
||||
bind:value={editName}
|
||||
placeholder="Enter snippet name..."
|
||||
class="w-full px-4 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="snippet-category"
|
||||
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
|
||||
>
|
||||
Category
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if showNewCategoryInput}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newCategoryInput}
|
||||
placeholder="Enter new category..."
|
||||
class="flex-1 px-4 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
{:else}
|
||||
<select
|
||||
id="snippet-category"
|
||||
bind:value={editCategory}
|
||||
class="flex-1 px-4 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
>
|
||||
{#each $categories as category (category)}
|
||||
<option value={category}>{category}</option>
|
||||
{/each}
|
||||
<option value="Custom">Custom</option>
|
||||
</select>
|
||||
{/if}
|
||||
<button
|
||||
onclick={toggleNewCategory}
|
||||
class="px-3 py-2 text-sm font-medium bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
>
|
||||
{showNewCategoryInput ? "Use Existing" : "New Category"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="snippet-content"
|
||||
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
|
||||
>
|
||||
Content
|
||||
</label>
|
||||
<textarea
|
||||
id="snippet-content"
|
||||
bind:value={editContent}
|
||||
placeholder="Enter your prompt snippet..."
|
||||
rows="8"
|
||||
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)] resize-none font-mono text-sm"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-4">
|
||||
<button
|
||||
onclick={handleCancelEdit}
|
||||
class="px-4 py-2 text-sm font-medium bg-[var(--bg-secondary)] text-[var(--text-secondary)] rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={handleSave}
|
||||
disabled={!editName.trim() || !editContent.trim()}
|
||||
class="px-4 py-2 text-sm font-medium bg-[var(--accent-primary)] text-white rounded-lg hover:bg-[var(--accent-primary)]/80 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isCreating ? "Create" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<div class="w-48 border-r border-[var(--border-color)] p-4 overflow-y-auto">
|
||||
<h3
|
||||
class="text-xs font-semibold text-[var(--text-tertiary)] uppercase tracking-wider mb-3"
|
||||
>
|
||||
Categories
|
||||
</h3>
|
||||
<div class="space-y-1">
|
||||
<button
|
||||
onclick={() => handleSelectCategory(null)}
|
||||
class="w-full text-left px-3 py-2 rounded-lg text-sm transition-colors {$selectedCategory ===
|
||||
null
|
||||
? 'bg-[var(--accent-primary)]/10 text-[var(--accent-primary)]'
|
||||
: 'text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)]'}"
|
||||
>
|
||||
All Snippets
|
||||
</button>
|
||||
{#each $categories as category (category)}
|
||||
<button
|
||||
onclick={() => handleSelectCategory(category)}
|
||||
class="w-full text-left px-3 py-2 rounded-lg text-sm transition-colors {$selectedCategory ===
|
||||
category
|
||||
? 'bg-[var(--accent-primary)]/10 text-[var(--accent-primary)]'
|
||||
: 'text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)]'}"
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#if $isLoading}
|
||||
<div class="flex items-center justify-center p-8">
|
||||
<div class="text-[var(--text-tertiary)]">Loading snippets...</div>
|
||||
</div>
|
||||
{:else if $snippets.length === 0}
|
||||
<div class="flex flex-col items-center justify-center p-8 text-center">
|
||||
<svg
|
||||
class="w-16 h-16 text-[var(--text-tertiary)] mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-[var(--text-secondary)]">No snippets in this category</p>
|
||||
<button
|
||||
onclick={handleStartCreate}
|
||||
class="mt-4 px-4 py-2 text-sm font-medium bg-[var(--accent-primary)] text-white rounded-lg hover:bg-[var(--accent-primary)]/80 transition-colors"
|
||||
>
|
||||
Create your first snippet
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="divide-y divide-[var(--border-color)]">
|
||||
{#each $snippets as snippet (snippet.id)}
|
||||
<div class="p-4 hover:bg-[var(--bg-secondary)] transition-colors group">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<h3 class="font-medium text-[var(--text-primary)]">{snippet.name}</h3>
|
||||
{#if snippet.is_default}
|
||||
<span
|
||||
class="px-2 py-0.5 text-xs font-medium bg-[var(--accent-primary)]/10 text-[var(--accent-primary)] rounded"
|
||||
>
|
||||
Default
|
||||
</span>
|
||||
{/if}
|
||||
<span class="text-xs text-[var(--text-tertiary)]">{snippet.category}</span>
|
||||
</div>
|
||||
<p class="text-sm text-[var(--text-secondary)] line-clamp-2 font-mono">
|
||||
{snippet.content}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<button
|
||||
onclick={() => handleInsert(snippet)}
|
||||
class="px-3 py-1.5 text-xs font-medium bg-[var(--accent-primary)] text-white rounded hover:bg-[var(--accent-primary)]/80 transition-colors"
|
||||
title="Insert this snippet"
|
||||
>
|
||||
Insert
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleStartEdit(snippet)}
|
||||
class="p-1.5 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
title="Edit snippet"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if !snippet.is_default}
|
||||
{#if showDeleteConfirm === snippet.id}
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
onclick={() => handleDelete(snippet.id)}
|
||||
class="px-2 py-1 text-xs font-medium bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showDeleteConfirm = null)}
|
||||
class="px-2 py-1 text-xs font-medium bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded hover:bg-[var(--bg-secondary)] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => (showDeleteConfirm = snippet.id)}
|
||||
class="p-1.5 text-[var(--text-tertiary)] hover:text-red-400 transition-colors"
|
||||
title="Delete snippet"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
[role="dialog"] {
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.overflow-y-auto {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-color) transparent;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -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<Snippet[]>([]);
|
||||
const categories = writable<string[]>([]);
|
||||
const isLoading = writable(false);
|
||||
const selectedCategory = writable<string | null>(null);
|
||||
|
||||
const filteredSnippets = derived(
|
||||
[snippets, selectedCategory],
|
||||
([$snippets, $selectedCategory]) => {
|
||||
if (!$selectedCategory) {
|
||||
return $snippets;
|
||||
}
|
||||
return $snippets.filter((s) => s.category === $selectedCategory);
|
||||
}
|
||||
);
|
||||
|
||||
async function loadSnippets(): Promise<void> {
|
||||
isLoading.set(true);
|
||||
try {
|
||||
const [snippetList, categoryList] = await Promise.all([
|
||||
invoke<Snippet[]>("list_snippets"),
|
||||
invoke<string[]>("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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
const currentSnippets = await invoke<Snippet[]>("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<boolean> {
|
||||
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<boolean> {
|
||||
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();
|
||||
Reference in New Issue
Block a user