generated from nhcarrigan/template
4c46d4c8fd
## 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>
498 lines
12 KiB
Svelte
498 lines
12 KiB
Svelte
<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>
|