From 0a73d2238c4d66e0af2169e87d4d561f3f1686ff Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Sun, 25 Jan 2026 17:40:03 -0800 Subject: [PATCH] feat: add clipboard history for code snippets Implements issue #25 - Clipboard History feature that tracks copied code snippets with language detection, search, and filtering. Backend (Rust): - New clipboard.rs module with persistent storage via tauri-plugin-store - Commands: capture, list, delete, toggle pin, clear, search, update language - Auto-deduplication and max history size (100 entries) - Pinned entries stay at top and persist through clear Frontend (Svelte/TypeScript): - Clipboard store with filtering, search, and language detection - ClipboardHistoryPanel component with search, language filter, pin/delete - Clipboard button added to InputBar next to Snippets/Actions - Auto-capture from code block copy buttons - Auto-capture from manual text selection in terminal - Insert snippets directly into input field Co-Authored-By: Claude Opus 4.5 --- src-tauri/src/clipboard.rs | 259 +++++++++ src-tauri/src/lib.rs | 10 + .../components/ClipboardHistoryPanel.svelte | 497 ++++++++++++++++++ src/lib/components/InputBar.svelte | 58 +- src/lib/components/Markdown.svelte | 7 + src/lib/components/StatusBar.svelte | 4 +- src/lib/components/Terminal.svelte | 29 +- src/lib/stores/clipboard.ts | 230 ++++++++ 8 files changed, 1086 insertions(+), 8 deletions(-) create mode 100644 src-tauri/src/clipboard.rs create mode 100644 src/lib/components/ClipboardHistoryPanel.svelte create mode 100644 src/lib/stores/clipboard.ts diff --git a/src-tauri/src/clipboard.rs b/src-tauri/src/clipboard.rs new file mode 100644 index 0000000..058d9ac --- /dev/null +++ b/src-tauri/src/clipboard.rs @@ -0,0 +1,259 @@ +// Clipboard history module for tracking and managing copied code snippets +// Implements issue #25 - Clipboard History feature + +use serde::{Deserialize, Serialize}; +use std::sync::Mutex; +use tauri_plugin_store::StoreExt; +use uuid::Uuid; + +const STORE_FILE: &str = "hikari-clipboard.json"; +const HISTORY_KEY: &str = "clipboard_history"; +const MAX_HISTORY_SIZE: usize = 100; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClipboardEntry { + pub id: String, + pub content: String, + pub language: Option, + pub source: Option, + pub timestamp: String, + pub is_pinned: bool, +} + +impl ClipboardEntry { + pub fn new(content: String, language: Option, source: Option) -> Self { + Self { + id: Uuid::new_v4().to_string(), + content, + language, + source, + timestamp: chrono::Utc::now().to_rfc3339(), + is_pinned: false, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +struct ClipboardHistory { + entries: Vec, +} + +// Track last clipboard content to avoid duplicates +#[derive(Default)] +struct ClipboardState { + last_content: Option, +} + +static CLIPBOARD_STATE: Mutex = Mutex::new(ClipboardState { last_content: None }); + +fn load_history(app: &tauri::AppHandle) -> ClipboardHistory { + let store = app.store(STORE_FILE).ok(); + store + .and_then(|s| s.get(HISTORY_KEY)) + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default() +} + +fn save_history(app: &tauri::AppHandle, history: &ClipboardHistory) -> Result<(), String> { + let store = app.store(STORE_FILE).map_err(|e| e.to_string())?; + store.set( + HISTORY_KEY, + serde_json::to_value(history).map_err(|e| e.to_string())?, + ); + store.save().map_err(|e| e.to_string())?; + Ok(()) +} + +/// List all clipboard entries, optionally filtered by language +#[tauri::command] +pub fn list_clipboard_entries( + app: tauri::AppHandle, + language: Option, +) -> Result, String> { + let history = load_history(&app); + let entries = if let Some(lang) = language { + history + .entries + .into_iter() + .filter(|e| e.language.as_ref() == Some(&lang)) + .collect() + } else { + history.entries + }; + Ok(entries) +} + +/// Capture current clipboard content and add to history +#[tauri::command] +pub fn capture_clipboard( + app: tauri::AppHandle, + content: String, + language: Option, + source: Option, +) -> Result { + // Check for duplicate (same content as last capture) + { + let mut state = CLIPBOARD_STATE.lock().map_err(|e| e.to_string())?; + if state.last_content.as_ref() == Some(&content) { + // Return existing entry if content is the same + let history = load_history(&app); + if let Some(entry) = history.entries.first() { + if entry.content == content { + return Ok(entry.clone()); + } + } + } + state.last_content = Some(content.clone()); + } + + let entry = ClipboardEntry::new(content, language, source); + let mut history = load_history(&app); + + // Add to front of history + history.entries.insert(0, entry.clone()); + + // Enforce max size (keep pinned entries) + let mut pinned: Vec = history + .entries + .iter() + .filter(|e| e.is_pinned) + .cloned() + .collect(); + let mut unpinned: Vec = history + .entries + .into_iter() + .filter(|e| !e.is_pinned) + .collect(); + + // Trim unpinned entries if over max size + if unpinned.len() + pinned.len() > MAX_HISTORY_SIZE { + let max_unpinned = MAX_HISTORY_SIZE.saturating_sub(pinned.len()); + unpinned.truncate(max_unpinned); + } + + // Merge back, pinned first then unpinned + pinned.extend(unpinned); + history.entries = pinned; + + // Sort by timestamp descending (newest first), pinned entries stay at top + history.entries.sort_by(|a, b| { + if a.is_pinned && !b.is_pinned { + std::cmp::Ordering::Less + } else if !a.is_pinned && b.is_pinned { + std::cmp::Ordering::Greater + } else { + b.timestamp.cmp(&a.timestamp) + } + }); + + save_history(&app, &history)?; + Ok(entry) +} + +/// Delete a clipboard entry by ID +#[tauri::command] +pub fn delete_clipboard_entry(app: tauri::AppHandle, id: String) -> Result<(), String> { + let mut history = load_history(&app); + history.entries.retain(|e| e.id != id); + save_history(&app, &history)?; + Ok(()) +} + +/// Toggle pin status of an entry +#[tauri::command] +pub fn toggle_pin_clipboard_entry( + app: tauri::AppHandle, + id: String, +) -> Result { + let mut history = load_history(&app); + let entry = history + .entries + .iter_mut() + .find(|e| e.id == id) + .ok_or("Entry not found")?; + + entry.is_pinned = !entry.is_pinned; + let updated_entry = entry.clone(); + + // Re-sort to move pinned entries to top + history.entries.sort_by(|a, b| { + if a.is_pinned && !b.is_pinned { + std::cmp::Ordering::Less + } else if !a.is_pinned && b.is_pinned { + std::cmp::Ordering::Greater + } else { + b.timestamp.cmp(&a.timestamp) + } + }); + + save_history(&app, &history)?; + Ok(updated_entry) +} + +/// Clear all non-pinned entries +#[tauri::command] +pub fn clear_clipboard_history(app: tauri::AppHandle) -> Result<(), String> { + let mut history = load_history(&app); + history.entries.retain(|e| e.is_pinned); + save_history(&app, &history)?; + Ok(()) +} + +/// Search clipboard entries by content +#[tauri::command] +pub fn search_clipboard_entries( + app: tauri::AppHandle, + query: String, +) -> Result, String> { + let history = load_history(&app); + let query_lower = query.to_lowercase(); + let entries = history + .entries + .into_iter() + .filter(|e| { + e.content.to_lowercase().contains(&query_lower) + || e.language + .as_ref() + .is_some_and(|l| l.to_lowercase().contains(&query_lower)) + || e.source + .as_ref() + .is_some_and(|s| s.to_lowercase().contains(&query_lower)) + }) + .collect(); + Ok(entries) +} + +/// Get all unique languages from history +#[tauri::command] +pub fn get_clipboard_languages(app: tauri::AppHandle) -> Result, String> { + let history = load_history(&app); + let mut languages: Vec = history + .entries + .iter() + .filter_map(|e| e.language.clone()) + .collect(); + languages.sort(); + languages.dedup(); + Ok(languages) +} + +/// Update the language of an entry +#[tauri::command] +pub fn update_clipboard_language( + app: tauri::AppHandle, + id: String, + language: Option, +) -> Result { + let mut history = load_history(&app); + let entry = history + .entries + .iter_mut() + .find(|e| e.id == id) + .ok_or("Entry not found")?; + + entry.language = language; + let updated_entry = entry.clone(); + + save_history(&app, &history)?; + Ok(updated_entry) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fc3e0b3..e7b4f98 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ mod achievements; mod bridge_manager; +mod clipboard; mod commands; mod config; mod git; @@ -17,6 +18,7 @@ mod wsl_bridge; mod wsl_notifications; use bridge_manager::create_shared_bridge_manager; +use clipboard::*; use commands::load_saved_achievements; use commands::*; use git::*; @@ -140,6 +142,14 @@ pub fn run() { git_log, git_discard, git_create_branch, + list_clipboard_entries, + capture_clipboard, + delete_clipboard_entry, + toggle_pin_clipboard_entry, + clear_clipboard_history, + search_clipboard_entries, + get_clipboard_languages, + update_clipboard_language, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/lib/components/ClipboardHistoryPanel.svelte b/src/lib/components/ClipboardHistoryPanel.svelte new file mode 100644 index 0000000..ddbfa3c --- /dev/null +++ b/src/lib/components/ClipboardHistoryPanel.svelte @@ -0,0 +1,497 @@ + + +{#if isOpen} + + +
+
+
+

