mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-26 06:24:32 +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.
436 lines
13 KiB
Rust
436 lines
13 KiB
Rust
use std::path::{Path, PathBuf};
|
|
|
|
use serde_json::Value;
|
|
|
|
use crate::session_manager::{SessionMessage, SessionMeta};
|
|
|
|
use super::utils::{parse_timestamp_to_ms, path_basename, truncate_summary};
|
|
|
|
const PROVIDER_ID: &str = "opencode";
|
|
|
|
/// Return the OpenCode data directory.
|
|
///
|
|
/// Respects `XDG_DATA_HOME` on all platforms; falls back to
|
|
/// `~/.local/share/opencode/storage/`.
|
|
pub(crate) fn get_opencode_data_dir() -> PathBuf {
|
|
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
|
|
if !xdg.is_empty() {
|
|
return PathBuf::from(xdg).join("opencode").join("storage");
|
|
}
|
|
}
|
|
dirs::home_dir()
|
|
.map(|h| h.join(".local/share/opencode/storage"))
|
|
.unwrap_or_else(|| PathBuf::from(".local/share/opencode/storage"))
|
|
}
|
|
|
|
pub fn scan_sessions() -> Vec<SessionMeta> {
|
|
let storage = get_opencode_data_dir();
|
|
let session_dir = storage.join("session");
|
|
if !session_dir.exists() {
|
|
return Vec::new();
|
|
}
|
|
|
|
let mut json_files = Vec::new();
|
|
collect_json_files(&session_dir, &mut json_files);
|
|
|
|
let mut sessions = Vec::new();
|
|
for path in json_files {
|
|
if let Some(meta) = parse_session(&storage, &path) {
|
|
sessions.push(meta);
|
|
}
|
|
}
|
|
sessions
|
|
}
|
|
|
|
pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
|
// `path` is the message directory: storage/message/{sessionID}/
|
|
if !path.is_dir() {
|
|
return Err(format!("Message directory not found: {}", path.display()));
|
|
}
|
|
|
|
let storage = path
|
|
.parent()
|
|
.and_then(|p| p.parent())
|
|
.ok_or_else(|| "Cannot determine storage root from message path".to_string())?;
|
|
|
|
let mut msg_files = Vec::new();
|
|
collect_json_files(path, &mut msg_files);
|
|
|
|
// Parse all messages and collect (created_ts, message_id, role, parts_text)
|
|
let mut entries: Vec<(i64, String, String, String)> = Vec::new();
|
|
|
|
for msg_path in &msg_files {
|
|
let data = match std::fs::read_to_string(msg_path) {
|
|
Ok(d) => d,
|
|
Err(_) => continue,
|
|
};
|
|
let value: Value = match serde_json::from_str(&data) {
|
|
Ok(v) => v,
|
|
Err(_) => continue,
|
|
};
|
|
|
|
let msg_id = match value.get("id").and_then(Value::as_str) {
|
|
Some(id) => id.to_string(),
|
|
None => continue,
|
|
};
|
|
|
|
let role = value
|
|
.get("role")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("unknown")
|
|
.to_string();
|
|
|
|
let created_ts = value
|
|
.get("time")
|
|
.and_then(|t| t.get("created"))
|
|
.and_then(parse_timestamp_to_ms)
|
|
.unwrap_or(0);
|
|
|
|
// Collect text parts from storage/part/{messageID}/
|
|
let part_dir = storage.join("part").join(&msg_id);
|
|
let text = collect_parts_text(&part_dir);
|
|
if text.trim().is_empty() {
|
|
continue;
|
|
}
|
|
|
|
entries.push((created_ts, msg_id, role, text));
|
|
}
|
|
|
|
// Sort by created timestamp
|
|
entries.sort_by_key(|(ts, _, _, _)| *ts);
|
|
|
|
let messages = entries
|
|
.into_iter()
|
|
.map(|(ts, _, role, content)| SessionMessage {
|
|
role,
|
|
content,
|
|
ts: if ts > 0 { Some(ts) } else { None },
|
|
})
|
|
.collect();
|
|
|
|
Ok(messages)
|
|
}
|
|
|
|
pub fn delete_session(storage: &Path, path: &Path, session_id: &str) -> Result<bool, String> {
|
|
if path.file_name().and_then(|name| name.to_str()) != Some(session_id) {
|
|
return Err(format!(
|
|
"OpenCode session path does not match session ID: expected {session_id}, found {}",
|
|
path.display()
|
|
));
|
|
}
|
|
|
|
let mut message_files = Vec::new();
|
|
collect_json_files(path, &mut message_files);
|
|
|
|
let mut message_ids = Vec::new();
|
|
for message_path in &message_files {
|
|
let data = match std::fs::read_to_string(message_path) {
|
|
Ok(data) => data,
|
|
Err(_) => continue,
|
|
};
|
|
let value: Value = match serde_json::from_str(&data) {
|
|
Ok(value) => value,
|
|
Err(_) => continue,
|
|
};
|
|
if let Some(message_id) = value.get("id").and_then(Value::as_str) {
|
|
message_ids.push(message_id.to_string());
|
|
}
|
|
}
|
|
|
|
for message_id in &message_ids {
|
|
let part_dir = storage.join("part").join(message_id);
|
|
remove_dir_all_if_exists(&part_dir).map_err(|e| {
|
|
format!(
|
|
"Failed to delete OpenCode part directory {}: {e}",
|
|
part_dir.display()
|
|
)
|
|
})?;
|
|
}
|
|
|
|
let session_diff_path = storage
|
|
.join("session_diff")
|
|
.join(format!("{session_id}.json"));
|
|
remove_file_if_exists(&session_diff_path).map_err(|e| {
|
|
format!(
|
|
"Failed to delete OpenCode session diff {}: {e}",
|
|
session_diff_path.display()
|
|
)
|
|
})?;
|
|
|
|
remove_dir_all_if_exists(path).map_err(|e| {
|
|
format!(
|
|
"Failed to delete OpenCode message directory {}: {e}",
|
|
path.display()
|
|
)
|
|
})?;
|
|
|
|
if let Some(session_file) = find_session_file(storage, session_id) {
|
|
remove_file_if_exists(&session_file).map_err(|e| {
|
|
format!(
|
|
"Failed to delete OpenCode session file {}: {e}",
|
|
session_file.display()
|
|
)
|
|
})?;
|
|
}
|
|
|
|
Ok(true)
|
|
}
|
|
|
|
fn parse_session(storage: &Path, 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("id").and_then(Value::as_str)?.to_string();
|
|
let title = value
|
|
.get("title")
|
|
.and_then(Value::as_str)
|
|
.filter(|s| !s.is_empty())
|
|
.map(|s| s.to_string());
|
|
let directory = value
|
|
.get("directory")
|
|
.and_then(Value::as_str)
|
|
.map(|s| s.to_string());
|
|
|
|
let created_at = value
|
|
.get("time")
|
|
.and_then(|t| t.get("created"))
|
|
.and_then(parse_timestamp_to_ms);
|
|
let updated_at = value
|
|
.get("time")
|
|
.and_then(|t| t.get("updated"))
|
|
.and_then(parse_timestamp_to_ms);
|
|
|
|
// Derive title from directory basename if no explicit title
|
|
let has_title = title.is_some();
|
|
let display_title = title.or_else(|| {
|
|
directory
|
|
.as_deref()
|
|
.and_then(path_basename)
|
|
.map(|s| s.to_string())
|
|
});
|
|
|
|
// Build source_path = message directory for this session
|
|
let msg_dir = storage.join("message").join(&session_id);
|
|
let source_path = msg_dir.to_string_lossy().to_string();
|
|
|
|
// Skip expensive I/O if title already available from session JSON
|
|
let summary = if has_title {
|
|
display_title.clone()
|
|
} else {
|
|
get_first_user_summary(storage, &session_id)
|
|
};
|
|
|
|
Some(SessionMeta {
|
|
provider_id: PROVIDER_ID.to_string(),
|
|
session_id: session_id.clone(),
|
|
title: display_title,
|
|
summary,
|
|
project_dir: directory,
|
|
created_at,
|
|
last_active_at: updated_at.or(created_at),
|
|
source_path: Some(source_path),
|
|
resume_command: Some(format!("opencode session resume {session_id}")),
|
|
})
|
|
}
|
|
|
|
/// Read the first user message's first text part to use as summary.
|
|
fn get_first_user_summary(storage: &Path, session_id: &str) -> Option<String> {
|
|
let msg_dir = storage.join("message").join(session_id);
|
|
if !msg_dir.is_dir() {
|
|
return None;
|
|
}
|
|
|
|
let mut msg_files = Vec::new();
|
|
collect_json_files(&msg_dir, &mut msg_files);
|
|
|
|
// Collect user messages with timestamps for ordering
|
|
let mut user_msgs: Vec<(i64, String)> = Vec::new();
|
|
for msg_path in &msg_files {
|
|
let data = match std::fs::read_to_string(msg_path) {
|
|
Ok(d) => d,
|
|
Err(_) => continue,
|
|
};
|
|
let value: Value = match serde_json::from_str(&data) {
|
|
Ok(v) => v,
|
|
Err(_) => continue,
|
|
};
|
|
|
|
if value.get("role").and_then(Value::as_str) != Some("user") {
|
|
continue;
|
|
}
|
|
|
|
let msg_id = match value.get("id").and_then(Value::as_str) {
|
|
Some(id) => id.to_string(),
|
|
None => continue,
|
|
};
|
|
|
|
let ts = value
|
|
.get("time")
|
|
.and_then(|t| t.get("created"))
|
|
.and_then(parse_timestamp_to_ms)
|
|
.unwrap_or(0);
|
|
|
|
user_msgs.push((ts, msg_id));
|
|
}
|
|
|
|
user_msgs.sort_by_key(|(ts, _)| *ts);
|
|
|
|
// Take first user message and get its parts
|
|
let (_, first_id) = user_msgs.first()?;
|
|
let part_dir = storage.join("part").join(first_id);
|
|
let text = collect_parts_text(&part_dir);
|
|
if text.trim().is_empty() {
|
|
return None;
|
|
}
|
|
Some(truncate_summary(&text, 160))
|
|
}
|
|
|
|
/// Collect text content from all parts in a part directory.
|
|
fn collect_parts_text(part_dir: &Path) -> String {
|
|
if !part_dir.is_dir() {
|
|
return String::new();
|
|
}
|
|
|
|
let mut parts = Vec::new();
|
|
collect_json_files(part_dir, &mut parts);
|
|
|
|
let mut texts = Vec::new();
|
|
for part_path in &parts {
|
|
let data = match std::fs::read_to_string(part_path) {
|
|
Ok(d) => d,
|
|
Err(_) => continue,
|
|
};
|
|
let value: Value = match serde_json::from_str(&data) {
|
|
Ok(v) => v,
|
|
Err(_) => continue,
|
|
};
|
|
|
|
// Only include text-type parts
|
|
if value.get("type").and_then(Value::as_str) != Some("text") {
|
|
continue;
|
|
}
|
|
|
|
if let Some(text) = value.get("text").and_then(Value::as_str) {
|
|
if !text.trim().is_empty() {
|
|
texts.push(text.to_string());
|
|
}
|
|
}
|
|
}
|
|
|
|
texts.join("\n")
|
|
}
|
|
|
|
fn collect_json_files(root: &Path, files: &mut Vec<PathBuf>) {
|
|
if !root.exists() {
|
|
return;
|
|
}
|
|
|
|
let entries = match std::fs::read_dir(root) {
|
|
Ok(entries) => entries,
|
|
Err(_) => return,
|
|
};
|
|
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if path.is_dir() {
|
|
collect_json_files(&path, files);
|
|
} else if path.extension().and_then(|ext| ext.to_str()) == Some("json") {
|
|
files.push(path);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn find_session_file(storage: &Path, session_id: &str) -> Option<PathBuf> {
|
|
let session_root = storage.join("session");
|
|
let mut files = Vec::new();
|
|
collect_json_files(&session_root, &mut files);
|
|
let expected = format!("{session_id}.json");
|
|
|
|
files
|
|
.into_iter()
|
|
.find(|path| path.file_name().and_then(|name| name.to_str()) == Some(expected.as_str()))
|
|
}
|
|
|
|
fn remove_file_if_exists(path: &Path) -> std::io::Result<()> {
|
|
match std::fs::remove_file(path) {
|
|
Ok(()) => Ok(()),
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
|
Err(err) => Err(err),
|
|
}
|
|
}
|
|
|
|
fn remove_dir_all_if_exists(path: &Path) -> std::io::Result<()> {
|
|
match std::fs::remove_dir_all(path) {
|
|
Ok(()) => Ok(()),
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
|
Err(err) => Err(err),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tempfile::tempdir;
|
|
|
|
#[test]
|
|
fn delete_session_removes_session_diff_messages_and_parts() {
|
|
let temp = tempdir().expect("tempdir");
|
|
let storage = temp.path();
|
|
let project_id = "project-123";
|
|
let session_id = "ses_123";
|
|
let session_dir = storage.join("session").join(project_id);
|
|
let message_dir = storage.join("message").join(session_id);
|
|
let session_diff = storage
|
|
.join("session_diff")
|
|
.join(format!("{session_id}.json"));
|
|
let part_dir = storage.join("part").join("msg_1");
|
|
let session_file = session_dir.join(format!("{session_id}.json"));
|
|
|
|
std::fs::create_dir_all(&session_dir).expect("create session dir");
|
|
std::fs::create_dir_all(&message_dir).expect("create message dir");
|
|
std::fs::create_dir_all(&part_dir).expect("create part dir");
|
|
std::fs::create_dir_all(storage.join("project")).expect("create project dir");
|
|
std::fs::create_dir_all(storage.join("session_diff")).expect("create session diff dir");
|
|
|
|
std::fs::write(
|
|
&session_file,
|
|
format!(
|
|
r#"{{
|
|
"id": "{session_id}",
|
|
"projectID": "{project_id}",
|
|
"directory": "/tmp/project",
|
|
"time": {{ "created": 1, "updated": 2 }}
|
|
}}"#
|
|
),
|
|
)
|
|
.expect("write session file");
|
|
std::fs::write(
|
|
message_dir.join("msg_1.json"),
|
|
format!(r#"{{"id":"msg_1","sessionID":"{session_id}","role":"user"}}"#),
|
|
)
|
|
.expect("write message file");
|
|
std::fs::write(
|
|
part_dir.join("prt_1.json"),
|
|
r#"{"id":"prt_1","messageID":"msg_1"}"#,
|
|
)
|
|
.expect("write part file");
|
|
std::fs::write(&session_diff, "[]").expect("write session diff");
|
|
std::fs::write(
|
|
storage.join("project").join(format!("{project_id}.json")),
|
|
r#"{"id":"project-123"}"#,
|
|
)
|
|
.expect("write project file");
|
|
|
|
delete_session(storage, &message_dir, session_id).expect("delete session");
|
|
|
|
assert!(!session_file.exists());
|
|
assert!(!message_dir.exists());
|
|
assert!(!session_diff.exists());
|
|
assert!(!part_dir.exists());
|
|
assert!(storage
|
|
.join("project")
|
|
.join(format!("{project_id}.json"))
|
|
.exists());
|
|
}
|
|
}
|