generated from nhcarrigan/template
1f5f95c8f3
- Fix assertions on constants in clipboard.rs (use const blocks) - Fix unnecessary unwrap after is_ok check in git.rs - Remove redundant u64 >= 0 comparison in stats.rs - Add #[allow(clippy::useless_vec)] to sorting tests - Add missing beforeEach import to vitest.setup.ts - Change global.Audio to globalThis.Audio in notifications.test.ts - Add type annotations to fix null type narrowing in conversations.test.ts
375 lines
12 KiB
Rust
375 lines
12 KiB
Rust
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::*;
|
|
use chrono::TimeZone;
|
|
|
|
fn create_test_session(id: &str, name: &str) -> SavedSession {
|
|
SavedSession {
|
|
id: id.to_string(),
|
|
name: name.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![],
|
|
}
|
|
}
|
|
|
|
fn create_test_message(id: &str, content: &str, msg_type: &str) -> SavedMessage {
|
|
SavedMessage {
|
|
id: id.to_string(),
|
|
message_type: msg_type.to_string(),
|
|
content: content.to_string(),
|
|
timestamp: Utc::now(),
|
|
tool_name: None,
|
|
}
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_list_item_preserves_all_fields() {
|
|
let created = Utc.with_ymd_and_hms(2024, 1, 15, 10, 30, 0).unwrap();
|
|
let last_activity = Utc.with_ymd_and_hms(2024, 1, 15, 14, 45, 0).unwrap();
|
|
|
|
let session = SavedSession {
|
|
id: "sess-123".to_string(),
|
|
name: "My Chat".to_string(),
|
|
created_at: created,
|
|
last_activity_at: last_activity,
|
|
working_directory: "/home/naomi/project".to_string(),
|
|
message_count: 42,
|
|
preview: "What is the meaning of life?".to_string(),
|
|
messages: vec![],
|
|
};
|
|
|
|
let item = SessionListItem::from(&session);
|
|
|
|
assert_eq!(item.id, "sess-123");
|
|
assert_eq!(item.name, "My Chat");
|
|
assert_eq!(item.created_at, created);
|
|
assert_eq!(item.last_activity_at, last_activity);
|
|
assert_eq!(item.working_directory, "/home/naomi/project");
|
|
assert_eq!(item.message_count, 42);
|
|
assert_eq!(item.preview, "What is the meaning of life?");
|
|
}
|
|
|
|
#[test]
|
|
fn test_saved_session_serialization() {
|
|
let session = create_test_session("test-1", "Test Session");
|
|
let json = serde_json::to_string(&session).expect("Failed to serialize");
|
|
let parsed: SavedSession = serde_json::from_str(&json).expect("Failed to deserialize");
|
|
|
|
assert_eq!(parsed.id, session.id);
|
|
assert_eq!(parsed.name, session.name);
|
|
assert_eq!(parsed.working_directory, session.working_directory);
|
|
}
|
|
|
|
#[test]
|
|
fn test_saved_message_serialization() {
|
|
let message = create_test_message("msg-1", "Hello!", "user");
|
|
let json = serde_json::to_string(&message).expect("Failed to serialize");
|
|
let parsed: SavedMessage = serde_json::from_str(&json).expect("Failed to deserialize");
|
|
|
|
assert_eq!(parsed.id, message.id);
|
|
assert_eq!(parsed.content, message.content);
|
|
assert_eq!(parsed.message_type, "user");
|
|
}
|
|
|
|
#[test]
|
|
fn test_saved_message_with_tool_name() {
|
|
let message = SavedMessage {
|
|
id: "msg-tool-1".to_string(),
|
|
message_type: "tool".to_string(),
|
|
content: "File read successfully".to_string(),
|
|
timestamp: Utc::now(),
|
|
tool_name: Some("Read".to_string()),
|
|
};
|
|
|
|
let json = serde_json::to_string(&message).expect("Failed to serialize");
|
|
let parsed: SavedMessage = serde_json::from_str(&json).expect("Failed to deserialize");
|
|
|
|
assert_eq!(parsed.tool_name, Some("Read".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_with_messages_serialization() {
|
|
let mut session = create_test_session("sess-full", "Full Session");
|
|
session.messages = vec![
|
|
create_test_message("msg-1", "Hello!", "user"),
|
|
create_test_message("msg-2", "Hi there!", "assistant"),
|
|
create_test_message("msg-3", "Read file", "tool"),
|
|
];
|
|
session.message_count = 3;
|
|
|
|
let json = serde_json::to_string(&session).expect("Failed to serialize");
|
|
let parsed: SavedSession = serde_json::from_str(&json).expect("Failed to deserialize");
|
|
|
|
assert_eq!(parsed.messages.len(), 3);
|
|
assert_eq!(parsed.messages[0].content, "Hello!");
|
|
assert_eq!(parsed.messages[1].message_type, "assistant");
|
|
assert_eq!(parsed.messages[2].message_type, "tool");
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_list_item_serialization() {
|
|
let item = SessionListItem {
|
|
id: "list-item-1".to_string(),
|
|
name: "Quick Chat".to_string(),
|
|
created_at: Utc::now(),
|
|
last_activity_at: Utc::now(),
|
|
working_directory: "/tmp".to_string(),
|
|
message_count: 10,
|
|
preview: "Short preview...".to_string(),
|
|
};
|
|
|
|
let json = serde_json::to_string(&item).expect("Failed to serialize");
|
|
let parsed: SessionListItem = serde_json::from_str(&json).expect("Failed to deserialize");
|
|
|
|
assert_eq!(parsed.id, item.id);
|
|
assert_eq!(parsed.name, item.name);
|
|
assert_eq!(parsed.preview, item.preview);
|
|
}
|
|
|
|
#[test]
|
|
fn test_message_type_field_rename() {
|
|
// The message_type field is renamed to "type" in JSON
|
|
let message = create_test_message("msg-1", "Test", "assistant");
|
|
let json = serde_json::to_string(&message).expect("Failed to serialize");
|
|
|
|
assert!(json.contains("\"type\":"));
|
|
assert!(!json.contains("\"message_type\":"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_default_empty_messages() {
|
|
let session = SavedSession {
|
|
id: "empty".to_string(),
|
|
name: "Empty".to_string(),
|
|
created_at: Utc::now(),
|
|
last_activity_at: Utc::now(),
|
|
working_directory: "/".to_string(),
|
|
message_count: 0,
|
|
preview: "".to_string(),
|
|
messages: vec![],
|
|
};
|
|
|
|
assert!(session.messages.is_empty());
|
|
assert_eq!(session.message_count, 0);
|
|
}
|
|
|
|
#[test]
|
|
#[allow(clippy::useless_vec)]
|
|
fn test_session_sorting_by_activity() {
|
|
let old_time = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
|
|
let new_time = Utc.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
|
|
|
|
let mut sessions = vec![
|
|
SessionListItem {
|
|
id: "old".to_string(),
|
|
name: "Old Session".to_string(),
|
|
created_at: old_time,
|
|
last_activity_at: old_time,
|
|
working_directory: "/old".to_string(),
|
|
message_count: 1,
|
|
preview: "Old".to_string(),
|
|
},
|
|
SessionListItem {
|
|
id: "new".to_string(),
|
|
name: "New Session".to_string(),
|
|
created_at: new_time,
|
|
last_activity_at: new_time,
|
|
working_directory: "/new".to_string(),
|
|
message_count: 1,
|
|
preview: "New".to_string(),
|
|
},
|
|
];
|
|
|
|
// Sort by last activity, most recent first (mimics list_sessions behavior)
|
|
sessions.sort_by(|a, b| b.last_activity_at.cmp(&a.last_activity_at));
|
|
|
|
assert_eq!(sessions[0].id, "new");
|
|
assert_eq!(sessions[1].id, "old");
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_clone() {
|
|
let original = create_test_session("clone-test", "Clone Test");
|
|
let cloned = original.clone();
|
|
|
|
assert_eq!(original.id, cloned.id);
|
|
assert_eq!(original.name, cloned.name);
|
|
}
|
|
|
|
#[test]
|
|
fn test_message_clone() {
|
|
let original = create_test_message("msg-clone", "Content", "user");
|
|
let cloned = original.clone();
|
|
|
|
assert_eq!(original.id, cloned.id);
|
|
assert_eq!(original.content, cloned.content);
|
|
}
|
|
}
|