Files
hikari-desktop/src/lib/components/GitPanel.svelte
T
hikari 4c46d4c8fd
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 54s
CI / Lint & Test (push) Successful in 14m42s
CI / Build Linux (push) Successful in 19m4s
CI / Build Windows (cross-compile) (push) Successful in 28m37s
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>
2026-01-25 22:19:00 -08:00

1108 lines
27 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { onMount, onDestroy } from "svelte";
import { claudeStore } from "$lib/stores/claude";
interface GitFileChange {
path: string;
status: string;
}
interface GitStatus {
is_repo: boolean;
branch: string | null;
upstream: string | null;
ahead: number;
behind: number;
staged: GitFileChange[];
unstaged: GitFileChange[];
untracked: string[];
}
interface GitBranch {
name: string;
is_current: boolean;
is_remote: boolean;
}
interface GitLogEntry {
hash: string;
short_hash: string;
author: string;
date: string;
message: string;
}
export let isOpen = false;
export let onClose: () => void;
let status: GitStatus | null = null;
let branches: GitBranch[] = [];
let log: GitLogEntry[] = [];
let loading = false;
let error: string | null = null;
let activeTab: "changes" | "branches" | "history" = "changes";
let commitMessage = "";
let showDiff = false;
let diffContent = "";
let diffFile: string | null = null;
let showBranchInput = false;
let newBranchName = "";
let actionInProgress = false;
let workingDir = "";
let refreshInterval: ReturnType<typeof setInterval> | null = null;
claudeStore.currentWorkingDirectory.subscribe((dir) => {
workingDir = dir;
});
onMount(() => {
if (isOpen) {
refresh();
refreshInterval = setInterval(refresh, 5000);
}
});
onDestroy(() => {
if (refreshInterval) {
clearInterval(refreshInterval);
}
});
$: if (isOpen && !refreshInterval) {
refresh();
refreshInterval = setInterval(refresh, 5000);
} else if (!isOpen && refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = null;
}
async function refresh() {
if (!workingDir) return;
loading = true;
error = null;
try {
status = await invoke<GitStatus>("git_status", { workingDir: workingDir });
if (status.is_repo) {
branches = await invoke<GitBranch[]>("git_branches", { workingDir: workingDir });
log = await invoke<GitLogEntry[]>("git_log", {
workingDir: workingDir,
limit: 20,
});
}
} catch (e) {
error = e as string;
} finally {
loading = false;
}
}
async function viewDiff(filePath: string, staged: boolean) {
try {
diffContent = await invoke<string>("git_diff", {
workingDir: workingDir,
filePath,
staged,
});
diffFile = filePath;
showDiff = true;
} catch (e) {
error = e as string;
}
}
async function stageFile(filePath: string) {
actionInProgress = true;
try {
await invoke("git_stage", { workingDir: workingDir, filePath });
await refresh();
} catch (e) {
error = e as string;
} finally {
actionInProgress = false;
}
}
async function unstageFile(filePath: string) {
actionInProgress = true;
try {
await invoke("git_unstage", { workingDir: workingDir, filePath });
await refresh();
} catch (e) {
error = e as string;
} finally {
actionInProgress = false;
}
}
async function stageAll() {
actionInProgress = true;
try {
await invoke("git_stage_all", { workingDir: workingDir });
await refresh();
} catch (e) {
error = e as string;
} finally {
actionInProgress = false;
}
}
async function discardFile(filePath: string) {
if (!confirm(`Discard changes to ${filePath}? This cannot be undone.`)) return;
actionInProgress = true;
try {
await invoke("git_discard", { workingDir: workingDir, filePath });
await refresh();
} catch (e) {
error = e as string;
} finally {
actionInProgress = false;
}
}
async function commit() {
if (!commitMessage.trim()) {
error = "Please enter a commit message";
return;
}
actionInProgress = true;
try {
await invoke("git_commit", { workingDir: workingDir, message: commitMessage });
commitMessage = "";
await refresh();
} catch (e) {
error = e as string;
} finally {
actionInProgress = false;
}
}
async function push() {
actionInProgress = true;
try {
await invoke("git_push", { workingDir: workingDir });
await refresh();
} catch (e) {
error = e as string;
} finally {
actionInProgress = false;
}
}
async function pull() {
actionInProgress = true;
try {
await invoke("git_pull", { workingDir: workingDir });
await refresh();
} catch (e) {
error = e as string;
} finally {
actionInProgress = false;
}
}
async function fetch() {
actionInProgress = true;
try {
await invoke("git_fetch", { workingDir: workingDir });
await refresh();
} catch (e) {
error = e as string;
} finally {
actionInProgress = false;
}
}
async function checkout(branch: string) {
actionInProgress = true;
try {
await invoke("git_checkout", { workingDir: workingDir, branch });
await refresh();
} catch (e) {
error = e as string;
} finally {
actionInProgress = false;
}
}
async function createBranch() {
if (!newBranchName.trim()) return;
actionInProgress = true;
try {
await invoke("git_create_branch", {
workingDir: workingDir,
branchName: newBranchName,
});
newBranchName = "";
showBranchInput = false;
await refresh();
} catch (e) {
error = e as string;
} finally {
actionInProgress = false;
}
}
function getStatusIcon(status: string): string {
switch (status) {
case "modified":
return "✏️";
case "added":
return "";
case "deleted":
return "🗑️";
case "renamed":
return "📝";
default:
return "❓";
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Escape") {
if (showDiff) {
showDiff = false;
} else {
onClose();
}
}
}
</script>
<svelte:window on:keydown={handleKeydown} />
{#if isOpen}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="git-panel-overlay" on:click={onClose} role="presentation">
<div
class="git-panel"
on:click|stopPropagation
role="dialog"
aria-label="Git Panel"
tabindex="-1"
>
<div class="git-panel-header">
<h2>🔀 Git</h2>
<button class="close-btn" on:click={onClose} title="Close (Esc)"></button>
</div>
{#if !workingDir}
<div class="git-panel-content">
<p class="empty-state">No working directory selected</p>
</div>
{:else if loading && !status}
<div class="git-panel-content">
<p class="loading">Loading git status...</p>
</div>
{:else if !status?.is_repo}
<div class="git-panel-content">
<p class="empty-state">Not a git repository</p>
</div>
{:else}
<div class="git-info-bar">
<div class="branch-info">
<span class="branch-icon">🌿</span>
<span class="branch-name">{status.branch || "detached"}</span>
{#if status.upstream}
<span class="upstream">{status.upstream}</span>
{/if}
</div>
<div class="sync-status">
{#if status.ahead > 0}
<span class="ahead" title="Commits ahead">{status.ahead}</span>
{/if}
{#if status.behind > 0}
<span class="behind" title="Commits behind">{status.behind}</span>
{/if}
</div>
<div class="quick-actions">
<button
on:click={fetch}
disabled={actionInProgress}
title="Fetch all remotes"
class="icon-btn"
>
🔄
</button>
<button on:click={pull} disabled={actionInProgress} title="Pull" class="icon-btn">
⬇️
</button>
<button
on:click={push}
disabled={actionInProgress || status.ahead === 0}
title="Push"
class="icon-btn"
>
⬆️
</button>
</div>
</div>
{#if error}
<div class="error-banner">
<span>{error}</span>
<button on:click={() => (error = null)}>✕</button>
</div>
{/if}
<div class="tab-bar">
<button
class="tab"
class:active={activeTab === "changes"}
on:click={() => (activeTab = "changes")}
>
Changes
{#if status.staged.length + status.unstaged.length + status.untracked.length > 0}
<span class="badge"
>{status.staged.length + status.unstaged.length + status.untracked.length}</span
>
{/if}
</button>
<button
class="tab"
class:active={activeTab === "branches"}
on:click={() => (activeTab = "branches")}
>
Branches
</button>
<button
class="tab"
class:active={activeTab === "history"}
on:click={() => (activeTab = "history")}
>
History
</button>
</div>
<div class="git-panel-content">
{#if activeTab === "changes"}
{#if status.staged.length > 0}
<div class="file-section">
<h3>Staged Changes ({status.staged.length})</h3>
<div class="file-list">
{#each status.staged as file (file.path)}
<div class="file-item staged">
<span class="status-icon">{getStatusIcon(file.status)}</span>
<span class="file-path" title={file.path}>{file.path}</span>
<div class="file-actions">
<button
on:click={() => viewDiff(file.path, true)}
title="View diff"
class="icon-btn small"
>
👁️
</button>
<button
on:click={() => unstageFile(file.path)}
disabled={actionInProgress}
title="Unstage"
class="icon-btn small"
>
</button>
</div>
</div>
{/each}
</div>
</div>
{/if}
{#if status.unstaged.length > 0}
<div class="file-section">
<h3>
Unstaged Changes ({status.unstaged.length})
<button
on:click={stageAll}
disabled={actionInProgress}
class="btn-trans-gradient stage-all-btn"
title="Stage all"
>
Stage All
</button>
</h3>
<div class="file-list">
{#each status.unstaged as file (file.path)}
<div class="file-item unstaged">
<span class="status-icon">{getStatusIcon(file.status)}</span>
<span class="file-path" title={file.path}>{file.path}</span>
<div class="file-actions">
<button
on:click={() => viewDiff(file.path, false)}
title="View diff"
class="icon-btn small"
>
👁️
</button>
<button
on:click={() => stageFile(file.path)}
disabled={actionInProgress}
title="Stage"
class="icon-btn small"
>
</button>
<button
on:click={() => discardFile(file.path)}
disabled={actionInProgress}
title="Discard changes"
class="icon-btn small danger"
>
🗑️
</button>
</div>
</div>
{/each}
</div>
</div>
{/if}
{#if status.untracked.length > 0}
<div class="file-section">
<h3>Untracked Files ({status.untracked.length})</h3>
<div class="file-list">
{#each status.untracked as file (file)}
<div class="file-item untracked">
<span class="status-icon"></span>
<span class="file-path" title={file}>{file}</span>
<div class="file-actions">
<button
on:click={() => stageFile(file)}
disabled={actionInProgress}
title="Stage"
class="icon-btn small"
>
</button>
</div>
</div>
{/each}
</div>
</div>
{/if}
{#if status.staged.length === 0 && status.unstaged.length === 0 && status.untracked.length === 0}
<p class="empty-state">✨ Working tree clean</p>
{/if}
{#if status.staged.length > 0}
<div class="commit-section">
<textarea
bind:value={commitMessage}
placeholder="Commit message..."
rows="3"
disabled={actionInProgress}
></textarea>
<button
on:click={commit}
disabled={actionInProgress || !commitMessage.trim()}
class="btn-trans-gradient commit-btn"
>
Commit
</button>
</div>
{/if}
{:else if activeTab === "branches"}
<div class="branch-section">
<div class="branch-actions">
{#if showBranchInput}
<div class="new-branch-input">
<input
type="text"
bind:value={newBranchName}
placeholder="New branch name..."
on:keydown={(e) => e.key === "Enter" && createBranch()}
/>
<button
on:click={createBranch}
disabled={actionInProgress}
class="btn-trans-gradient">Create</button
>
<button on:click={() => (showBranchInput = false)}>Cancel</button>
</div>
{:else}
<button
on:click={() => (showBranchInput = true)}
class="btn-trans-gradient new-branch-btn"
>
New Branch
</button>
{/if}
</div>
<h3>Local Branches</h3>
<div class="branch-list">
{#each branches.filter((b) => !b.is_remote) as branch (branch.name)}
<div class="branch-item" class:current={branch.is_current}>
<span class="branch-icon">{branch.is_current ? "✓" : "○"}</span>
<span class="branch-name">{branch.name}</span>
{#if !branch.is_current}
<button
on:click={() => checkout(branch.name)}
disabled={actionInProgress}
class="checkout-btn"
>
Checkout
</button>
{/if}
</div>
{/each}
</div>
{#if branches.filter((b) => b.is_remote).length > 0}
<h3>Remote Branches</h3>
<div class="branch-list">
{#each branches.filter((b) => b.is_remote) as branch (branch.name)}
<div class="branch-item remote">
<span class="branch-icon">☁️</span>
<span class="branch-name">{branch.name}</span>
</div>
{/each}
</div>
{/if}
</div>
{:else if activeTab === "history"}
<div class="history-list">
{#each log as entry (entry.hash)}
<div class="log-entry">
<div class="log-header">
<span class="commit-hash" title={entry.hash}>{entry.short_hash}</span>
<span class="commit-date">{entry.date}</span>
</div>
<div class="commit-message">{entry.message}</div>
<div class="commit-author">by {entry.author}</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
</div>
{#if showDiff}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="diff-overlay" on:click={() => (showDiff = false)} role="presentation">
<div
class="diff-modal"
on:click|stopPropagation
role="dialog"
aria-label="Diff View"
tabindex="-1"
>
<div class="diff-header">
<h3>📄 {diffFile}</h3>
<button on:click={() => (showDiff = false)} title="Close"></button>
</div>
<pre class="diff-content">{diffContent || "(No changes)"}</pre>
</div>
</div>
{/if}
{/if}
<style>
.git-panel-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;
}
.git-panel {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
width: 600px;
max-width: 90vw;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.git-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--border-color);
}
.git-panel-header h2 {
margin: 0;
font-size: 1.2rem;
}
.close-btn {
background: none;
border: none;
color: var(--text-primary);
cursor: pointer;
font-size: 1.2rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.close-btn:hover {
background: var(--bg-tertiary);
}
.git-info-bar {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-color);
}
.branch-info {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
min-width: 0;
overflow: hidden;
}
.branch-icon {
font-size: 1rem;
}
.branch-name {
font-weight: 600;
color: var(--accent-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.upstream {
color: var(--text-secondary);
font-size: 0.85rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sync-status {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.ahead,
.behind {
font-size: 0.85rem;
padding: 0.1rem 0.4rem;
border-radius: 4px;
}
.ahead {
background: var(--success-color);
color: white;
}
.behind {
background: var(--warning-color);
color: white;
}
.quick-actions {
display: flex;
gap: 0.25rem;
flex-shrink: 0;
}
.icon-btn {
background: none;
border: none;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 1rem;
}
.icon-btn:hover:not(:disabled) {
background: var(--bg-secondary);
}
.icon-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.icon-btn.small {
font-size: 0.85rem;
padding: 0.15rem 0.35rem;
}
.icon-btn.danger:hover:not(:disabled) {
background: rgba(255, 0, 0, 0.2);
}
.error-banner {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 1rem;
background: rgba(255, 0, 0, 0.1);
border-bottom: 1px solid var(--error-color);
color: var(--error-color);
}
.error-banner button {
background: none;
border: none;
color: var(--error-color);
cursor: pointer;
}
.tab-bar {
display: flex;
border-bottom: 1px solid var(--border-color);
}
.tab {
flex: 1;
padding: 0.75rem;
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.tab:hover {
background: var(--bg-tertiary);
}
.tab.active {
color: var(--accent-primary);
border-bottom-color: var(--accent-primary);
}
.badge {
background: var(--accent-primary);
color: white;
font-size: 0.75rem;
padding: 0.1rem 0.4rem;
border-radius: 10px;
margin-left: 0.25rem;
}
.git-panel-content {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
.empty-state,
.loading {
text-align: center;
color: var(--text-secondary);
padding: 2rem;
}
.file-section {
margin-bottom: 1.5rem;
}
.file-section h3 {
font-size: 0.9rem;
color: var(--text-secondary);
margin: 0 0 0.5rem 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.stage-all-btn {
font-size: 0.75rem;
padding: 0.2rem 0.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
}
.file-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.file-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.5rem;
background: var(--bg-tertiary);
border-radius: 4px;
font-size: 0.85rem;
}
.file-item.staged {
border-left: 3px solid var(--success-color);
}
.file-item.unstaged {
border-left: 3px solid var(--warning-color);
}
.file-item.untracked {
border-left: 3px solid var(--text-secondary);
}
.status-icon {
font-size: 0.8rem;
}
.file-path {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
}
.file-actions {
display: flex;
gap: 0.25rem;
opacity: 0;
transition: opacity 0.2s;
}
.file-item:hover .file-actions {
opacity: 1;
}
.commit-section {
margin-top: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.commit-section textarea {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 0.5rem;
color: var(--text-primary);
font-family: inherit;
resize: vertical;
}
.commit-btn {
border: none;
padding: 0.5rem 1rem;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
}
.branch-section h3 {
font-size: 0.9rem;
color: var(--text-secondary);
margin: 1rem 0 0.5rem 0;
}
.branch-actions {
margin-bottom: 1rem;
}
.new-branch-btn {
border: none;
padding: 0.5rem 1rem;
border-radius: 8px;
cursor: pointer;
}
.new-branch-input {
display: flex;
gap: 0.5rem;
}
.new-branch-input input {
flex: 1;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 0.5rem;
color: var(--text-primary);
}
.new-branch-input button {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
}
.new-branch-input button:last-of-type {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.branch-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.branch-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
background: var(--bg-tertiary);
border-radius: 4px;
}
.branch-item.current {
background: color-mix(in srgb, var(--accent-primary) 10%, transparent);
border-left: 3px solid var(--accent-primary);
}
.branch-item.remote {
opacity: 0.7;
}
.branch-item .branch-name {
flex: 1;
font-family: var(--font-mono);
font-size: 0.9rem;
color: var(--text-primary);
font-weight: normal;
}
.checkout-btn {
font-size: 0.75rem;
padding: 0.2rem 0.5rem;
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s;
}
.branch-item:hover .checkout-btn {
opacity: 1;
}
.history-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.log-entry {
padding: 0.75rem;
background: var(--bg-tertiary);
border-radius: 4px;
border-left: 3px solid var(--accent-primary);
}
.log-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
}
.commit-hash {
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--accent-primary);
cursor: pointer;
}
.commit-date {
font-size: 0.75rem;
color: var(--text-secondary);
}
.commit-message {
font-size: 0.9rem;
margin-bottom: 0.25rem;
}
.commit-author {
font-size: 0.75rem;
color: var(--text-secondary);
}
.diff-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1100;
}
.diff-modal {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
width: 800px;
max-width: 95vw;
max-height: 80vh;
display: flex;
flex-direction: column;
}
.diff-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
}
.diff-header h3 {
margin: 0;
font-size: 1rem;
font-family: var(--font-mono);
}
.diff-header button {
background: none;
border: none;
color: var(--text-primary);
cursor: pointer;
font-size: 1.2rem;
}
.diff-content {
flex: 1;
overflow: auto;
padding: 1rem;
margin: 0;
font-family: var(--font-mono);
font-size: 0.85rem;
line-height: 1.4;
white-space: pre;
background: var(--bg-primary);
}
</style>