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 achievements;
|
||||||
mod bridge_manager;
|
mod bridge_manager;
|
||||||
|
mod clipboard;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod config;
|
mod config;
|
||||||
mod git;
|
mod git;
|
||||||
@@ -17,6 +18,7 @@ mod wsl_bridge;
|
|||||||
mod wsl_notifications;
|
mod wsl_notifications;
|
||||||
|
|
||||||
use bridge_manager::create_shared_bridge_manager;
|
use bridge_manager::create_shared_bridge_manager;
|
||||||
|
use clipboard::*;
|
||||||
use commands::load_saved_achievements;
|
use commands::load_saved_achievements;
|
||||||
use commands::*;
|
use commands::*;
|
||||||
use git::*;
|
use git::*;
|
||||||
@@ -140,6 +142,14 @@ pub fn run() {
|
|||||||
git_log,
|
git_log,
|
||||||
git_discard,
|
git_discard,
|
||||||
git_create_branch,
|
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!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -0,0 +1,497 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { clipboardStore, type ClipboardEntry } from "$lib/stores/clipboard";
|
||||||
|
|
||||||
|
export let isOpen = false;
|
||||||
|
export let onClose: () => void;
|
||||||
|
export let onInsert: (content: string) => void = () => {};
|
||||||
|
|
||||||
|
let searchQuery = "";
|
||||||
|
let selectedLanguage: string | null = null;
|
||||||
|
let confirmingDeleteId: string | null = null;
|
||||||
|
let copiedId: string | null = null;
|
||||||
|
|
||||||
|
// Subscribe to derived stores
|
||||||
|
const filteredEntries = clipboardStore.filteredEntries;
|
||||||
|
const languagesStore = clipboardStore.languages;
|
||||||
|
const isLoadingStore = clipboardStore.isLoading;
|
||||||
|
|
||||||
|
$: entries = $filteredEntries;
|
||||||
|
$: languages = $languagesStore;
|
||||||
|
$: isLoading = $isLoadingStore;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
clipboardStore.loadEntries();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$: if (isOpen) {
|
||||||
|
clipboardStore.loadEntries();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
clipboardStore.setSearchQuery(searchQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLanguageFilter(lang: string | null) {
|
||||||
|
selectedLanguage = lang;
|
||||||
|
clipboardStore.setLanguageFilter(lang);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCopy(entry: ClipboardEntry) {
|
||||||
|
const success = await clipboardStore.copyToClipboard(entry.content);
|
||||||
|
if (success) {
|
||||||
|
copiedId = entry.id;
|
||||||
|
setTimeout(() => {
|
||||||
|
copiedId = null;
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInsert(entry: ClipboardEntry) {
|
||||||
|
onInsert(entry.content);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
await clipboardStore.deleteEntry(id);
|
||||||
|
confirmingDeleteId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTogglePin(id: string) {
|
||||||
|
await clipboardStore.togglePin(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleClearHistory() {
|
||||||
|
if (confirm("Clear all non-pinned clipboard entries?")) {
|
||||||
|
await clipboardStore.clearHistory();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateContent(content: string, maxLength: number = 200): string {
|
||||||
|
if (content.length <= maxLength) return content;
|
||||||
|
return content.substring(0, maxLength) + "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLanguageIcon(language: string | null): string {
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
typescript: "TS",
|
||||||
|
javascript: "JS",
|
||||||
|
python: "PY",
|
||||||
|
rust: "RS",
|
||||||
|
go: "GO",
|
||||||
|
java: "JV",
|
||||||
|
c: "C",
|
||||||
|
cpp: "C++",
|
||||||
|
csharp: "C#",
|
||||||
|
php: "PHP",
|
||||||
|
ruby: "RB",
|
||||||
|
swift: "SW",
|
||||||
|
kotlin: "KT",
|
||||||
|
sql: "SQL",
|
||||||
|
html: "HTML",
|
||||||
|
css: "CSS",
|
||||||
|
json: "JSON",
|
||||||
|
yaml: "YAML",
|
||||||
|
bash: "SH",
|
||||||
|
shell: "SH",
|
||||||
|
};
|
||||||
|
return language ? icons[language.toLowerCase()] || language.toUpperCase().slice(0, 3) : "TXT";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isOpen}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<div class="clipboard-overlay" on:click={onClose}>
|
||||||
|
<div class="clipboard-panel" on:click|stopPropagation>
|
||||||
|
<div class="clipboard-header">
|
||||||
|
<h2>📋 Clipboard History</h2>
|
||||||
|
<div class="header-actions">
|
||||||
|
{#if entries.length > 0}
|
||||||
|
<button
|
||||||
|
class="clear-btn"
|
||||||
|
on:click={handleClearHistory}
|
||||||
|
title="Clear non-pinned entries"
|
||||||
|
>
|
||||||
|
🗑️ Clear
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button class="close-btn" on:click={onClose}>✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="clipboard-controls">
|
||||||
|
<div class="search-box">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search clipboard..."
|
||||||
|
bind:value={searchQuery}
|
||||||
|
on:input={handleSearch}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="language-filter">
|
||||||
|
<button
|
||||||
|
class="filter-btn"
|
||||||
|
class:active={selectedLanguage === null}
|
||||||
|
on:click={() => handleLanguageFilter(null)}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
{#each languages as lang (lang)}
|
||||||
|
<button
|
||||||
|
class="filter-btn"
|
||||||
|
class:active={selectedLanguage === lang}
|
||||||
|
on:click={() => handleLanguageFilter(lang)}
|
||||||
|
>
|
||||||
|
{getLanguageIcon(lang)}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="clipboard-content">
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="loading">Loading...</div>
|
||||||
|
{:else if entries.length === 0}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>📭 No clipboard entries yet</p>
|
||||||
|
<p class="hint">
|
||||||
|
Copy code from Claude's responses or use the copy button on code blocks to save them
|
||||||
|
here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="entries-list">
|
||||||
|
{#each entries as entry (entry.id)}
|
||||||
|
<div class="entry" class:pinned={entry.is_pinned}>
|
||||||
|
<div class="entry-header">
|
||||||
|
<div class="entry-meta">
|
||||||
|
<span class="language-badge">{getLanguageIcon(entry.language)}</span>
|
||||||
|
<span class="timestamp">{clipboardStore.formatTimestamp(entry.timestamp)}</span>
|
||||||
|
{#if entry.is_pinned}
|
||||||
|
<span class="pin-badge">📌</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="entry-actions">
|
||||||
|
<button
|
||||||
|
class="action-btn"
|
||||||
|
title={entry.is_pinned ? "Unpin" : "Pin"}
|
||||||
|
on:click={() => handleTogglePin(entry.id)}
|
||||||
|
>
|
||||||
|
{entry.is_pinned ? "📌" : "📍"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="action-btn"
|
||||||
|
title="Copy to clipboard"
|
||||||
|
on:click={() => handleCopy(entry)}
|
||||||
|
>
|
||||||
|
{copiedId === entry.id ? "✓" : "📋"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="action-btn insert-btn"
|
||||||
|
title="Insert"
|
||||||
|
on:click={() => handleInsert(entry)}
|
||||||
|
>
|
||||||
|
➡️
|
||||||
|
</button>
|
||||||
|
{#if confirmingDeleteId === entry.id}
|
||||||
|
<button
|
||||||
|
class="action-btn confirm-delete"
|
||||||
|
on:click={() => handleDelete(entry.id)}
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</button>
|
||||||
|
<button class="action-btn" on:click={() => (confirmingDeleteId = null)}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="action-btn delete-btn"
|
||||||
|
title="Delete"
|
||||||
|
on:click={() => (confirmingDeleteId = entry.id)}
|
||||||
|
>
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre class="entry-content"><code>{truncateContent(entry.content)}</code></pre>
|
||||||
|
{#if entry.source}
|
||||||
|
<div class="entry-source">From: {entry.source}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.clipboard-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clipboard-panel {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 700px;
|
||||||
|
max-height: 80vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clipboard-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clipboard-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-btn:hover {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clipboard-controls {
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--trans-pink);
|
||||||
|
box-shadow: 0 0 0 2px rgba(245, 169, 184, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-filter {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn.active {
|
||||||
|
background: var(--trans-gradient-vibrant);
|
||||||
|
color: #1a1a2e;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clipboard-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading,
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state .hint {
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entries-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry:hover {
|
||||||
|
border-color: var(--trans-pink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry.pinned {
|
||||||
|
border-color: var(--trans-blue);
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(91, 206, 250, 0.05) 0%,
|
||||||
|
rgba(245, 169, 184, 0.05) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-badge {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timestamp {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-badge {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 4px 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-btn {
|
||||||
|
background: var(--trans-gradient-vibrant);
|
||||||
|
border-radius: 4px;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn:hover {
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-delete {
|
||||||
|
color: #ff6b6b;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-content {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-content code {
|
||||||
|
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-source {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
import { characterState } from "$lib/stores/character";
|
import { characterState } from "$lib/stores/character";
|
||||||
import { handleNewUserMessage } from "$lib/notifications/rules";
|
import { handleNewUserMessage } from "$lib/notifications/rules";
|
||||||
import { setSkipNextGreeting } from "$lib/tauri";
|
import { setSkipNextGreeting } from "$lib/tauri";
|
||||||
|
import { clipboardStore } from "$lib/stores/clipboard";
|
||||||
import {
|
import {
|
||||||
setShouldRestoreHistory,
|
setShouldRestoreHistory,
|
||||||
setSavedHistory,
|
setSavedHistory,
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
import AttachmentPreview from "$lib/components/AttachmentPreview.svelte";
|
import AttachmentPreview from "$lib/components/AttachmentPreview.svelte";
|
||||||
import SnippetLibraryPanel from "$lib/components/SnippetLibraryPanel.svelte";
|
import SnippetLibraryPanel from "$lib/components/SnippetLibraryPanel.svelte";
|
||||||
import QuickActionsPanel from "$lib/components/QuickActionsPanel.svelte";
|
import QuickActionsPanel from "$lib/components/QuickActionsPanel.svelte";
|
||||||
|
import ClipboardHistoryPanel from "$lib/components/ClipboardHistoryPanel.svelte";
|
||||||
import type { Attachment } from "$lib/types/messages";
|
import type { Attachment } from "$lib/types/messages";
|
||||||
|
|
||||||
const INPUT_HISTORY_KEY = "hikari-input-history";
|
const INPUT_HISTORY_KEY = "hikari-input-history";
|
||||||
@@ -43,6 +45,7 @@
|
|||||||
let isDragging = $state(false);
|
let isDragging = $state(false);
|
||||||
let showSnippetLibrary = $state(false);
|
let showSnippetLibrary = $state(false);
|
||||||
let showQuickActions = $state(false);
|
let showQuickActions = $state(false);
|
||||||
|
let showClipboardHistory = $state(false);
|
||||||
|
|
||||||
// Input history state
|
// Input history state
|
||||||
let inputHistory = $state<string[]>([]);
|
let inputHistory = $state<string[]>([]);
|
||||||
@@ -504,6 +507,15 @@ User: ${formattedMessage}`;
|
|||||||
const items = event.clipboardData?.items;
|
const items = event.clipboardData?.items;
|
||||||
let handledFile = false;
|
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) {
|
if (items && items.length > 0) {
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.kind === "file") {
|
if (item.kind === "file") {
|
||||||
@@ -631,6 +643,16 @@ User: ${formattedMessage}`;
|
|||||||
userHasTyped = true;
|
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<void> {
|
async function handleQuickAction(prompt: string): Promise<void> {
|
||||||
// Quick actions send the prompt directly
|
// Quick actions send the prompt directly
|
||||||
if (!isConnected || isSubmitting) return;
|
if (!isConnected || isSubmitting) return;
|
||||||
@@ -787,6 +809,27 @@ User: ${formattedMessage}`;
|
|||||||
</svg>
|
</svg>
|
||||||
<span>Snippets</span>
|
<span>Snippets</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (showClipboardHistory = true)}
|
||||||
|
class="control-button"
|
||||||
|
title="Clipboard History"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2" />
|
||||||
|
<rect x="9" y="3" width="6" height="4" rx="1" />
|
||||||
|
</svg>
|
||||||
|
<span>Clipboard</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="input-row">
|
<div class="input-row">
|
||||||
@@ -870,9 +913,14 @@ User: ${formattedMessage}`;
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showQuickActions}
|
{#if showQuickActions}
|
||||||
<QuickActionsPanel
|
<QuickActionsPanel onClose={() => (showQuickActions = false)} onAction={handleQuickAction} />
|
||||||
onClose={() => (showQuickActions = false)}
|
{/if}
|
||||||
onAction={handleQuickAction}
|
|
||||||
|
{#if showClipboardHistory}
|
||||||
|
<ClipboardHistoryPanel
|
||||||
|
isOpen={showClipboardHistory}
|
||||||
|
onClose={() => (showClipboardHistory = false)}
|
||||||
|
onInsert={handleClipboardInsert}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -1043,6 +1091,8 @@ User: ${formattedMessage}`;
|
|||||||
|
|
||||||
.trans-gradient-button:hover:not(:disabled) {
|
.trans-gradient-button:hover:not(:disabled) {
|
||||||
filter: brightness(1.1);
|
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);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import hljs from "highlight.js";
|
import hljs from "highlight.js";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
|
import { clipboardStore } from "$lib/stores/clipboard";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
content: string;
|
content: string;
|
||||||
@@ -147,6 +148,12 @@
|
|||||||
.replace(/>/g, ">");
|
.replace(/>/g, ">");
|
||||||
if (code) {
|
if (code) {
|
||||||
await navigator.clipboard.writeText(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");
|
const textSpan = copyBtn.querySelector(".copy-text");
|
||||||
if (textSpan) {
|
if (textSpan) {
|
||||||
textSpan.textContent = "Copied!";
|
textSpan.textContent = "Copied!";
|
||||||
|
|||||||
@@ -251,9 +251,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={() => (showStats = !showStats)}
|
onclick={() => (showStats = !showStats)}
|
||||||
class="p-1 text-gray-500 icon-trans-hover {showStats
|
class="p-1 text-gray-500 icon-trans-hover {showStats ? 'text-[var(--trans-pink)]' : ''}"
|
||||||
? 'text-[var(--trans-pink)]'
|
|
||||||
: ''}"
|
|
||||||
title="Usage Stats"
|
title="Usage Stats"
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { claudeStore, type TerminalLine } from "$lib/stores/claude";
|
import { claudeStore, type TerminalLine } from "$lib/stores/claude";
|
||||||
import { afterUpdate, tick } from "svelte";
|
import { afterUpdate, tick, onMount, onDestroy } from "svelte";
|
||||||
import ConversationTabs from "./ConversationTabs.svelte";
|
import ConversationTabs from "./ConversationTabs.svelte";
|
||||||
import Markdown from "./Markdown.svelte";
|
import Markdown from "./Markdown.svelte";
|
||||||
import HighlightedText from "./HighlightedText.svelte";
|
import HighlightedText from "./HighlightedText.svelte";
|
||||||
import { searchState, searchQuery } from "$lib/stores/search";
|
import { searchState, searchQuery } from "$lib/stores/search";
|
||||||
|
import { clipboardStore } from "$lib/stores/clipboard";
|
||||||
|
|
||||||
let terminalElement: HTMLDivElement;
|
let terminalElement: HTMLDivElement;
|
||||||
let shouldAutoScroll = true;
|
let shouldAutoScroll = true;
|
||||||
@@ -122,6 +123,32 @@
|
|||||||
searchState.setMatchCount(0);
|
searchState.setMatchCount(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle manual text selection copy events
|
||||||
|
function handleCopy() {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
const selectedText = selection?.toString();
|
||||||
|
|
||||||
|
if (selectedText && selectedText.trim().length > 0) {
|
||||||
|
// Only capture multi-line or longer text (likely code/meaningful content)
|
||||||
|
if (selectedText.includes("\n") || selectedText.length > 50) {
|
||||||
|
clipboardStore.captureClipboard(selectedText, null, "Copied from chat");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
// Listen for copy events on the terminal
|
||||||
|
if (terminalElement) {
|
||||||
|
terminalElement.addEventListener("copy", handleCopy);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (terminalElement) {
|
||||||
|
terminalElement.removeEventListener("copy", handleCopy);
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
// Clipboard history store for managing copied code snippets
|
||||||
|
// Implements issue #25 - Clipboard History feature
|
||||||
|
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { writable, derived } from "svelte/store";
|
||||||
|
|
||||||
|
export interface ClipboardEntry {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
language: string | null;
|
||||||
|
source: string | null;
|
||||||
|
timestamp: string;
|
||||||
|
is_pinned: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create stores
|
||||||
|
const entriesStore = writable<ClipboardEntry[]>([]);
|
||||||
|
const searchQueryStore = writable<string>("");
|
||||||
|
const languageFilterStore = writable<string | null>(null);
|
||||||
|
const isLoadingStore = writable<boolean>(false);
|
||||||
|
|
||||||
|
// Derived store for filtered entries
|
||||||
|
const filteredEntriesStore = derived(
|
||||||
|
[entriesStore, searchQueryStore, languageFilterStore],
|
||||||
|
([$entries, $searchQuery, $languageFilter]) => {
|
||||||
|
let filtered = $entries;
|
||||||
|
|
||||||
|
// Filter by language
|
||||||
|
if ($languageFilter) {
|
||||||
|
filtered = filtered.filter((e) => e.language === $languageFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by search query
|
||||||
|
if ($searchQuery) {
|
||||||
|
const query = $searchQuery.toLowerCase();
|
||||||
|
filtered = filtered.filter(
|
||||||
|
(e) =>
|
||||||
|
e.content.toLowerCase().includes(query) ||
|
||||||
|
e.language?.toLowerCase().includes(query) ||
|
||||||
|
e.source?.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Derived store for unique languages
|
||||||
|
const languagesStore = derived(entriesStore, ($entries) => {
|
||||||
|
const languages = new Set<string>();
|
||||||
|
for (const entry of $entries) {
|
||||||
|
if (entry.language) {
|
||||||
|
languages.add(entry.language);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(languages).sort();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function to detect language from content
|
||||||
|
function detectLanguage(content: string): string | null {
|
||||||
|
// Common language patterns
|
||||||
|
const patterns: [RegExp, string][] = [
|
||||||
|
[/^(import|export|const|let|var|function|class|interface|type)\s/m, "typescript"],
|
||||||
|
[/^(def|class|import|from|if __name__|async def)\s/m, "python"],
|
||||||
|
[/^(fn|let|mut|impl|struct|enum|use|mod|pub)\s/m, "rust"],
|
||||||
|
[/^(package|import|func|type|var|const)\s/m, "go"],
|
||||||
|
[/<\?php/m, "php"],
|
||||||
|
[/^(SELECT|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP)\s/im, "sql"],
|
||||||
|
[/^<!DOCTYPE|^<html|^<div|^<span/im, "html"],
|
||||||
|
[/^\s*\{[\s\S]*"[\w-]+":/m, "json"],
|
||||||
|
[/^---\s*\n/m, "yaml"],
|
||||||
|
[/^\s*#\s*(include|define|ifdef)/m, "c"],
|
||||||
|
[/^(public|private|protected)\s+(class|interface|static)/m, "java"],
|
||||||
|
[/^\$[\w_]+\s*=/m, "bash"],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [pattern, lang] of patterns) {
|
||||||
|
if (pattern.test(content)) {
|
||||||
|
return lang;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store actions
|
||||||
|
async function loadEntries(): Promise<void> {
|
||||||
|
isLoadingStore.set(true);
|
||||||
|
try {
|
||||||
|
const entries = await invoke<ClipboardEntry[]>("list_clipboard_entries", {
|
||||||
|
language: null,
|
||||||
|
});
|
||||||
|
entriesStore.set(entries);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load clipboard entries:", error);
|
||||||
|
} finally {
|
||||||
|
isLoadingStore.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureClipboard(
|
||||||
|
content: string,
|
||||||
|
language?: string | null,
|
||||||
|
source?: string | null
|
||||||
|
): Promise<ClipboardEntry | null> {
|
||||||
|
try {
|
||||||
|
// Auto-detect language if not provided
|
||||||
|
const detectedLanguage = language ?? detectLanguage(content);
|
||||||
|
|
||||||
|
const entry = await invoke<ClipboardEntry>("capture_clipboard", {
|
||||||
|
content,
|
||||||
|
language: detectedLanguage,
|
||||||
|
source: source ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reload entries to get the updated list
|
||||||
|
await loadEntries();
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to capture clipboard:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteEntry(id: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await invoke("delete_clipboard_entry", { id });
|
||||||
|
entriesStore.update((entries) => entries.filter((e) => e.id !== id));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete clipboard entry:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePin(id: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const updated = await invoke<ClipboardEntry>("toggle_pin_clipboard_entry", { id });
|
||||||
|
entriesStore.update((entries) =>
|
||||||
|
entries
|
||||||
|
.map((e) => (e.id === id ? updated : e))
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Pinned first, then by timestamp
|
||||||
|
if (a.is_pinned && !b.is_pinned) return -1;
|
||||||
|
if (!a.is_pinned && b.is_pinned) return 1;
|
||||||
|
return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to toggle pin:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearHistory(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await invoke("clear_clipboard_history");
|
||||||
|
entriesStore.update((entries) => entries.filter((e) => e.is_pinned));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to clear clipboard history:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateLanguage(id: string, language: string | null): Promise<void> {
|
||||||
|
try {
|
||||||
|
const updated = await invoke<ClipboardEntry>("update_clipboard_language", {
|
||||||
|
id,
|
||||||
|
language,
|
||||||
|
});
|
||||||
|
entriesStore.update((entries) => entries.map((e) => (e.id === id ? updated : e)));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update language:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSearchQuery(query: string): void {
|
||||||
|
searchQueryStore.set(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLanguageFilter(language: string | null): void {
|
||||||
|
languageFilterStore.set(language);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy entry content to clipboard
|
||||||
|
async function copyToClipboard(content: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(content);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to copy to clipboard:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format timestamp for display
|
||||||
|
function formatTimestamp(timestamp: string): string {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
|
if (diffMins < 1) return "Just now";
|
||||||
|
if (diffMins < 60) return `${diffMins}m ago`;
|
||||||
|
if (diffHours < 24) return `${diffHours}h ago`;
|
||||||
|
if (diffDays < 7) return `${diffDays}d ago`;
|
||||||
|
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export the store
|
||||||
|
export const clipboardStore = {
|
||||||
|
subscribe: entriesStore.subscribe,
|
||||||
|
entries: entriesStore,
|
||||||
|
filteredEntries: filteredEntriesStore,
|
||||||
|
languages: languagesStore,
|
||||||
|
searchQuery: searchQueryStore,
|
||||||
|
languageFilter: languageFilterStore,
|
||||||
|
isLoading: isLoadingStore,
|
||||||
|
loadEntries,
|
||||||
|
captureClipboard,
|
||||||
|
deleteEntry,
|
||||||
|
togglePin,
|
||||||
|
clearHistory,
|
||||||
|
updateLanguage,
|
||||||
|
setSearchQuery,
|
||||||
|
setLanguageFilter,
|
||||||
|
copyToClipboard,
|
||||||
|
formatTimestamp,
|
||||||
|
detectLanguage,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user