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 <noreply@anthropic.com>
This commit is contained in:
2026-01-25 17:40:03 -08:00
parent c34720346e
commit 0a73d2238c
8 changed files with 1086 additions and 8 deletions
+259
View File
@@ -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<String>,
pub source: Option<String>,
pub timestamp: String,
pub is_pinned: bool,
}
impl ClipboardEntry {
pub fn new(content: String, language: Option<String>, source: Option<String>) -> 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<ClipboardEntry>,
}
// Track last clipboard content to avoid duplicates
#[derive(Default)]
struct ClipboardState {
last_content: Option<String>,
}
static CLIPBOARD_STATE: Mutex<ClipboardState> = 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<String>,
) -> Result<Vec<ClipboardEntry>, 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<String>,
source: Option<String>,
) -> Result<ClipboardEntry, String> {
// 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<ClipboardEntry> = history
.entries
.iter()
.filter(|e| e.is_pinned)
.cloned()
.collect();
let mut unpinned: Vec<ClipboardEntry> = 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<ClipboardEntry, String> {
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<Vec<ClipboardEntry>, 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<Vec<String>, String> {
let history = load_history(&app);
let mut languages: Vec<String> = 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<String>,
) -> Result<ClipboardEntry, String> {
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)
}
+10
View File
@@ -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");