use std::collections::{HashMap, HashSet}; use crate::db::entities::conversation; use crate::db::service::{conversation_service, folder_service, import_service}; use crate::db::AppDatabase; use crate::models::*; use crate::parsers::claude::ClaudeParser; use crate::parsers::codex::CodexParser; use crate::parsers::gemini::GeminiParser; use crate::parsers::opencode::OpenCodeParser; use crate::parsers::{path_eq_for_matching, AgentParser}; #[tauri::command] pub async fn list_folder_conversations( db: tauri::State<'_, AppDatabase>, folder_id: i32, agent_type: Option, search: Option, sort_by: Option, status: Option, ) -> Result, String> { conversation_service::list_by_folder(&db.conn, folder_id, agent_type, search, sort_by, status) .await .map_err(|e| e.to_string()) } /// Synchronous implementation shared by list_conversations, list_folders, and get_stats. fn list_conversations_sync( agent_type: Option, search: Option, sort_by: Option, folder_path: Option, ) -> Result, String> { let mut all_conversations = Vec::new(); let mut seen_keys = HashSet::new(); let parsers: Vec<(AgentType, Box)> = vec![ (AgentType::ClaudeCode, Box::new(ClaudeParser::new())), (AgentType::Codex, Box::new(CodexParser::new())), (AgentType::OpenCode, Box::new(OpenCodeParser::new())), (AgentType::Gemini, Box::new(GeminiParser::new())), ]; for (at, parser) in &parsers { if let Some(ref filter) = agent_type { if filter != at { continue; } } match parser.list_conversations() { Ok(conversations) => { // Deduplicate conversations based on (agent_type, id) combination for conversation in conversations { let key = format!("{:?}-{}", conversation.agent_type, conversation.id); if seen_keys.insert(key) { all_conversations.push(conversation); } } } Err(e) => { eprintln!("Error listing {} conversations: {}", at, e); } } } // Apply search filter if let Some(ref query) = search { let query_lower = query.to_lowercase(); all_conversations.retain(|s| { s.title .as_ref() .map(|t| t.to_lowercase().contains(&query_lower)) .unwrap_or(false) || s.folder_name .as_ref() .map(|p| p.to_lowercase().contains(&query_lower)) .unwrap_or(false) || s.folder_path .as_ref() .map(|p| p.to_lowercase().contains(&query_lower)) .unwrap_or(false) || s.git_branch .as_ref() .map(|b| b.to_lowercase().contains(&query_lower)) .unwrap_or(false) }); } // Apply folder path filter if let Some(ref fp) = folder_path { all_conversations.retain(|s| { s.folder_path .as_deref() .map(|p| path_eq_for_matching(p, fp.as_str())) .unwrap_or(false) }); } // Apply sorting match sort_by.as_deref() { Some("oldest") => all_conversations.sort_by(|a, b| a.started_at.cmp(&b.started_at)), Some("messages") => all_conversations.sort_by(|a, b| b.message_count.cmp(&a.message_count)), _ => all_conversations.sort_by(|a, b| b.started_at.cmp(&a.started_at)), // default: newest first } Ok(all_conversations) } #[tauri::command] pub async fn list_conversations( agent_type: Option, search: Option, sort_by: Option, folder_path: Option, ) -> Result, String> { tokio::task::spawn_blocking(move || { list_conversations_sync(agent_type, search, sort_by, folder_path) }) .await .map_err(|e| e.to_string())? } #[tauri::command] pub async fn get_conversation( agent_type: AgentType, conversation_id: String, ) -> Result { tokio::task::spawn_blocking(move || { let parser: Box = match agent_type { AgentType::ClaudeCode => Box::new(ClaudeParser::new()), AgentType::Codex => Box::new(CodexParser::new()), AgentType::OpenCode => Box::new(OpenCodeParser::new()), AgentType::Gemini => Box::new(GeminiParser::new()), _ => { return Err(format!( "conversation parsing not supported for {agent_type}" )) } }; parser .get_conversation(&conversation_id) .map_err(|e| e.to_string()) }) .await .map_err(|e| e.to_string())? } #[tauri::command] pub async fn list_folders() -> Result, String> { tokio::task::spawn_blocking(move || { let all_conversations = list_conversations_sync(None, None, None, None)?; Ok(compute_folders(&all_conversations)) }) .await .map_err(|e| e.to_string())? } #[tauri::command] pub async fn get_stats() -> Result { tokio::task::spawn_blocking(move || { let all_conversations = list_conversations_sync(None, None, None, None)?; compute_stats(&all_conversations) }) .await .map_err(|e| e.to_string())? } #[tauri::command] pub async fn get_sidebar_data() -> Result { tokio::task::spawn_blocking(move || { let all_conversations = list_conversations_sync(None, None, None, None)?; let folders = compute_folders(&all_conversations); let stats = compute_stats(&all_conversations)?; Ok(SidebarData { folders, stats }) }) .await .map_err(|e| e.to_string())? } fn compute_folders(all_conversations: &[ConversationSummary]) -> Vec { let mut folder_map: HashMap = HashMap::new(); for conversation in all_conversations { let path = conversation .folder_path .clone() .unwrap_or_else(|| "unknown".to_string()); let name = conversation .folder_name .clone() .unwrap_or_else(|| "unknown".to_string()); let entry = folder_map .entry(path.clone()) .or_insert_with(|| FolderInfo { path: path.clone(), name, agent_types: Vec::new(), conversation_count: 0, }); entry.conversation_count += 1; if !entry.agent_types.contains(&conversation.agent_type) { entry.agent_types.push(conversation.agent_type); } } let mut folders: Vec = folder_map.into_values().collect(); folders.sort_by(|a, b| b.conversation_count.cmp(&a.conversation_count)); folders } #[tauri::command] pub async fn import_local_conversations( db: tauri::State<'_, AppDatabase>, folder_id: i32, ) -> Result { let folder = folder_service::get_folder_by_id(&db.conn, folder_id) .await .map_err(|e| e.to_string())? .ok_or_else(|| format!("Folder not found: {folder_id}"))?; import_service::import_local_conversations(&db.conn, folder_id, &folder.path) .await .map_err(|e| e.to_string()) } #[tauri::command] pub async fn get_folder_conversation( db: tauri::State<'_, AppDatabase>, conversation_id: i32, ) -> Result { let summary = conversation_service::get_by_id(&db.conn, conversation_id) .await .map_err(|e| e.to_string())?; let (turns, session_stats) = if let Some(ref ext_id) = summary.external_id { let at = summary.agent_type; let eid = ext_id.clone(); tokio::task::spawn_blocking(move || -> Result<_, String> { let parser: Box = match at { AgentType::ClaudeCode => Box::new(ClaudeParser::new()), AgentType::Codex => Box::new(CodexParser::new()), AgentType::OpenCode => Box::new(OpenCodeParser::new()), AgentType::Gemini => Box::new(GeminiParser::new()), _ => return Ok((vec![], None)), }; // If the external session file doesn't exist yet (e.g., new ACP session // not yet synced to disk), return empty turns instead of failing. match parser.get_conversation(&eid) { Ok(d) => Ok((d.turns, d.session_stats)), Err(crate::parsers::ParseError::ConversationNotFound(_)) => Ok((vec![], None)), Err(e) => Err(e.to_string()), } }) .await .map_err(|e| e.to_string())? .map_err(|e: String| e)? } else { (vec![], None) }; let mut summary = summary; summary.message_count = turns.len() as u32; Ok(DbConversationDetail { summary, turns, session_stats, }) } #[tauri::command] pub async fn create_conversation( db: tauri::State<'_, AppDatabase>, folder_id: i32, agent_type: AgentType, title: Option, ) -> Result { // Detect current git branch from the folder path let git_branch = if let Some(folder) = folder_service::get_folder_by_id(&db.conn, folder_id) .await .map_err(|e| e.to_string())? { detect_git_branch(&folder.path).await } else { None }; let model = conversation_service::create(&db.conn, folder_id, agent_type, title, git_branch) .await .map_err(|e| e.to_string())?; Ok(model.id) } async fn detect_git_branch(path: &str) -> Option { let output = crate::process::tokio_command("git") .args(["rev-parse", "--abbrev-ref", "HEAD"]) .current_dir(path) .output() .await .ok()?; if !output.status.success() { return None; } let branch = String::from_utf8_lossy(&output.stdout).trim().to_string(); if branch.is_empty() || branch == "HEAD" { return None; } Some(branch) } #[tauri::command] pub async fn update_conversation_status( db: tauri::State<'_, AppDatabase>, conversation_id: i32, status: String, ) -> Result<(), String> { let status_enum: conversation::ConversationStatus = serde_json::from_value(serde_json::Value::String(status)).map_err(|e| e.to_string())?; conversation_service::update_status(&db.conn, conversation_id, status_enum) .await .map_err(|e| e.to_string()) } #[tauri::command] pub async fn update_conversation_title( db: tauri::State<'_, AppDatabase>, conversation_id: i32, title: String, ) -> Result<(), String> { conversation_service::update_title(&db.conn, conversation_id, title) .await .map_err(|e| e.to_string()) } #[tauri::command] pub async fn update_conversation_external_id( db: tauri::State<'_, AppDatabase>, conversation_id: i32, external_id: String, ) -> Result<(), String> { conversation_service::update_external_id(&db.conn, conversation_id, external_id) .await .map_err(|e| e.to_string()) } #[tauri::command] pub async fn delete_conversation( db: tauri::State<'_, AppDatabase>, conversation_id: i32, ) -> Result<(), String> { conversation_service::soft_delete(&db.conn, conversation_id) .await .map_err(|e| e.to_string()) } fn compute_stats(all_conversations: &[ConversationSummary]) -> Result { let mut total_messages: u32 = 0; let mut counts: HashMap = HashMap::new(); for conversation in all_conversations { total_messages += conversation.message_count; *counts.entry(conversation.agent_type).or_insert(0) += 1; } let mut by_agent: Vec = counts .into_iter() .map(|(agent_type, conversation_count)| AgentConversationCount { agent_type, conversation_count, }) .collect(); by_agent.sort_by(|a, b| b.conversation_count.cmp(&a.conversation_count)); Ok(AgentStats { total_conversations: all_conversations.len() as u32, total_messages, by_agent, }) }