mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-28 08:44:41 +08:00
8c3f18a9bd
Add delete_session Tauri command dispatching to provider-specific deletion logic for all 5 providers (Claude, Codex, Gemini, OpenCode, OpenClaw). Includes path traversal protection via canonicalize + starts_with validation, session ID verification against file contents, frontend confirmation dialog with optimistic cache updates, i18n keys (zh/en/ja), and component tests.
176 lines
5.2 KiB
Rust
176 lines
5.2 KiB
Rust
use std::path::Path;
|
|
|
|
use serde_json::Value;
|
|
|
|
use crate::session_manager::{SessionMessage, SessionMeta};
|
|
|
|
use super::utils::{parse_timestamp_to_ms, truncate_summary};
|
|
|
|
const PROVIDER_ID: &str = "gemini";
|
|
|
|
pub fn scan_sessions() -> Vec<SessionMeta> {
|
|
let gemini_dir = crate::gemini_config::get_gemini_dir();
|
|
let tmp_dir = gemini_dir.join("tmp");
|
|
if !tmp_dir.exists() {
|
|
return Vec::new();
|
|
}
|
|
|
|
let mut sessions = Vec::new();
|
|
|
|
// Iterate over project hash directories: tmp/<project_hash>/chats/session-*.json
|
|
let project_dirs = match std::fs::read_dir(&tmp_dir) {
|
|
Ok(entries) => entries,
|
|
Err(_) => return Vec::new(),
|
|
};
|
|
|
|
for entry in project_dirs.flatten() {
|
|
let chats_dir = entry.path().join("chats");
|
|
if !chats_dir.is_dir() {
|
|
continue;
|
|
}
|
|
|
|
let chat_files = match std::fs::read_dir(&chats_dir) {
|
|
Ok(entries) => entries,
|
|
Err(_) => continue,
|
|
};
|
|
|
|
for file_entry in chat_files.flatten() {
|
|
let path = file_entry.path();
|
|
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
|
continue;
|
|
}
|
|
if let Some(meta) = parse_session(&path) {
|
|
sessions.push(meta);
|
|
}
|
|
}
|
|
}
|
|
|
|
sessions
|
|
}
|
|
|
|
pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
|
let data = std::fs::read_to_string(path).map_err(|e| format!("Failed to read session: {e}"))?;
|
|
let value: Value =
|
|
serde_json::from_str(&data).map_err(|e| format!("Failed to parse session JSON: {e}"))?;
|
|
|
|
let messages = value
|
|
.get("messages")
|
|
.and_then(Value::as_array)
|
|
.ok_or_else(|| "No messages array found".to_string())?;
|
|
|
|
let mut result = Vec::new();
|
|
for msg in messages {
|
|
let content = match msg.get("content").and_then(Value::as_str) {
|
|
Some(c) if !c.trim().is_empty() => c.to_string(),
|
|
_ => continue,
|
|
};
|
|
|
|
let role = match msg.get("type").and_then(Value::as_str) {
|
|
Some("gemini") => "assistant".to_string(),
|
|
Some("user") => "user".to_string(),
|
|
Some(other) => other.to_string(),
|
|
None => continue,
|
|
};
|
|
|
|
let ts = msg.get("timestamp").and_then(parse_timestamp_to_ms);
|
|
|
|
result.push(SessionMessage { role, content, ts });
|
|
}
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result<bool, String> {
|
|
let meta = parse_session(path).ok_or_else(|| {
|
|
format!(
|
|
"Failed to parse Gemini session metadata: {}",
|
|
path.display()
|
|
)
|
|
})?;
|
|
|
|
if meta.session_id != session_id {
|
|
return Err(format!(
|
|
"Gemini session ID mismatch: expected {session_id}, found {}",
|
|
meta.session_id
|
|
));
|
|
}
|
|
|
|
std::fs::remove_file(path).map_err(|e| {
|
|
format!(
|
|
"Failed to delete Gemini session file {}: {e}",
|
|
path.display()
|
|
)
|
|
})?;
|
|
|
|
Ok(true)
|
|
}
|
|
|
|
fn parse_session(path: &Path) -> Option<SessionMeta> {
|
|
let data = std::fs::read_to_string(path).ok()?;
|
|
let value: Value = serde_json::from_str(&data).ok()?;
|
|
|
|
let session_id = value.get("sessionId").and_then(Value::as_str)?.to_string();
|
|
|
|
let created_at = value.get("startTime").and_then(parse_timestamp_to_ms);
|
|
let last_active_at = value.get("lastUpdated").and_then(parse_timestamp_to_ms);
|
|
|
|
// Derive title from first user message
|
|
let title = value
|
|
.get("messages")
|
|
.and_then(Value::as_array)
|
|
.and_then(|msgs| {
|
|
msgs.iter()
|
|
.find(|m| m.get("type").and_then(Value::as_str) == Some("user"))
|
|
.and_then(|m| m.get("content").and_then(Value::as_str))
|
|
.filter(|s| !s.trim().is_empty())
|
|
.map(|s| truncate_summary(s, 160))
|
|
});
|
|
|
|
let source_path = path.to_string_lossy().to_string();
|
|
|
|
Some(SessionMeta {
|
|
provider_id: PROVIDER_ID.to_string(),
|
|
session_id: session_id.clone(),
|
|
title: title.clone(),
|
|
summary: title,
|
|
project_dir: None, // project hash is not reversible
|
|
created_at,
|
|
last_active_at: last_active_at.or(created_at),
|
|
source_path: Some(source_path),
|
|
resume_command: Some(format!("gemini --resume {session_id}")),
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tempfile::tempdir;
|
|
|
|
#[test]
|
|
fn delete_session_removes_json_file() {
|
|
let temp = tempdir().expect("tempdir");
|
|
let path = temp.path().join("session-2026-03-06T10-17-test.json");
|
|
std::fs::write(
|
|
&path,
|
|
r#"{
|
|
"sessionId": "gemini-session-123",
|
|
"startTime": "2026-03-06T10:17:58.000Z",
|
|
"lastUpdated": "2026-03-06T10:20:00.000Z",
|
|
"messages": [
|
|
{
|
|
"id": "msg-1",
|
|
"timestamp": "2026-03-06T10:17:58.000Z",
|
|
"type": "user",
|
|
"content": "hello"
|
|
}
|
|
]
|
|
}"#,
|
|
)
|
|
.expect("write session");
|
|
|
|
delete_session(temp.path(), &path, "gemini-session-123").expect("delete session");
|
|
|
|
assert!(!path.exists());
|
|
}
|
|
}
|