generated from nhcarrigan/template
feat: add multiple productivity features and UI enhancements (#68)
## Summary This PR adds a collection of productivity features and UI enhancements to improve the Hikari Desktop experience: ### New Features - **Clipboard History** (#25) - Track and manage copied code snippets with language detection, search, filtering, and pinning - **Quick Actions Panel** (#15) - Buttons for common quick actions like "Review PR", "Run tests", "Explain file", with customizable actions - **Git Integration Panel** (#24) - View current branch, changed/staged files, quick git actions (commit, push, pull), and branch management - **Session Import/Export** (#8) - Export conversations to JSON and import previously saved sessions - **Snippet Library** (#22) - Save and reuse common prompts with categories and quick insert - **Session History** (#14) - Auto-save conversations with browsable history and search - **High Contrast Mode** (#20) - Accessibility theme with improved visibility - **Minimize to System Tray** (#11) - System tray support with right-click menu ### UI Enhancements - Trans-pride gradient theme applied across UI elements - Copy button added to code blocks - Linter formatting and eslint-disable comments for cleaner code ## Closes Closes #8 Closes #11 Closes #14 Closes #15 Closes #20 Closes #22 Closes #24 Closes #25 Closes #34 Closes #35 Closes #36 Closes #37 Closes #69 Closes #70 ## Test Plan - [ ] Verify clipboard history captures code from code block copy buttons - [ ] Verify clipboard history captures manually selected text from terminal - [ ] Test snippet library CRUD operations and insertion - [ ] Test quick actions panel with default and custom actions - [ ] Test git panel shows correct status, branch, and performs git operations - [ ] Test session history auto-save and restore - [ ] Test session import/export roundtrip - [ ] Verify high contrast mode provides adequate contrast - [ ] Test minimize to tray functionality and tray menu - [ ] Verify trans-pride gradient theme displays correctly in all themes --- *✨ This PR was created with help from Hikari~ 🌸* Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Reviewed-on: #68 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #68.
This commit is contained in:
@@ -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