feat: add multiple productivity features and UI enhancements (#68)
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

## 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:
2026-01-25 22:19:00 -08:00
committed by Naomi Carrigan
parent 852a4d6661
commit 4c46d4c8fd
47 changed files with 11695 additions and 319 deletions
+226
View File
@@ -0,0 +1,226 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tauri::AppHandle;
use tauri_plugin_store::StoreExt;
const SNIPPETS_STORE_KEY: &str = "snippets";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Snippet {
pub id: String,
pub name: String,
pub content: String,
pub category: String,
pub is_default: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
fn get_default_snippets() -> Vec<Snippet> {
let now = Utc::now();
vec![
Snippet {
id: "default-explain-code".to_string(),
name: "Explain this code".to_string(),
content: "Please explain what this code does, step by step:".to_string(),
category: "Code Review".to_string(),
is_default: true,
created_at: now,
updated_at: now,
},
Snippet {
id: "default-fix-error".to_string(),
name: "Fix this error".to_string(),
content: "I'm getting the following error. Can you help me fix it?".to_string(),
category: "Debugging".to_string(),
is_default: true,
created_at: now,
updated_at: now,
},
Snippet {
id: "default-write-tests".to_string(),
name: "Write tests".to_string(),
content: "Please write unit tests for this code with good coverage:".to_string(),
category: "Testing".to_string(),
is_default: true,
created_at: now,
updated_at: now,
},
Snippet {
id: "default-refactor".to_string(),
name: "Refactor for clarity".to_string(),
content: "Please refactor this code to improve readability and maintainability:".to_string(),
category: "Code Review".to_string(),
is_default: true,
created_at: now,
updated_at: now,
},
Snippet {
id: "default-optimize".to_string(),
name: "Optimize performance".to_string(),
content: "Please analyze this code for performance issues and suggest optimizations:".to_string(),
category: "Performance".to_string(),
is_default: true,
created_at: now,
updated_at: now,
},
Snippet {
id: "default-review-pr".to_string(),
name: "Review PR".to_string(),
content: "Please review this pull request and provide feedback on code quality, potential issues, and suggestions for improvement.".to_string(),
category: "Code Review".to_string(),
is_default: true,
created_at: now,
updated_at: now,
},
Snippet {
id: "default-add-comments".to_string(),
name: "Add documentation".to_string(),
content: "Please add clear documentation comments to this code explaining what it does:".to_string(),
category: "Documentation".to_string(),
is_default: true,
created_at: now,
updated_at: now,
},
Snippet {
id: "default-security-review".to_string(),
name: "Security review".to_string(),
content: "Please review this code for security vulnerabilities and suggest fixes:".to_string(),
category: "Security".to_string(),
is_default: true,
created_at: now,
updated_at: now,
},
]
}
fn load_all_snippets(app: &AppHandle) -> Result<Vec<Snippet>, String> {
let store = app
.store("hikari-snippets.json")
.map_err(|e| e.to_string())?;
match store.get(SNIPPETS_STORE_KEY) {
Some(value) => {
let mut snippets: Vec<Snippet> =
serde_json::from_value(value.clone()).map_err(|e| e.to_string())?;
// Ensure default snippets exist (in case new ones were added in an update)
let defaults = get_default_snippets();
for default in defaults {
if !snippets.iter().any(|s| s.id == default.id) {
snippets.push(default);
}
}
Ok(snippets)
}
None => Ok(get_default_snippets()),
}
}
fn save_all_snippets(app: &AppHandle, snippets: &[Snippet]) -> Result<(), String> {
let store = app
.store("hikari-snippets.json")
.map_err(|e| e.to_string())?;
let value = serde_json::to_value(snippets).map_err(|e| e.to_string())?;
store.set(SNIPPETS_STORE_KEY, value);
store.save().map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn list_snippets(app: AppHandle) -> Result<Vec<Snippet>, String> {
let mut snippets = load_all_snippets(&app)?;
// Sort by category, then by name
snippets.sort_by(|a, b| {
let cat_cmp = a.category.cmp(&b.category);
if cat_cmp == std::cmp::Ordering::Equal {
a.name.cmp(&b.name)
} else {
cat_cmp
}
});
Ok(snippets)
}
#[tauri::command]
pub async fn save_snippet(app: AppHandle, snippet: Snippet) -> Result<(), String> {
let mut snippets = load_all_snippets(&app)?;
// Update existing or add new
if let Some(existing) = snippets.iter_mut().find(|s| s.id == snippet.id) {
// Don't allow editing default snippets' is_default flag
let mut updated = snippet;
updated.is_default = existing.is_default;
*existing = updated;
} else {
snippets.push(snippet);
}
save_all_snippets(&app, &snippets)
}
#[tauri::command]
pub async fn delete_snippet(app: AppHandle, snippet_id: String) -> Result<(), String> {
let mut snippets = load_all_snippets(&app)?;
// Don't allow deleting default snippets
if snippets
.iter()
.any(|s| s.id == snippet_id && s.is_default)
{
return Err("Cannot delete default snippets".to_string());
}
snippets.retain(|s| s.id != snippet_id);
save_all_snippets(&app, &snippets)
}
#[tauri::command]
pub async fn get_snippet_categories(app: AppHandle) -> Result<Vec<String>, String> {
let snippets = load_all_snippets(&app)?;
let mut categories: Vec<String> = snippets.iter().map(|s| s.category.clone()).collect();
categories.sort();
categories.dedup();
Ok(categories)
}
#[tauri::command]
pub async fn reset_default_snippets(app: AppHandle) -> Result<(), String> {
let mut snippets = load_all_snippets(&app)?;
// Remove all default snippets
snippets.retain(|s| !s.is_default);
// Add fresh default snippets
snippets.extend(get_default_snippets());
save_all_snippets(&app, &snippets)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_snippets_exist() {
let defaults = get_default_snippets();
assert!(!defaults.is_empty());
assert!(defaults.iter().all(|s| s.is_default));
}
#[test]
fn test_default_snippets_have_required_fields() {
let defaults = get_default_snippets();
for snippet in defaults {
assert!(!snippet.id.is_empty());
assert!(!snippet.name.is_empty());
assert!(!snippet.content.is_empty());
assert!(!snippet.category.is_empty());
}
}
}