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
+178
View File
@@ -1,7 +1,9 @@
use crate::achievements::{check_achievements, AchievementProgress};
use chrono::{Local, Timelike};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::time::Instant;
use tauri_plugin_store::StoreExt;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UsageStats {
@@ -28,6 +30,14 @@ pub struct UsageStats {
#[serde(skip)]
pub session_start: Option<Instant>,
// Extended tracking for achievements
pub sessions_started: u64,
pub consecutive_days: u64,
pub total_days_used: u64,
pub morning_sessions: u64, // Sessions started before 9 AM
pub night_sessions: u64, // Sessions started after 10 PM
pub last_session_date: Option<String>, // ISO date string for streak tracking
// Achievement tracking
#[serde(skip)]
pub achievements: AchievementProgress,
@@ -65,6 +75,47 @@ impl UsageStats {
self.session_duration_seconds = 0;
self.session_start = Some(Instant::now());
self.achievements.start_session();
// Track session start for achievements
self.track_session_start();
}
pub fn track_session_start(&mut self) {
let now = Local::now();
let today = now.format("%Y-%m-%d").to_string();
let hour = now.hour();
// Increment session count
self.sessions_started += 1;
// Track morning/night sessions
if hour < 9 {
self.morning_sessions += 1;
}
if hour >= 22 {
self.night_sessions += 1;
}
// Track consecutive days and total days
if let Some(last_date) = &self.last_session_date {
if last_date != &today {
// Check if it's the next day (consecutive)
if is_consecutive_day(last_date, &today) {
self.consecutive_days += 1;
} else {
// Streak broken
self.consecutive_days = 1;
}
self.total_days_used += 1;
self.last_session_date = Some(today);
}
// Same day - don't increment anything
} else {
// First session ever
self.consecutive_days = 1;
self.total_days_used = 1;
self.last_session_date = Some(today);
}
}
pub fn increment_messages(&mut self) {
@@ -127,12 +178,34 @@ impl UsageStats {
session_tools_usage: self.session_tools_usage.clone(),
session_duration_seconds: self.session_duration_seconds,
session_start: self.session_start,
sessions_started: self.sessions_started,
consecutive_days: self.consecutive_days,
total_days_used: self.total_days_used,
morning_sessions: self.morning_sessions,
night_sessions: self.night_sessions,
last_session_date: self.last_session_date.clone(),
achievements: AchievementProgress::new(), // Dummy for copy
};
check_achievements(&stats_copy, &mut self.achievements)
}
}
// Helper function to check if two dates are consecutive
fn is_consecutive_day(prev_date: &str, current_date: &str) -> bool {
use chrono::NaiveDate;
let prev = NaiveDate::parse_from_str(prev_date, "%Y-%m-%d").ok();
let current = NaiveDate::parse_from_str(current_date, "%Y-%m-%d").ok();
match (prev, current) {
(Some(p), Some(c)) => {
let diff = c.signed_duration_since(p).num_days();
diff == 1
}
_ => false,
}
}
// Pricing as of January 2025
// https://www.anthropic.com/pricing
fn calculate_cost(input_tokens: u64, output_tokens: u64, model: &str) -> f64 {
@@ -169,6 +242,111 @@ pub struct StatsUpdateEvent {
pub stats: UsageStats,
}
/// Serializable struct for persisting only lifetime (total) stats
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PersistedStats {
pub total_input_tokens: u64,
pub total_output_tokens: u64,
pub total_cost_usd: f64,
pub messages_exchanged: u64,
pub code_blocks_generated: u64,
pub files_edited: u64,
pub files_created: u64,
pub tools_usage: HashMap<String, u64>,
pub sessions_started: u64,
pub consecutive_days: u64,
pub total_days_used: u64,
pub morning_sessions: u64,
pub night_sessions: u64,
pub last_session_date: Option<String>,
}
impl From<&UsageStats> for PersistedStats {
fn from(stats: &UsageStats) -> Self {
PersistedStats {
total_input_tokens: stats.total_input_tokens,
total_output_tokens: stats.total_output_tokens,
total_cost_usd: stats.total_cost_usd,
messages_exchanged: stats.messages_exchanged,
code_blocks_generated: stats.code_blocks_generated,
files_edited: stats.files_edited,
files_created: stats.files_created,
tools_usage: stats.tools_usage.clone(),
sessions_started: stats.sessions_started,
consecutive_days: stats.consecutive_days,
total_days_used: stats.total_days_used,
morning_sessions: stats.morning_sessions,
night_sessions: stats.night_sessions,
last_session_date: stats.last_session_date.clone(),
}
}
}
impl UsageStats {
/// Apply persisted stats to restore lifetime totals
pub fn apply_persisted(&mut self, persisted: PersistedStats) {
self.total_input_tokens = persisted.total_input_tokens;
self.total_output_tokens = persisted.total_output_tokens;
self.total_cost_usd = persisted.total_cost_usd;
self.messages_exchanged = persisted.messages_exchanged;
self.code_blocks_generated = persisted.code_blocks_generated;
self.files_edited = persisted.files_edited;
self.files_created = persisted.files_created;
self.tools_usage = persisted.tools_usage;
self.sessions_started = persisted.sessions_started;
self.consecutive_days = persisted.consecutive_days;
self.total_days_used = persisted.total_days_used;
self.morning_sessions = persisted.morning_sessions;
self.night_sessions = persisted.night_sessions;
self.last_session_date = persisted.last_session_date;
}
}
/// Save lifetime stats to persistent store
pub async fn save_stats(app: &tauri::AppHandle, stats: &UsageStats) -> Result<(), String> {
let store = app.store("stats.json").map_err(|e| e.to_string())?;
let persisted = PersistedStats::from(stats);
println!("Saving stats: {:?}", persisted);
store.set(
"lifetime_stats",
serde_json::to_value(persisted).map_err(|e| e.to_string())?,
);
store.save().map_err(|e| e.to_string())?;
println!("Stats saved successfully");
Ok(())
}
/// Load lifetime stats from persistent store
pub async fn load_stats(app: &tauri::AppHandle) -> Option<PersistedStats> {
println!("Loading stats from store...");
let store = match app.store("stats.json") {
Ok(s) => s,
Err(e) => {
println!("Failed to open stats store: {}", e);
return None;
}
};
if let Some(stats_value) = store.get("lifetime_stats") {
println!("Found lifetime stats in store: {:?}", stats_value);
if let Ok(persisted) = serde_json::from_value::<PersistedStats>(stats_value.clone()) {
println!("Loaded lifetime stats successfully");
return Some(persisted);
} else {
println!("Failed to parse lifetime stats");
}
} else {
println!("No lifetime stats found in store");
}
None
}
#[cfg(test)]
mod tests {
use super::*;