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>
468 lines
17 KiB
Svelte
468 lines
17 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from "svelte";
|
|
import { snippetsStore, type Snippet } from "$lib/stores/snippets";
|
|
|
|
interface Props {
|
|
onClose: () => void;
|
|
onInsert: (content: string) => void;
|
|
}
|
|
|
|
const { onClose, onInsert }: Props = $props();
|
|
|
|
let editingSnippet = $state<Snippet | null>(null);
|
|
let isCreating = $state(false);
|
|
let showDeleteConfirm = $state<string | null>(null);
|
|
|
|
let editName = $state("");
|
|
let editContent = $state("");
|
|
let editCategory = $state("");
|
|
let newCategoryInput = $state("");
|
|
let showNewCategoryInput = $state(false);
|
|
|
|
const snippets = $derived(snippetsStore.filteredSnippets);
|
|
const categories = $derived(snippetsStore.categories);
|
|
const selectedCategory = $derived(snippetsStore.selectedCategory);
|
|
const isLoading = $derived(snippetsStore.isLoading);
|
|
|
|
onMount(() => {
|
|
snippetsStore.loadSnippets();
|
|
});
|
|
|
|
function handleSelectCategory(category: string | null): void {
|
|
snippetsStore.setSelectedCategory(category);
|
|
}
|
|
|
|
function handleInsert(snippet: Snippet): void {
|
|
onInsert(snippet.content);
|
|
onClose();
|
|
}
|
|
|
|
function handleStartCreate(): void {
|
|
isCreating = true;
|
|
editingSnippet = null;
|
|
editName = "";
|
|
editContent = "";
|
|
editCategory = $categories.length > 0 ? $categories[0] : "Custom";
|
|
showNewCategoryInput = false;
|
|
newCategoryInput = "";
|
|
}
|
|
|
|
function handleStartEdit(snippet: Snippet): void {
|
|
editingSnippet = snippet;
|
|
isCreating = false;
|
|
editName = snippet.name;
|
|
editContent = snippet.content;
|
|
editCategory = snippet.category;
|
|
showNewCategoryInput = false;
|
|
newCategoryInput = "";
|
|
}
|
|
|
|
function handleCancelEdit(): void {
|
|
editingSnippet = null;
|
|
isCreating = false;
|
|
showNewCategoryInput = false;
|
|
newCategoryInput = "";
|
|
}
|
|
|
|
async function handleSave(): Promise<void> {
|
|
const finalCategory =
|
|
showNewCategoryInput && newCategoryInput.trim() ? newCategoryInput.trim() : editCategory;
|
|
|
|
if (!editName.trim() || !editContent.trim() || !finalCategory) {
|
|
return;
|
|
}
|
|
|
|
if (isCreating) {
|
|
await snippetsStore.createSnippet(editName.trim(), editContent.trim(), finalCategory);
|
|
} else if (editingSnippet) {
|
|
await snippetsStore.updateSnippet(
|
|
editingSnippet.id,
|
|
editName.trim(),
|
|
editContent.trim(),
|
|
finalCategory
|
|
);
|
|
}
|
|
|
|
handleCancelEdit();
|
|
}
|
|
|
|
async function handleDelete(snippetId: string): Promise<void> {
|
|
await snippetsStore.deleteSnippet(snippetId);
|
|
showDeleteConfirm = null;
|
|
}
|
|
|
|
function toggleNewCategory(): void {
|
|
showNewCategoryInput = !showNewCategoryInput;
|
|
if (showNewCategoryInput) {
|
|
newCategoryInput = "";
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div
|
|
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
|
onclick={onClose}
|
|
role="button"
|
|
tabindex="0"
|
|
onkeydown={(e) => e.key === "Escape" && onClose()}
|
|
>
|
|
<div
|
|
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-4xl w-full max-h-[80vh] overflow-hidden flex flex-col"
|
|
onclick={(e) => e.stopPropagation()}
|
|
onkeydown={(e) => e.stopPropagation()}
|
|
role="dialog"
|
|
aria-labelledby="snippet-library-title"
|
|
tabindex="-1"
|
|
>
|
|
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
|
|
<div class="flex items-center gap-3">
|
|
{#if editingSnippet || isCreating}
|
|
<button
|
|
onclick={handleCancelEdit}
|
|
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
|
aria-label="Back to list"
|
|
>
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M15 19l-7-7 7-7"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
{/if}
|
|
<h2 id="snippet-library-title" class="text-xl font-semibold text-[var(--text-primary)]">
|
|
{#if isCreating}
|
|
Create Snippet
|
|
{:else if editingSnippet}
|
|
Edit Snippet
|
|
{:else}
|
|
Snippet Library
|
|
{/if}
|
|
</h2>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
{#if !editingSnippet && !isCreating}
|
|
<button
|
|
onclick={handleStartCreate}
|
|
class="btn-trans-gradient px-3 py-1.5 text-sm font-medium rounded flex items-center gap-2"
|
|
>
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M12 4v16m8-8H4"
|
|
/>
|
|
</svg>
|
|
New Snippet
|
|
</button>
|
|
{/if}
|
|
<button
|
|
onclick={onClose}
|
|
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
|
aria-label="Close"
|
|
>
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M6 18L18 6M6 6l12 12"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{#if editingSnippet || isCreating}
|
|
<div class="p-6 flex-1 overflow-y-auto">
|
|
<div class="space-y-4">
|
|
<div>
|
|
<label
|
|
for="snippet-name"
|
|
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
|
|
>
|
|
Name
|
|
</label>
|
|
<input
|
|
id="snippet-name"
|
|
type="text"
|
|
bind:value={editName}
|
|
placeholder="Enter snippet name..."
|
|
class="w-full px-4 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)]"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label
|
|
for="snippet-category"
|
|
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
|
|
>
|
|
Category
|
|
</label>
|
|
<div class="flex items-center gap-2">
|
|
{#if showNewCategoryInput}
|
|
<input
|
|
type="text"
|
|
bind:value={newCategoryInput}
|
|
placeholder="Enter new category..."
|
|
class="flex-1 px-4 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)]"
|
|
/>
|
|
{:else}
|
|
<select
|
|
id="snippet-category"
|
|
bind:value={editCategory}
|
|
class="flex-1 px-4 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
|
|
>
|
|
{#each $categories as category (category)}
|
|
<option value={category}>{category}</option>
|
|
{/each}
|
|
<option value="Custom">Custom</option>
|
|
</select>
|
|
{/if}
|
|
<button
|
|
onclick={toggleNewCategory}
|
|
class="px-3 py-2 text-sm font-medium bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
|
>
|
|
{showNewCategoryInput ? "Use Existing" : "New Category"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label
|
|
for="snippet-content"
|
|
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
|
|
>
|
|
Content
|
|
</label>
|
|
<textarea
|
|
id="snippet-content"
|
|
bind:value={editContent}
|
|
placeholder="Enter your prompt snippet..."
|
|
rows="8"
|
|
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)] resize-none font-mono text-sm"
|
|
></textarea>
|
|
</div>
|
|
|
|
<div class="flex justify-end gap-2 pt-4">
|
|
<button
|
|
onclick={handleCancelEdit}
|
|
class="px-4 py-2 text-sm font-medium bg-[var(--bg-secondary)] text-[var(--text-secondary)] rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onclick={handleSave}
|
|
disabled={!editName.trim() || !editContent.trim()}
|
|
class="btn-trans-gradient px-4 py-2 text-sm font-medium rounded-lg"
|
|
>
|
|
{isCreating ? "Create" : "Save"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<div class="flex flex-1 overflow-hidden">
|
|
<div class="w-48 border-r border-[var(--border-color)] p-4 overflow-y-auto">
|
|
<h3
|
|
class="text-xs font-semibold text-[var(--text-tertiary)] uppercase tracking-wider mb-3"
|
|
>
|
|
Categories
|
|
</h3>
|
|
<div class="space-y-1">
|
|
<button
|
|
onclick={() => handleSelectCategory(null)}
|
|
class="w-full text-left px-3 py-2 rounded-lg text-sm transition-colors {$selectedCategory ===
|
|
null
|
|
? 'bg-[var(--accent-primary)]/10 text-[var(--accent-primary)]'
|
|
: 'text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)]'}"
|
|
>
|
|
All Snippets
|
|
</button>
|
|
{#each $categories as category (category)}
|
|
<button
|
|
onclick={() => handleSelectCategory(category)}
|
|
class="w-full text-left px-3 py-2 rounded-lg text-sm transition-colors {$selectedCategory ===
|
|
category
|
|
? 'bg-[var(--accent-primary)]/10 text-[var(--accent-primary)]'
|
|
: 'text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)]'}"
|
|
>
|
|
{category}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex-1 overflow-y-auto">
|
|
{#if $isLoading}
|
|
<div class="flex items-center justify-center p-8">
|
|
<div class="text-[var(--text-tertiary)]">Loading snippets...</div>
|
|
</div>
|
|
{:else if $snippets.length === 0}
|
|
<div class="flex flex-col items-center justify-center p-8 text-center">
|
|
<svg
|
|
class="w-16 h-16 text-[var(--text-tertiary)] mb-4"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="1.5"
|
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
|
/>
|
|
</svg>
|
|
<p class="text-[var(--text-secondary)]">No snippets in this category</p>
|
|
<button
|
|
onclick={handleStartCreate}
|
|
class="btn-trans-gradient mt-4 px-4 py-2 text-sm font-medium rounded-lg"
|
|
>
|
|
Create your first snippet
|
|
</button>
|
|
</div>
|
|
{:else}
|
|
<div class="divide-y divide-[var(--border-color)]">
|
|
{#each $snippets as snippet (snippet.id)}
|
|
<div class="p-4 hover:bg-[var(--bg-secondary)] transition-colors group">
|
|
<div class="flex items-start justify-between gap-4">
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-center gap-2 mb-1">
|
|
<h3 class="font-medium text-[var(--text-primary)]">{snippet.name}</h3>
|
|
{#if snippet.is_default}
|
|
<span
|
|
class="px-2 py-0.5 text-xs font-medium bg-[var(--accent-primary)]/10 text-[var(--accent-primary)] rounded"
|
|
>
|
|
Default
|
|
</span>
|
|
{/if}
|
|
<span class="text-xs text-[var(--text-tertiary)]">{snippet.category}</span>
|
|
</div>
|
|
<p class="text-sm text-[var(--text-secondary)] line-clamp-2 font-mono">
|
|
{snippet.content}
|
|
</p>
|
|
</div>
|
|
<div
|
|
class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
>
|
|
<button
|
|
onclick={() => handleInsert(snippet)}
|
|
class="btn-trans-gradient px-3 py-1.5 text-xs font-medium rounded"
|
|
title="Insert this snippet"
|
|
>
|
|
Insert
|
|
</button>
|
|
<button
|
|
onclick={() => handleStartEdit(snippet)}
|
|
class="p-1.5 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors"
|
|
title="Edit snippet"
|
|
>
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
{#if !snippet.is_default}
|
|
{#if showDeleteConfirm === snippet.id}
|
|
<div class="flex items-center gap-1">
|
|
<button
|
|
onclick={() => handleDelete(snippet.id)}
|
|
class="px-2 py-1 text-xs font-medium bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
|
|
>
|
|
Confirm
|
|
</button>
|
|
<button
|
|
onclick={() => (showDeleteConfirm = null)}
|
|
class="px-2 py-1 text-xs font-medium bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded hover:bg-[var(--bg-secondary)] transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
{:else}
|
|
<button
|
|
onclick={() => (showDeleteConfirm = snippet.id)}
|
|
class="p-1.5 text-[var(--text-tertiary)] hover:text-red-400 transition-colors"
|
|
title="Delete snippet"
|
|
>
|
|
<svg
|
|
class="w-4 h-4"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
[role="dialog"] {
|
|
animation: slideIn 0.2s ease-out;
|
|
}
|
|
|
|
@keyframes slideIn {
|
|
from {
|
|
opacity: 0;
|
|
transform: scale(0.95) translateY(10px);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: scale(1) translateY(0);
|
|
}
|
|
}
|
|
|
|
.overflow-y-auto {
|
|
scrollbar-width: thin;
|
|
scrollbar-color: var(--border-color) transparent;
|
|
}
|
|
|
|
.overflow-y-auto::-webkit-scrollbar {
|
|
width: 8px;
|
|
}
|
|
|
|
.overflow-y-auto::-webkit-scrollbar-track {
|
|
background: transparent;
|
|
}
|
|
|
|
.overflow-y-auto::-webkit-scrollbar-thumb {
|
|
background-color: var(--border-color);
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
|
background-color: var(--accent-primary);
|
|
}
|
|
|
|
.line-clamp-2 {
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
</style>
|