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,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>
|
||||
Reference in New Issue
Block a user