generated from nhcarrigan/template
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>
This commit was merged in pull request #68.
This commit is contained in:
Generated
+2
@@ -1613,6 +1613,7 @@ dependencies = [
|
||||
"tauri-build",
|
||||
"tauri-plugin-clipboard-manager",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-fs",
|
||||
"tauri-plugin-http",
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-opener",
|
||||
@@ -4180,6 +4181,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"heck 0.5.0",
|
||||
"http",
|
||||
"image",
|
||||
"jni",
|
||||
"libc",
|
||||
"log",
|
||||
|
||||
@@ -13,7 +13,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
@@ -27,6 +27,7 @@ tauri-plugin-notification = "2"
|
||||
tauri-plugin-os = "2"
|
||||
tauri-plugin-http = "2"
|
||||
tauri-plugin-clipboard-manager = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
tempfile = "3"
|
||||
semver = "1"
|
||||
chrono = { version = "0.4.43", features = ["serde"] }
|
||||
|
||||
@@ -15,6 +15,21 @@
|
||||
"notification:allow-request-permission",
|
||||
"notification:allow-notify",
|
||||
"clipboard-manager:default",
|
||||
"clipboard-manager:allow-read-image"
|
||||
"clipboard-manager:allow-read-image",
|
||||
"core:tray:default",
|
||||
"fs:default",
|
||||
"fs:allow-read-text-file",
|
||||
"fs:allow-write-text-file",
|
||||
{
|
||||
"identifier": "fs:allow-read-file",
|
||||
"allow": [{ "path": "**" }]
|
||||
},
|
||||
{
|
||||
"identifier": "fs:allow-write-file",
|
||||
"allow": [{ "path": "**" }]
|
||||
},
|
||||
"core:window:allow-set-size",
|
||||
"core:window:allow-set-always-on-top",
|
||||
"core:window:allow-inner-size"
|
||||
]
|
||||
}
|
||||
|
||||
+1485
-2
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,259 @@
|
||||
// Clipboard history module for tracking and managing copied code snippets
|
||||
// Implements issue #25 - Clipboard History feature
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Mutex;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
const STORE_FILE: &str = "hikari-clipboard.json";
|
||||
const HISTORY_KEY: &str = "clipboard_history";
|
||||
const MAX_HISTORY_SIZE: usize = 100;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClipboardEntry {
|
||||
pub id: String,
|
||||
pub content: String,
|
||||
pub language: Option<String>,
|
||||
pub source: Option<String>,
|
||||
pub timestamp: String,
|
||||
pub is_pinned: bool,
|
||||
}
|
||||
|
||||
impl ClipboardEntry {
|
||||
pub fn new(content: String, language: Option<String>, source: Option<String>) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
content,
|
||||
language,
|
||||
source,
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
is_pinned: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
struct ClipboardHistory {
|
||||
entries: Vec<ClipboardEntry>,
|
||||
}
|
||||
|
||||
// Track last clipboard content to avoid duplicates
|
||||
#[derive(Default)]
|
||||
struct ClipboardState {
|
||||
last_content: Option<String>,
|
||||
}
|
||||
|
||||
static CLIPBOARD_STATE: Mutex<ClipboardState> = Mutex::new(ClipboardState { last_content: None });
|
||||
|
||||
fn load_history(app: &tauri::AppHandle) -> ClipboardHistory {
|
||||
let store = app.store(STORE_FILE).ok();
|
||||
store
|
||||
.and_then(|s| s.get(HISTORY_KEY))
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn save_history(app: &tauri::AppHandle, history: &ClipboardHistory) -> Result<(), String> {
|
||||
let store = app.store(STORE_FILE).map_err(|e| e.to_string())?;
|
||||
store.set(
|
||||
HISTORY_KEY,
|
||||
serde_json::to_value(history).map_err(|e| e.to_string())?,
|
||||
);
|
||||
store.save().map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all clipboard entries, optionally filtered by language
|
||||
#[tauri::command]
|
||||
pub fn list_clipboard_entries(
|
||||
app: tauri::AppHandle,
|
||||
language: Option<String>,
|
||||
) -> Result<Vec<ClipboardEntry>, String> {
|
||||
let history = load_history(&app);
|
||||
let entries = if let Some(lang) = language {
|
||||
history
|
||||
.entries
|
||||
.into_iter()
|
||||
.filter(|e| e.language.as_ref() == Some(&lang))
|
||||
.collect()
|
||||
} else {
|
||||
history.entries
|
||||
};
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Capture current clipboard content and add to history
|
||||
#[tauri::command]
|
||||
pub fn capture_clipboard(
|
||||
app: tauri::AppHandle,
|
||||
content: String,
|
||||
language: Option<String>,
|
||||
source: Option<String>,
|
||||
) -> Result<ClipboardEntry, String> {
|
||||
// Check for duplicate (same content as last capture)
|
||||
{
|
||||
let mut state = CLIPBOARD_STATE.lock().map_err(|e| e.to_string())?;
|
||||
if state.last_content.as_ref() == Some(&content) {
|
||||
// Return existing entry if content is the same
|
||||
let history = load_history(&app);
|
||||
if let Some(entry) = history.entries.first() {
|
||||
if entry.content == content {
|
||||
return Ok(entry.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
state.last_content = Some(content.clone());
|
||||
}
|
||||
|
||||
let entry = ClipboardEntry::new(content, language, source);
|
||||
let mut history = load_history(&app);
|
||||
|
||||
// Add to front of history
|
||||
history.entries.insert(0, entry.clone());
|
||||
|
||||
// Enforce max size (keep pinned entries)
|
||||
let mut pinned: Vec<ClipboardEntry> = history
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| e.is_pinned)
|
||||
.cloned()
|
||||
.collect();
|
||||
let mut unpinned: Vec<ClipboardEntry> = history
|
||||
.entries
|
||||
.into_iter()
|
||||
.filter(|e| !e.is_pinned)
|
||||
.collect();
|
||||
|
||||
// Trim unpinned entries if over max size
|
||||
if unpinned.len() + pinned.len() > MAX_HISTORY_SIZE {
|
||||
let max_unpinned = MAX_HISTORY_SIZE.saturating_sub(pinned.len());
|
||||
unpinned.truncate(max_unpinned);
|
||||
}
|
||||
|
||||
// Merge back, pinned first then unpinned
|
||||
pinned.extend(unpinned);
|
||||
history.entries = pinned;
|
||||
|
||||
// Sort by timestamp descending (newest first), pinned entries stay at top
|
||||
history.entries.sort_by(|a, b| {
|
||||
if a.is_pinned && !b.is_pinned {
|
||||
std::cmp::Ordering::Less
|
||||
} else if !a.is_pinned && b.is_pinned {
|
||||
std::cmp::Ordering::Greater
|
||||
} else {
|
||||
b.timestamp.cmp(&a.timestamp)
|
||||
}
|
||||
});
|
||||
|
||||
save_history(&app, &history)?;
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
/// Delete a clipboard entry by ID
|
||||
#[tauri::command]
|
||||
pub fn delete_clipboard_entry(app: tauri::AppHandle, id: String) -> Result<(), String> {
|
||||
let mut history = load_history(&app);
|
||||
history.entries.retain(|e| e.id != id);
|
||||
save_history(&app, &history)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Toggle pin status of an entry
|
||||
#[tauri::command]
|
||||
pub fn toggle_pin_clipboard_entry(
|
||||
app: tauri::AppHandle,
|
||||
id: String,
|
||||
) -> Result<ClipboardEntry, String> {
|
||||
let mut history = load_history(&app);
|
||||
let entry = history
|
||||
.entries
|
||||
.iter_mut()
|
||||
.find(|e| e.id == id)
|
||||
.ok_or("Entry not found")?;
|
||||
|
||||
entry.is_pinned = !entry.is_pinned;
|
||||
let updated_entry = entry.clone();
|
||||
|
||||
// Re-sort to move pinned entries to top
|
||||
history.entries.sort_by(|a, b| {
|
||||
if a.is_pinned && !b.is_pinned {
|
||||
std::cmp::Ordering::Less
|
||||
} else if !a.is_pinned && b.is_pinned {
|
||||
std::cmp::Ordering::Greater
|
||||
} else {
|
||||
b.timestamp.cmp(&a.timestamp)
|
||||
}
|
||||
});
|
||||
|
||||
save_history(&app, &history)?;
|
||||
Ok(updated_entry)
|
||||
}
|
||||
|
||||
/// Clear all non-pinned entries
|
||||
#[tauri::command]
|
||||
pub fn clear_clipboard_history(app: tauri::AppHandle) -> Result<(), String> {
|
||||
let mut history = load_history(&app);
|
||||
history.entries.retain(|e| e.is_pinned);
|
||||
save_history(&app, &history)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Search clipboard entries by content
|
||||
#[tauri::command]
|
||||
pub fn search_clipboard_entries(
|
||||
app: tauri::AppHandle,
|
||||
query: String,
|
||||
) -> Result<Vec<ClipboardEntry>, String> {
|
||||
let history = load_history(&app);
|
||||
let query_lower = query.to_lowercase();
|
||||
let entries = history
|
||||
.entries
|
||||
.into_iter()
|
||||
.filter(|e| {
|
||||
e.content.to_lowercase().contains(&query_lower)
|
||||
|| e.language
|
||||
.as_ref()
|
||||
.is_some_and(|l| l.to_lowercase().contains(&query_lower))
|
||||
|| e.source
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.to_lowercase().contains(&query_lower))
|
||||
})
|
||||
.collect();
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Get all unique languages from history
|
||||
#[tauri::command]
|
||||
pub fn get_clipboard_languages(app: tauri::AppHandle) -> Result<Vec<String>, String> {
|
||||
let history = load_history(&app);
|
||||
let mut languages: Vec<String> = history
|
||||
.entries
|
||||
.iter()
|
||||
.filter_map(|e| e.language.clone())
|
||||
.collect();
|
||||
languages.sort();
|
||||
languages.dedup();
|
||||
Ok(languages)
|
||||
}
|
||||
|
||||
/// Update the language of an entry
|
||||
#[tauri::command]
|
||||
pub fn update_clipboard_language(
|
||||
app: tauri::AppHandle,
|
||||
id: String,
|
||||
language: Option<String>,
|
||||
) -> Result<ClipboardEntry, String> {
|
||||
let mut history = load_history(&app);
|
||||
let entry = history
|
||||
.entries
|
||||
.iter_mut()
|
||||
.find(|e| e.id == id)
|
||||
.ok_or("Entry not found")?;
|
||||
|
||||
entry.language = language;
|
||||
let updated_entry = entry.clone();
|
||||
|
||||
save_history(&app, &history)?;
|
||||
Ok(updated_entry)
|
||||
}
|
||||
@@ -102,6 +102,19 @@ pub async fn get_usage_stats(
|
||||
manager.get_usage_stats(&conversation_id)
|
||||
}
|
||||
|
||||
/// Load persisted lifetime stats from store (no bridge required)
|
||||
#[tauri::command]
|
||||
pub async fn get_persisted_stats(app: AppHandle) -> Result<UsageStats, String> {
|
||||
let mut stats = UsageStats::new();
|
||||
|
||||
// Load persisted stats if available
|
||||
if let Some(persisted) = crate::stats::load_stats(&app).await {
|
||||
stats.apply_persisted(persisted);
|
||||
}
|
||||
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn validate_directory(
|
||||
path: String,
|
||||
|
||||
@@ -70,6 +70,32 @@ pub struct HikariConfig {
|
||||
|
||||
#[serde(default = "default_font_size")]
|
||||
pub font_size: u32,
|
||||
|
||||
#[serde(default)]
|
||||
pub minimize_to_tray: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub streamer_mode: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub streamer_hide_paths: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub compact_mode: bool,
|
||||
|
||||
// Profile fields
|
||||
#[serde(default)]
|
||||
pub profile_name: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub profile_avatar_path: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub profile_bio: Option<String>,
|
||||
|
||||
// Custom theme colors
|
||||
#[serde(default)]
|
||||
pub custom_theme_colors: CustomThemeColors,
|
||||
}
|
||||
|
||||
impl Default for HikariConfig {
|
||||
@@ -89,6 +115,14 @@ impl Default for HikariConfig {
|
||||
update_checks_enabled: true,
|
||||
character_panel_width: None,
|
||||
font_size: 14,
|
||||
minimize_to_tray: false,
|
||||
streamer_mode: false,
|
||||
streamer_hide_paths: false,
|
||||
compact_mode: false,
|
||||
profile_name: None,
|
||||
profile_avatar_path: None,
|
||||
profile_bio: None,
|
||||
custom_theme_colors: CustomThemeColors::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,6 +153,29 @@ pub enum Theme {
|
||||
#[default]
|
||||
Dark,
|
||||
Light,
|
||||
#[serde(rename = "high-contrast")]
|
||||
HighContrast,
|
||||
Custom,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
pub struct CustomThemeColors {
|
||||
#[serde(default)]
|
||||
pub bg_primary: Option<String>,
|
||||
#[serde(default)]
|
||||
pub bg_secondary: Option<String>,
|
||||
#[serde(default)]
|
||||
pub bg_terminal: Option<String>,
|
||||
#[serde(default)]
|
||||
pub accent_primary: Option<String>,
|
||||
#[serde(default)]
|
||||
pub accent_secondary: Option<String>,
|
||||
#[serde(default)]
|
||||
pub text_primary: Option<String>,
|
||||
#[serde(default)]
|
||||
pub text_secondary: Option<String>,
|
||||
#[serde(default)]
|
||||
pub border_color: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -140,6 +197,14 @@ mod tests {
|
||||
assert!(config.update_checks_enabled);
|
||||
assert!(config.character_panel_width.is_none());
|
||||
assert_eq!(config.font_size, 14);
|
||||
assert!(!config.minimize_to_tray);
|
||||
assert!(!config.streamer_mode);
|
||||
assert!(!config.streamer_hide_paths);
|
||||
assert!(!config.compact_mode);
|
||||
assert!(config.profile_name.is_none());
|
||||
assert!(config.profile_avatar_path.is_none());
|
||||
assert!(config.profile_bio.is_none());
|
||||
assert_eq!(config.custom_theme_colors, CustomThemeColors::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -159,6 +224,14 @@ mod tests {
|
||||
update_checks_enabled: true,
|
||||
character_panel_width: Some(400),
|
||||
font_size: 16,
|
||||
minimize_to_tray: true,
|
||||
streamer_mode: false,
|
||||
streamer_hide_paths: false,
|
||||
compact_mode: false,
|
||||
profile_name: Some("Test User".to_string()),
|
||||
profile_avatar_path: None,
|
||||
profile_bio: Some("A test bio".to_string()),
|
||||
custom_theme_colors: CustomThemeColors::default(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
@@ -179,8 +252,16 @@ mod tests {
|
||||
fn test_theme_serialization() {
|
||||
let dark = Theme::Dark;
|
||||
let light = Theme::Light;
|
||||
let high_contrast = Theme::HighContrast;
|
||||
|
||||
assert_eq!(serde_json::to_string(&dark).unwrap(), "\"dark\"");
|
||||
assert_eq!(serde_json::to_string(&light).unwrap(), "\"light\"");
|
||||
assert_eq!(
|
||||
serde_json::to_string(&high_contrast).unwrap(),
|
||||
"\"high-contrast\""
|
||||
);
|
||||
|
||||
let custom = Theme::Custom;
|
||||
assert_eq!(serde_json::to_string(&custom).unwrap(), "\"custom\"");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GitStatus {
|
||||
pub is_repo: bool,
|
||||
pub branch: Option<String>,
|
||||
pub upstream: Option<String>,
|
||||
pub ahead: u32,
|
||||
pub behind: u32,
|
||||
pub staged: Vec<GitFileChange>,
|
||||
pub unstaged: Vec<GitFileChange>,
|
||||
pub untracked: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GitFileChange {
|
||||
pub path: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GitBranch {
|
||||
pub name: String,
|
||||
pub is_current: bool,
|
||||
pub is_remote: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GitLogEntry {
|
||||
pub hash: String,
|
||||
pub short_hash: String,
|
||||
pub author: String,
|
||||
pub date: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
fn run_git_command(working_dir: &str, args: &[&str]) -> Result<String, String> {
|
||||
let output = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(working_dir)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute git: {}", e))?;
|
||||
|
||||
if output.status.success() {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
} else {
|
||||
Err(String::from_utf8_lossy(&output.stderr).to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_status(working_dir: String) -> Result<GitStatus, String> {
|
||||
// Check if it's a git repo
|
||||
let is_repo = run_git_command(&working_dir, &["rev-parse", "--git-dir"]).is_ok();
|
||||
|
||||
if !is_repo {
|
||||
return Ok(GitStatus {
|
||||
is_repo: false,
|
||||
branch: None,
|
||||
upstream: None,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
staged: vec![],
|
||||
unstaged: vec![],
|
||||
untracked: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
// Get current branch
|
||||
let branch = run_git_command(&working_dir, &["rev-parse", "--abbrev-ref", "HEAD"])
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string());
|
||||
|
||||
// Get upstream branch
|
||||
let upstream = run_git_command(
|
||||
&working_dir,
|
||||
&["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
|
||||
)
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string());
|
||||
|
||||
// Get ahead/behind counts
|
||||
let (ahead, behind) = if upstream.is_some() {
|
||||
let rev_list =
|
||||
run_git_command(&working_dir, &["rev-list", "--left-right", "--count", "@{u}...HEAD"])
|
||||
.unwrap_or_default();
|
||||
let parts: Vec<&str> = rev_list.trim().split('\t').collect();
|
||||
if parts.len() == 2 {
|
||||
(
|
||||
parts[1].parse().unwrap_or(0),
|
||||
parts[0].parse().unwrap_or(0),
|
||||
)
|
||||
} else {
|
||||
(0, 0)
|
||||
}
|
||||
} else {
|
||||
(0, 0)
|
||||
};
|
||||
|
||||
// Get status with porcelain format
|
||||
let status_output =
|
||||
run_git_command(&working_dir, &["status", "--porcelain=v1"]).unwrap_or_default();
|
||||
|
||||
let mut staged = vec![];
|
||||
let mut unstaged = vec![];
|
||||
let mut untracked = vec![];
|
||||
|
||||
for line in status_output.lines() {
|
||||
if line.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let index_status = line.chars().next().unwrap_or(' ');
|
||||
let worktree_status = line.chars().nth(1).unwrap_or(' ');
|
||||
let path = line[3..].to_string();
|
||||
|
||||
// Untracked files
|
||||
if index_status == '?' && worktree_status == '?' {
|
||||
untracked.push(path);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Staged changes (index status)
|
||||
if index_status != ' ' && index_status != '?' {
|
||||
staged.push(GitFileChange {
|
||||
path: path.clone(),
|
||||
status: match index_status {
|
||||
'M' => "modified".to_string(),
|
||||
'A' => "added".to_string(),
|
||||
'D' => "deleted".to_string(),
|
||||
'R' => "renamed".to_string(),
|
||||
'C' => "copied".to_string(),
|
||||
_ => "unknown".to_string(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Unstaged changes (worktree status)
|
||||
if worktree_status != ' ' && worktree_status != '?' {
|
||||
unstaged.push(GitFileChange {
|
||||
path,
|
||||
status: match worktree_status {
|
||||
'M' => "modified".to_string(),
|
||||
'D' => "deleted".to_string(),
|
||||
_ => "unknown".to_string(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(GitStatus {
|
||||
is_repo: true,
|
||||
branch,
|
||||
upstream,
|
||||
ahead,
|
||||
behind,
|
||||
staged,
|
||||
unstaged,
|
||||
untracked,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_diff(working_dir: String, file_path: Option<String>, staged: bool) -> Result<String, String> {
|
||||
let mut args = vec!["diff"];
|
||||
|
||||
if staged {
|
||||
args.push("--cached");
|
||||
}
|
||||
|
||||
if let Some(ref path) = file_path {
|
||||
args.push("--");
|
||||
args.push(path);
|
||||
}
|
||||
|
||||
run_git_command(&working_dir, &args)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_branches(working_dir: String) -> Result<Vec<GitBranch>, String> {
|
||||
let output = run_git_command(&working_dir, &["branch", "-a", "--format=%(refname:short)\t%(HEAD)"])?;
|
||||
|
||||
let branches: Vec<GitBranch> = output
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let parts: Vec<&str> = line.split('\t').collect();
|
||||
if parts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let name = parts[0].to_string();
|
||||
let is_current = parts.get(1).map(|s| *s == "*").unwrap_or(false);
|
||||
let is_remote = name.starts_with("remotes/") || name.starts_with("origin/");
|
||||
|
||||
Some(GitBranch {
|
||||
name,
|
||||
is_current,
|
||||
is_remote,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(branches)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_checkout(working_dir: String, branch: String) -> Result<String, String> {
|
||||
run_git_command(&working_dir, &["checkout", &branch])
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_stage(working_dir: String, file_path: String) -> Result<String, String> {
|
||||
run_git_command(&working_dir, &["add", &file_path])
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_unstage(working_dir: String, file_path: String) -> Result<String, String> {
|
||||
run_git_command(&working_dir, &["restore", "--staged", &file_path])
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_stage_all(working_dir: String) -> Result<String, String> {
|
||||
run_git_command(&working_dir, &["add", "-A"])
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_commit(working_dir: String, message: String) -> Result<String, String> {
|
||||
run_git_command(&working_dir, &["commit", "-m", &message])
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_push(working_dir: String) -> Result<String, String> {
|
||||
run_git_command(&working_dir, &["push"])
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_pull(working_dir: String) -> Result<String, String> {
|
||||
run_git_command(&working_dir, &["pull"])
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_fetch(working_dir: String) -> Result<String, String> {
|
||||
run_git_command(&working_dir, &["fetch", "--all"])
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_log(working_dir: String, limit: Option<u32>) -> Result<Vec<GitLogEntry>, String> {
|
||||
let limit_str = limit.unwrap_or(10).to_string();
|
||||
let output = run_git_command(
|
||||
&working_dir,
|
||||
&[
|
||||
"log",
|
||||
&format!("-{}", limit_str),
|
||||
"--pretty=format:%H\t%h\t%an\t%ar\t%s",
|
||||
],
|
||||
)?;
|
||||
|
||||
let entries: Vec<GitLogEntry> = output
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let parts: Vec<&str> = line.split('\t').collect();
|
||||
if parts.len() < 5 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(GitLogEntry {
|
||||
hash: parts[0].to_string(),
|
||||
short_hash: parts[1].to_string(),
|
||||
author: parts[2].to_string(),
|
||||
date: parts[3].to_string(),
|
||||
message: parts[4..].join("\t"),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_discard(working_dir: String, file_path: String) -> Result<String, String> {
|
||||
run_git_command(&working_dir, &["checkout", "--", &file_path])
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_create_branch(working_dir: String, branch_name: String) -> Result<String, String> {
|
||||
run_git_command(&working_dir, &["checkout", "-b", &branch_name])
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
mod achievements;
|
||||
mod bridge_manager;
|
||||
mod clipboard;
|
||||
mod commands;
|
||||
mod config;
|
||||
mod git;
|
||||
mod notifications;
|
||||
mod quick_actions;
|
||||
mod sessions;
|
||||
mod snippets;
|
||||
mod stats;
|
||||
mod temp_manager;
|
||||
mod tray;
|
||||
mod types;
|
||||
mod vbs_notification;
|
||||
mod windows_toast;
|
||||
@@ -12,10 +18,17 @@ mod wsl_bridge;
|
||||
mod wsl_notifications;
|
||||
|
||||
use bridge_manager::create_shared_bridge_manager;
|
||||
use clipboard::*;
|
||||
use commands::load_saved_achievements;
|
||||
use commands::*;
|
||||
use git::*;
|
||||
use notifications::*;
|
||||
use quick_actions::*;
|
||||
use sessions::*;
|
||||
use snippets::*;
|
||||
use tauri::Manager;
|
||||
use temp_manager::create_shared_temp_manager;
|
||||
use tray::{setup_tray, should_minimize_to_tray};
|
||||
use vbs_notification::*;
|
||||
use windows_toast::*;
|
||||
use wsl_notifications::*;
|
||||
@@ -34,6 +47,7 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_os::init())
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.manage(bridge_manager.clone())
|
||||
.manage(temp_manager.clone())
|
||||
.setup(move |app| {
|
||||
@@ -47,6 +61,27 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
// Set up system tray
|
||||
if let Err(e) = setup_tray(app.handle()) {
|
||||
eprintln!("Failed to set up system tray: {}", e);
|
||||
}
|
||||
|
||||
// Handle window close event for minimize to tray
|
||||
let main_window = app.get_webview_window("main").unwrap();
|
||||
main_window.on_window_event({
|
||||
let app_handle = app.handle().clone();
|
||||
move |event| {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
if should_minimize_to_tray(&app_handle) {
|
||||
api.prevent_close();
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
@@ -60,6 +95,7 @@ pub fn run() {
|
||||
get_config,
|
||||
save_config,
|
||||
get_usage_stats,
|
||||
get_persisted_stats,
|
||||
load_saved_achievements,
|
||||
answer_question,
|
||||
send_windows_notification,
|
||||
@@ -78,6 +114,43 @@ pub fn run() {
|
||||
cleanup_all_temp_files,
|
||||
cleanup_orphaned_temp_files,
|
||||
get_file_size,
|
||||
list_sessions,
|
||||
save_session,
|
||||
load_session,
|
||||
delete_session,
|
||||
search_sessions,
|
||||
clear_all_sessions,
|
||||
list_snippets,
|
||||
save_snippet,
|
||||
delete_snippet,
|
||||
get_snippet_categories,
|
||||
reset_default_snippets,
|
||||
list_quick_actions,
|
||||
save_quick_action,
|
||||
delete_quick_action,
|
||||
reset_default_quick_actions,
|
||||
git_status,
|
||||
git_diff,
|
||||
git_branches,
|
||||
git_checkout,
|
||||
git_stage,
|
||||
git_unstage,
|
||||
git_stage_all,
|
||||
git_commit,
|
||||
git_push,
|
||||
git_pull,
|
||||
git_fetch,
|
||||
git_log,
|
||||
git_discard,
|
||||
git_create_branch,
|
||||
list_clipboard_entries,
|
||||
capture_clipboard,
|
||||
delete_clipboard_entry,
|
||||
toggle_pin_clipboard_entry,
|
||||
clear_clipboard_history,
|
||||
search_clipboard_entries,
|
||||
get_clipboard_languages,
|
||||
update_clipboard_language,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
|
||||
const QUICK_ACTIONS_STORE_KEY: &str = "quick_actions";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct QuickAction {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub prompt: String,
|
||||
pub icon: String,
|
||||
pub is_default: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
fn get_default_quick_actions() -> Vec<QuickAction> {
|
||||
let now = Utc::now();
|
||||
vec![
|
||||
QuickAction {
|
||||
id: "default-review-pr".to_string(),
|
||||
name: "Review PR".to_string(),
|
||||
prompt: "Please review this pull request and provide feedback on code quality, potential issues, and suggestions for improvement.".to_string(),
|
||||
icon: "git-pull-request".to_string(),
|
||||
is_default: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
QuickAction {
|
||||
id: "default-run-tests".to_string(),
|
||||
name: "Run Tests".to_string(),
|
||||
prompt: "Please run the test suite for this project and report any failures or issues.".to_string(),
|
||||
icon: "play".to_string(),
|
||||
is_default: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
QuickAction {
|
||||
id: "default-explain-file".to_string(),
|
||||
name: "Explain File".to_string(),
|
||||
prompt: "Please explain what this file does, its purpose, and how it fits into the overall project structure.".to_string(),
|
||||
icon: "file-text".to_string(),
|
||||
is_default: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
QuickAction {
|
||||
id: "default-fix-error".to_string(),
|
||||
name: "Fix Error".to_string(),
|
||||
prompt: "I'm getting an error. Can you help me identify the cause and fix it?".to_string(),
|
||||
icon: "alert-circle".to_string(),
|
||||
is_default: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
QuickAction {
|
||||
id: "default-write-tests".to_string(),
|
||||
name: "Write Tests".to_string(),
|
||||
prompt: "Please write comprehensive unit tests for the current code with good coverage.".to_string(),
|
||||
icon: "check-square".to_string(),
|
||||
is_default: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
QuickAction {
|
||||
id: "default-refactor".to_string(),
|
||||
name: "Refactor".to_string(),
|
||||
prompt: "Please refactor this code to improve readability, maintainability, and performance.".to_string(),
|
||||
icon: "refresh-cw".to_string(),
|
||||
is_default: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn load_all_quick_actions(app: &AppHandle) -> Result<Vec<QuickAction>, String> {
|
||||
let store = app
|
||||
.store("hikari-quick-actions.json")
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
match store.get(QUICK_ACTIONS_STORE_KEY) {
|
||||
Some(value) => {
|
||||
let mut actions: Vec<QuickAction> =
|
||||
serde_json::from_value(value.clone()).map_err(|e| e.to_string())?;
|
||||
|
||||
let defaults = get_default_quick_actions();
|
||||
for default in defaults {
|
||||
if !actions.iter().any(|a| a.id == default.id) {
|
||||
actions.push(default);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(actions)
|
||||
}
|
||||
None => Ok(get_default_quick_actions()),
|
||||
}
|
||||
}
|
||||
|
||||
fn save_all_quick_actions(app: &AppHandle, actions: &[QuickAction]) -> Result<(), String> {
|
||||
let store = app
|
||||
.store("hikari-quick-actions.json")
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let value = serde_json::to_value(actions).map_err(|e| e.to_string())?;
|
||||
store.set(QUICK_ACTIONS_STORE_KEY, value);
|
||||
store.save().map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_quick_actions(app: AppHandle) -> Result<Vec<QuickAction>, String> {
|
||||
let mut actions = load_all_quick_actions(&app)?;
|
||||
|
||||
actions.sort_by(|a, b| {
|
||||
let default_cmp = b.is_default.cmp(&a.is_default);
|
||||
if default_cmp == std::cmp::Ordering::Equal {
|
||||
a.name.cmp(&b.name)
|
||||
} else {
|
||||
default_cmp
|
||||
}
|
||||
});
|
||||
|
||||
Ok(actions)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_quick_action(app: AppHandle, action: QuickAction) -> Result<(), String> {
|
||||
let mut actions = load_all_quick_actions(&app)?;
|
||||
|
||||
if let Some(existing) = actions.iter_mut().find(|a| a.id == action.id) {
|
||||
let mut updated = action;
|
||||
updated.is_default = existing.is_default;
|
||||
*existing = updated;
|
||||
} else {
|
||||
actions.push(action);
|
||||
}
|
||||
|
||||
save_all_quick_actions(&app, &actions)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_quick_action(app: AppHandle, action_id: String) -> Result<(), String> {
|
||||
let mut actions = load_all_quick_actions(&app)?;
|
||||
|
||||
if actions
|
||||
.iter()
|
||||
.any(|a| a.id == action_id && a.is_default)
|
||||
{
|
||||
return Err("Cannot delete default quick actions".to_string());
|
||||
}
|
||||
|
||||
actions.retain(|a| a.id != action_id);
|
||||
save_all_quick_actions(&app, &actions)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reset_default_quick_actions(app: AppHandle) -> Result<(), String> {
|
||||
let mut actions = load_all_quick_actions(&app)?;
|
||||
|
||||
actions.retain(|a| !a.is_default);
|
||||
actions.extend(get_default_quick_actions());
|
||||
|
||||
save_all_quick_actions(&app, &actions)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_quick_actions_exist() {
|
||||
let defaults = get_default_quick_actions();
|
||||
assert!(!defaults.is_empty());
|
||||
assert!(defaults.iter().all(|a| a.is_default));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_quick_actions_have_required_fields() {
|
||||
let defaults = get_default_quick_actions();
|
||||
for action in defaults {
|
||||
assert!(!action.id.is_empty());
|
||||
assert!(!action.name.is_empty());
|
||||
assert!(!action.prompt.is_empty());
|
||||
assert!(!action.icon.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
|
||||
const SESSIONS_STORE_KEY: &str = "sessions";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SavedSession {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub last_activity_at: DateTime<Utc>,
|
||||
pub working_directory: String,
|
||||
pub message_count: usize,
|
||||
pub preview: String, // First ~100 chars of conversation for preview
|
||||
pub messages: Vec<SavedMessage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SavedMessage {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub message_type: String,
|
||||
pub content: String,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub tool_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionListItem {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub last_activity_at: DateTime<Utc>,
|
||||
pub working_directory: String,
|
||||
pub message_count: usize,
|
||||
pub preview: String,
|
||||
}
|
||||
|
||||
impl From<&SavedSession> for SessionListItem {
|
||||
fn from(session: &SavedSession) -> Self {
|
||||
SessionListItem {
|
||||
id: session.id.clone(),
|
||||
name: session.name.clone(),
|
||||
created_at: session.created_at,
|
||||
last_activity_at: session.last_activity_at,
|
||||
working_directory: session.working_directory.clone(),
|
||||
message_count: session.message_count,
|
||||
preview: session.preview.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_all_sessions(app: &AppHandle) -> Result<Vec<SavedSession>, String> {
|
||||
let store = app
|
||||
.store("hikari-sessions.json")
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
match store.get(SESSIONS_STORE_KEY) {
|
||||
Some(value) => serde_json::from_value(value.clone()).map_err(|e| e.to_string()),
|
||||
None => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn save_all_sessions(app: &AppHandle, sessions: &[SavedSession]) -> Result<(), String> {
|
||||
let store = app
|
||||
.store("hikari-sessions.json")
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let value = serde_json::to_value(sessions).map_err(|e| e.to_string())?;
|
||||
store.set(SESSIONS_STORE_KEY, value);
|
||||
store.save().map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_sessions(app: AppHandle) -> Result<Vec<SessionListItem>, String> {
|
||||
let sessions = load_all_sessions(&app)?;
|
||||
let mut items: Vec<SessionListItem> = sessions.iter().map(SessionListItem::from).collect();
|
||||
|
||||
// Sort by last activity, most recent first
|
||||
items.sort_by(|a, b| b.last_activity_at.cmp(&a.last_activity_at));
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_session(app: AppHandle, session: SavedSession) -> Result<(), String> {
|
||||
let mut sessions = load_all_sessions(&app)?;
|
||||
|
||||
// Update existing or add new
|
||||
if let Some(existing) = sessions.iter_mut().find(|s| s.id == session.id) {
|
||||
*existing = session;
|
||||
} else {
|
||||
sessions.push(session);
|
||||
}
|
||||
|
||||
save_all_sessions(&app, &sessions)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_session(app: AppHandle, session_id: String) -> Result<Option<SavedSession>, String> {
|
||||
let sessions = load_all_sessions(&app)?;
|
||||
Ok(sessions.into_iter().find(|s| s.id == session_id))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_session(app: AppHandle, session_id: String) -> Result<(), String> {
|
||||
let mut sessions = load_all_sessions(&app)?;
|
||||
sessions.retain(|s| s.id != session_id);
|
||||
save_all_sessions(&app, &sessions)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn search_sessions(app: AppHandle, query: String) -> Result<Vec<SessionListItem>, String> {
|
||||
let sessions = load_all_sessions(&app)?;
|
||||
let query_lower = query.to_lowercase();
|
||||
|
||||
let mut matching: Vec<SessionListItem> = sessions
|
||||
.iter()
|
||||
.filter(|s| {
|
||||
s.name.to_lowercase().contains(&query_lower)
|
||||
|| s.preview.to_lowercase().contains(&query_lower)
|
||||
|| s.working_directory.to_lowercase().contains(&query_lower)
|
||||
|| s.messages
|
||||
.iter()
|
||||
.any(|m| m.content.to_lowercase().contains(&query_lower))
|
||||
})
|
||||
.map(SessionListItem::from)
|
||||
.collect();
|
||||
|
||||
// Sort by last activity, most recent first
|
||||
matching.sort_by(|a, b| b.last_activity_at.cmp(&a.last_activity_at));
|
||||
|
||||
Ok(matching)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clear_all_sessions(app: AppHandle) -> Result<(), String> {
|
||||
save_all_sessions(&app, &[])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_session_list_item_from_saved_session() {
|
||||
let session = SavedSession {
|
||||
id: "test-id".to_string(),
|
||||
name: "Test Session".to_string(),
|
||||
created_at: Utc::now(),
|
||||
last_activity_at: Utc::now(),
|
||||
working_directory: "/home/test".to_string(),
|
||||
message_count: 5,
|
||||
preview: "Hello world".to_string(),
|
||||
messages: vec![],
|
||||
};
|
||||
|
||||
let item = SessionListItem::from(&session);
|
||||
assert_eq!(item.id, "test-id");
|
||||
assert_eq!(item.name, "Test Session");
|
||||
assert_eq!(item.message_count, 5);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
use tauri::{
|
||||
menu::{Menu, MenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
AppHandle, Manager,
|
||||
};
|
||||
|
||||
use crate::config::HikariConfig;
|
||||
|
||||
pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> {
|
||||
let show_item = MenuItem::with_id(app, "show", "Show Hikari", true, None::<&str>)?;
|
||||
let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
|
||||
|
||||
let menu = Menu::with_items(app, &[&show_item, &quit_item])?;
|
||||
|
||||
let _tray = TrayIconBuilder::with_id("main")
|
||||
.icon(app.default_window_icon().unwrap().clone())
|
||||
.menu(&menu)
|
||||
.tooltip("Hikari - Claude Code Assistant")
|
||||
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||
"show" => {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
"quit" => {
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
let app = tray.app_handle();
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn should_minimize_to_tray(app: &AppHandle) -> bool {
|
||||
let config_path = app
|
||||
.path()
|
||||
.app_config_dir()
|
||||
.ok()
|
||||
.map(|p| p.join("hikari-config.json"));
|
||||
|
||||
if let Some(path) = config_path {
|
||||
if let Ok(content) = std::fs::read_to_string(&path) {
|
||||
if let Ok(config) = serde_json::from_str::<HikariConfig>(&content) {
|
||||
return config.minimize_to_tray;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
@@ -112,7 +112,7 @@ impl WslBridge {
|
||||
return Err("Process already running".to_string());
|
||||
}
|
||||
|
||||
// Load saved achievements when starting a new session
|
||||
// Load saved achievements and stats when starting a new session
|
||||
let app_clone = app.clone();
|
||||
let stats = self.stats.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
@@ -122,7 +122,17 @@ impl WslBridge {
|
||||
"Loaded {} unlocked achievements",
|
||||
achievements.unlocked.len()
|
||||
);
|
||||
stats.write().achievements = achievements;
|
||||
|
||||
println!("Loading saved stats...");
|
||||
let persisted_stats = crate::stats::load_stats(&app_clone).await;
|
||||
|
||||
let mut stats_guard = stats.write();
|
||||
stats_guard.achievements = achievements;
|
||||
|
||||
if let Some(persisted) = persisted_stats {
|
||||
println!("Applying persisted lifetime stats");
|
||||
stats_guard.apply_persisted(persisted);
|
||||
}
|
||||
});
|
||||
|
||||
let working_dir = &options.working_dir;
|
||||
@@ -440,6 +450,18 @@ impl WslBridge {
|
||||
self.session_id = None;
|
||||
self.mcp_config_file = None; // Temp file is automatically deleted when dropped
|
||||
|
||||
// Save lifetime stats before resetting session
|
||||
let stats_snapshot = self.stats.read().clone();
|
||||
let app_clone = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
println!("Saving stats on session stop...");
|
||||
if let Err(e) = crate::stats::save_stats(&app_clone, &stats_snapshot).await {
|
||||
eprintln!("Failed to save stats: {}", e);
|
||||
} else {
|
||||
println!("Stats saved successfully on session stop");
|
||||
}
|
||||
});
|
||||
|
||||
// Reset session stats on explicit disconnect
|
||||
self.stats.write().reset_session();
|
||||
|
||||
@@ -733,10 +755,23 @@ fn process_json_line(
|
||||
|
||||
let current_stats = stats.read().clone();
|
||||
let stats_event = StatsUpdateEvent {
|
||||
stats: current_stats,
|
||||
stats: current_stats.clone(),
|
||||
};
|
||||
let _ = app.emit("claude:stats", stats_event);
|
||||
|
||||
// Save stats periodically (every 10 messages to avoid excessive disk writes)
|
||||
if current_stats.session_messages_exchanged.is_multiple_of(10)
|
||||
&& current_stats.session_messages_exchanged > 0
|
||||
{
|
||||
let app_handle = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
println!("Periodic stats save (every 10 messages)...");
|
||||
if let Err(e) = crate::stats::save_stats(&app_handle, ¤t_stats).await {
|
||||
eprintln!("Failed to save stats: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Only emit error results - success content is already sent via Assistant message
|
||||
if subtype != "success" {
|
||||
if let Some(text) = result {
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
},
|
||||
"trayIcon": {
|
||||
"id": "main",
|
||||
"iconPath": "icons/32x32.png",
|
||||
"iconAsTemplate": false,
|
||||
"tooltip": "Hikari - Claude Code Assistant"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
Reference in New Issue
Block a user