📋 Clipboard History

+
+ {#if entries.length > 0} + + {/if} + +
+
+ +
+ +
+ + {#each languages as lang (lang)} + + {/each} +
+
+ +
+ {#if isLoading} +
Loading...
+ {:else if entries.length === 0} +
+

📭 No clipboard entries yet

+

+ Copy code from Claude's responses or use the copy button on code blocks to save them + here. +

+
+ {:else} +
+ {#each entries as entry (entry.id)} +
+
+ +
+ + + + {#if confirmingDeleteId === entry.id} + + + {:else} + + {/if} +
+
+
{truncateContent(entry.content)}
+ {#if entry.source} +
From: {entry.source}
+ {/if} +
+ {/each} +
+ {/if} +
+
+
+{/if} + + diff --git a/src/lib/components/InputBar.svelte b/src/lib/components/InputBar.svelte index 346f826..b8f82cc 100644 --- a/src/lib/components/InputBar.svelte +++ b/src/lib/components/InputBar.svelte @@ -7,6 +7,7 @@ import { characterState } from "$lib/stores/character"; import { handleNewUserMessage } from "$lib/notifications/rules"; import { setSkipNextGreeting } from "$lib/tauri"; + import { clipboardStore } from "$lib/stores/clipboard"; import { setShouldRestoreHistory, setSavedHistory, @@ -27,6 +28,7 @@ import AttachmentPreview from "$lib/components/AttachmentPreview.svelte"; import SnippetLibraryPanel from "$lib/components/SnippetLibraryPanel.svelte"; import QuickActionsPanel from "$lib/components/QuickActionsPanel.svelte"; + import ClipboardHistoryPanel from "$lib/components/ClipboardHistoryPanel.svelte"; import type { Attachment } from "$lib/types/messages"; const INPUT_HISTORY_KEY = "hikari-input-history"; @@ -43,6 +45,7 @@ let isDragging = $state(false); let showSnippetLibrary = $state(false); let showQuickActions = $state(false); + let showClipboardHistory = $state(false); // Input history state let inputHistory = $state([]); @@ -504,6 +507,15 @@ User: ${formattedMessage}`; const items = event.clipboardData?.items; let handledFile = false; + // Also capture text content to clipboard history + const textContent = event.clipboardData?.getData("text/plain"); + if (textContent && textContent.trim().length > 0) { + // Only capture multi-line or longer text (likely code snippets) + if (textContent.includes("\n") || textContent.length > 50) { + clipboardStore.captureClipboard(textContent, null, "Pasted into chat"); + } + } + if (items && items.length > 0) { for (const item of items) { if (item.kind === "file") { @@ -631,6 +643,16 @@ User: ${formattedMessage}`; userHasTyped = true; } + function handleClipboardInsert(content: string): void { + // Insert clipboard content at cursor position or append to input + if (inputValue.trim()) { + inputValue = inputValue + "\n\n" + content; + } else { + inputValue = content; + } + userHasTyped = true; + } + async function handleQuickAction(prompt: string): Promise { // Quick actions send the prompt directly if (!isConnected || isSubmitting) return; @@ -787,6 +809,27 @@ User: ${formattedMessage}`; Snippets +
@@ -870,9 +913,14 @@ User: ${formattedMessage}`; {/if} {#if showQuickActions} - (showQuickActions = false)} - onAction={handleQuickAction} + (showQuickActions = false)} onAction={handleQuickAction} /> +{/if} + +{#if showClipboardHistory} + (showClipboardHistory = false)} + onInsert={handleClipboardInsert} /> {/if} @@ -1043,6 +1091,8 @@ User: ${formattedMessage}`; .trans-gradient-button:hover:not(:disabled) { filter: brightness(1.1); - box-shadow: 0 0 20px rgba(91, 206, 250, 0.4), 0 0 30px rgba(245, 169, 184, 0.3); + box-shadow: + 0 0 20px rgba(91, 206, 250, 0.4), + 0 0 30px rgba(245, 169, 184, 0.3); } diff --git a/src/lib/components/Markdown.svelte b/src/lib/components/Markdown.svelte index 81f94da..671ae48 100644 --- a/src/lib/components/Markdown.svelte +++ b/src/lib/components/Markdown.svelte @@ -3,6 +3,7 @@ import hljs from "highlight.js"; import { onMount } from "svelte"; import { openUrl } from "@tauri-apps/plugin-opener"; + import { clipboardStore } from "$lib/stores/clipboard"; interface Props { content: string; @@ -147,6 +148,12 @@ .replace(/>/g, ">"); if (code) { await navigator.clipboard.writeText(code); + + // Capture to clipboard history + const langElement = copyBtn.parentElement?.querySelector(".code-block-lang"); + const language = langElement?.textContent || null; + await clipboardStore.captureClipboard(code, language, "Claude response"); + const textSpan = copyBtn.querySelector(".copy-text"); if (textSpan) { textSpan.textContent = "Copied!"; diff --git a/src/lib/components/StatusBar.svelte b/src/lib/components/StatusBar.svelte index bf85829..43fd75a 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -251,9 +251,7 @@