generated from nhcarrigan/template
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:
@@ -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)
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user