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>
457 lines
18 KiB
Svelte
457 lines
18 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from "svelte";
|
|
import { quickActionsStore, type QuickAction } from "$lib/stores/quickActions";
|
|
|
|
interface Props {
|
|
onClose: () => void;
|
|
onAction: (prompt: string) => void;
|
|
}
|
|
|
|
const { onClose, onAction }: Props = $props();
|
|
|
|
let editingAction = $state<QuickAction | null>(null);
|
|
let isCreating = $state(false);
|
|
let showDeleteConfirm = $state<string | null>(null);
|
|
|
|
let editName = $state("");
|
|
let editPrompt = $state("");
|
|
let editIcon = $state("zap");
|
|
|
|
const actions = $derived(quickActionsStore.actions);
|
|
const isLoading = $derived(quickActionsStore.isLoading);
|
|
|
|
const availableIcons = [
|
|
{ id: "zap", label: "Lightning" },
|
|
{ id: "play", label: "Play" },
|
|
{ id: "file-text", label: "File" },
|
|
{ id: "alert-circle", label: "Alert" },
|
|
{ id: "check-square", label: "Check" },
|
|
{ id: "refresh-cw", label: "Refresh" },
|
|
{ id: "git-pull-request", label: "Git PR" },
|
|
{ id: "code", label: "Code" },
|
|
{ id: "search", label: "Search" },
|
|
{ id: "terminal", label: "Terminal" },
|
|
{ id: "bug", label: "Bug" },
|
|
{ id: "shield", label: "Shield" },
|
|
];
|
|
|
|
onMount(() => {
|
|
quickActionsStore.loadQuickActions();
|
|
});
|
|
|
|
function handleAction(action: QuickAction): void {
|
|
onAction(action.prompt);
|
|
onClose();
|
|
}
|
|
|
|
function handleStartCreate(): void {
|
|
isCreating = true;
|
|
editingAction = null;
|
|
editName = "";
|
|
editPrompt = "";
|
|
editIcon = "zap";
|
|
}
|
|
|
|
function handleStartEdit(action: QuickAction): void {
|
|
editingAction = action;
|
|
isCreating = false;
|
|
editName = action.name;
|
|
editPrompt = action.prompt;
|
|
editIcon = action.icon;
|
|
}
|
|
|
|
function handleCancelEdit(): void {
|
|
editingAction = null;
|
|
isCreating = false;
|
|
}
|
|
|
|
async function handleSave(): Promise<void> {
|
|
if (!editName.trim() || !editPrompt.trim()) {
|
|
return;
|
|
}
|
|
|
|
if (isCreating) {
|
|
await quickActionsStore.createQuickAction(editName.trim(), editPrompt.trim(), editIcon);
|
|
} else if (editingAction) {
|
|
await quickActionsStore.updateQuickAction(
|
|
editingAction.id,
|
|
editName.trim(),
|
|
editPrompt.trim(),
|
|
editIcon
|
|
);
|
|
}
|
|
|
|
handleCancelEdit();
|
|
}
|
|
|
|
async function handleDelete(actionId: string): Promise<void> {
|
|
await quickActionsStore.deleteQuickAction(actionId);
|
|
showDeleteConfirm = null;
|
|
}
|
|
|
|
function getIconSvg(icon: string): string {
|
|
const icons: Record<string, string> = {
|
|
"git-pull-request":
|
|
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 3v12M18 9a3 3 0 100 6 3 3 0 000-6zm0 0V3m0 18v-6M6 21a3 3 0 100-6 3 3 0 000 6z" />',
|
|
play: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />',
|
|
"file-text":
|
|
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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" />',
|
|
"alert-circle":
|
|
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />',
|
|
"check-square":
|
|
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />',
|
|
"refresh-cw":
|
|
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />',
|
|
zap: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />',
|
|
code: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />',
|
|
search:
|
|
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />',
|
|
terminal:
|
|
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />',
|
|
bug: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />',
|
|
shield:
|
|
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />',
|
|
};
|
|
return icons[icon] || icons["zap"];
|
|
}
|
|
</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-2xl w-full max-h-[80vh] overflow-hidden flex flex-col"
|
|
onclick={(e) => e.stopPropagation()}
|
|
onkeydown={(e) => e.stopPropagation()}
|
|
role="dialog"
|
|
aria-labelledby="quick-actions-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 editingAction || 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="quick-actions-title" class="text-xl font-semibold text-[var(--text-primary)]">
|
|
{#if isCreating}
|
|
Create Quick Action
|
|
{:else if editingAction}
|
|
Edit Quick Action
|
|
{:else}
|
|
Quick Actions
|
|
{/if}
|
|
</h2>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
{#if !editingAction && !isCreating}
|
|
<button
|
|
onclick={handleStartCreate}
|
|
class="px-3 py-1.5 text-sm font-medium btn-trans-gradient 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 Action
|
|
</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 editingAction || isCreating}
|
|
<div class="p-6 flex-1 overflow-y-auto">
|
|
<div class="space-y-4">
|
|
<div>
|
|
<label
|
|
for="action-name"
|
|
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
|
|
>
|
|
Name
|
|
</label>
|
|
<input
|
|
id="action-name"
|
|
type="text"
|
|
bind:value={editName}
|
|
placeholder="Enter action 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="action-icon"
|
|
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
|
|
>
|
|
Icon
|
|
</label>
|
|
<div class="grid grid-cols-6 gap-2">
|
|
{#each availableIcons as icon (icon.id)}
|
|
<button
|
|
onclick={() => (editIcon = icon.id)}
|
|
class="p-3 rounded-lg border transition-colors {editIcon === icon.id
|
|
? 'bg-[var(--accent-primary)]/10 border-[var(--accent-primary)] text-[var(--accent-primary)]'
|
|
: 'bg-[var(--bg-secondary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]/50'}"
|
|
title={icon.label}
|
|
>
|
|
<svg
|
|
class="w-5 h-5 mx-auto"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -- Icons are from controlled internal function -->
|
|
{@html getIconSvg(icon.id)}
|
|
</svg>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label
|
|
for="action-prompt"
|
|
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
|
|
>
|
|
Prompt
|
|
</label>
|
|
<textarea
|
|
id="action-prompt"
|
|
bind:value={editPrompt}
|
|
placeholder="Enter the prompt to send..."
|
|
rows="4"
|
|
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() || !editPrompt.trim()}
|
|
class="px-4 py-2 text-sm font-medium btn-trans-gradient rounded-lg"
|
|
>
|
|
{isCreating ? "Create" : "Save"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<div class="flex-1 overflow-y-auto p-6">
|
|
{#if $isLoading}
|
|
<div class="flex items-center justify-center p-8">
|
|
<div class="text-[var(--text-tertiary)]">Loading quick actions...</div>
|
|
</div>
|
|
{:else if $actions.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="M13 10V3L4 14h7v7l9-11h-7z"
|
|
/>
|
|
</svg>
|
|
<p class="text-[var(--text-secondary)]">No quick actions available</p>
|
|
<button
|
|
onclick={handleStartCreate}
|
|
class="mt-4 px-4 py-2 text-sm font-medium btn-trans-gradient rounded-lg"
|
|
>
|
|
Create your first action
|
|
</button>
|
|
</div>
|
|
{:else}
|
|
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
|
{#each $actions as action (action.id)}
|
|
<div
|
|
class="group relative bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg hover:border-[var(--accent-primary)]/50 transition-colors"
|
|
>
|
|
<button onclick={() => handleAction(action)} class="w-full p-4 text-left">
|
|
<div class="flex items-center gap-3 mb-2">
|
|
<div
|
|
class="w-10 h-10 rounded-lg bg-[var(--accent-primary)]/10 flex items-center justify-center text-[var(--accent-primary)]"
|
|
>
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -- Icons are from controlled internal function -->
|
|
{@html getIconSvg(action.icon)}
|
|
</svg>
|
|
</div>
|
|
<div class="flex-1 min-w-0">
|
|
<h3 class="font-medium text-[var(--text-primary)] truncate">{action.name}</h3>
|
|
{#if action.is_default}
|
|
<span class="text-xs text-[var(--text-tertiary)]">Default</span>
|
|
{:else}
|
|
<span class="text-xs text-[var(--accent-secondary)]">Custom</span>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
<p class="text-xs text-[var(--text-tertiary)] line-clamp-2">{action.prompt}</p>
|
|
</button>
|
|
<div
|
|
class="absolute top-2 right-2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
>
|
|
<button
|
|
onclick={(e) => {
|
|
e.stopPropagation();
|
|
handleStartEdit(action);
|
|
}}
|
|
class="p-1.5 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] bg-[var(--bg-primary)] rounded transition-colors"
|
|
title="Edit action"
|
|
>
|
|
<svg class="w-3.5 h-3.5" 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 !action.is_default}
|
|
{#if showDeleteConfirm === action.id}
|
|
<div class="flex items-center gap-1 bg-[var(--bg-primary)] rounded p-1">
|
|
<button
|
|
onclick={(e) => {
|
|
e.stopPropagation();
|
|
handleDelete(action.id);
|
|
}}
|
|
class="px-2 py-0.5 text-xs font-medium bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
|
|
>
|
|
Delete
|
|
</button>
|
|
<button
|
|
onclick={(e) => {
|
|
e.stopPropagation();
|
|
showDeleteConfirm = null;
|
|
}}
|
|
class="px-2 py-0.5 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={(e) => {
|
|
e.stopPropagation();
|
|
showDeleteConfirm = action.id;
|
|
}}
|
|
class="p-1.5 text-[var(--text-tertiary)] hover:text-red-400 bg-[var(--bg-primary)] rounded transition-colors"
|
|
title="Delete action"
|
|
>
|
|
<svg
|
|
class="w-3.5 h-3.5"
|
|
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>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</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>
|