mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-04-03 22:49:15 +08:00
feat: add session deletion with per-provider cleanup and path safety
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.
This commit is contained in:
@@ -57,3 +57,20 @@ pub async fn launch_session_terminal(
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_session(
|
||||
providerId: String,
|
||||
sessionId: String,
|
||||
sourcePath: String,
|
||||
) -> Result<bool, String> {
|
||||
let provider_id = providerId.clone();
|
||||
let session_id = sessionId.clone();
|
||||
let source_path = sourcePath.clone();
|
||||
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
session_manager::delete_session(&provider_id, &session_id, &source_path)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Failed to delete session: {e}"))?
|
||||
}
|
||||
|
||||
@@ -1038,6 +1038,7 @@ pub fn run() {
|
||||
// Session manager
|
||||
commands::list_sessions,
|
||||
commands::get_session_messages,
|
||||
commands::delete_session,
|
||||
commands::launch_session_terminal,
|
||||
commands::get_tool_versions,
|
||||
// Provider terminal
|
||||
|
||||
@@ -2,7 +2,7 @@ pub mod providers;
|
||||
pub mod terminal;
|
||||
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use providers::{claude, codex, gemini, openclaw, opencode};
|
||||
|
||||
@@ -79,3 +79,90 @@ pub fn load_messages(provider_id: &str, source_path: &str) -> Result<Vec<Session
|
||||
_ => Err(format!("Unsupported provider: {provider_id}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_session(
|
||||
provider_id: &str,
|
||||
session_id: &str,
|
||||
source_path: &str,
|
||||
) -> Result<bool, String> {
|
||||
let root = provider_root(provider_id)?;
|
||||
delete_session_with_root(provider_id, session_id, Path::new(source_path), &root)
|
||||
}
|
||||
|
||||
fn delete_session_with_root(
|
||||
provider_id: &str,
|
||||
session_id: &str,
|
||||
source_path: &Path,
|
||||
root: &Path,
|
||||
) -> Result<bool, String> {
|
||||
let validated_root = canonicalize_existing_path(root, "session root")?;
|
||||
let validated_source = canonicalize_existing_path(source_path, "session source")?;
|
||||
|
||||
if !validated_source.starts_with(&validated_root) {
|
||||
return Err(format!(
|
||||
"Session source path is outside provider root: {}",
|
||||
source_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
match provider_id {
|
||||
"codex" => codex::delete_session(&validated_root, &validated_source, session_id),
|
||||
"claude" => claude::delete_session(&validated_root, &validated_source, session_id),
|
||||
"opencode" => opencode::delete_session(&validated_root, &validated_source, session_id),
|
||||
"openclaw" => openclaw::delete_session(&validated_root, &validated_source, session_id),
|
||||
"gemini" => gemini::delete_session(&validated_root, &validated_source, session_id),
|
||||
_ => Err(format!("Unsupported provider: {provider_id}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_root(provider_id: &str) -> Result<PathBuf, String> {
|
||||
let root = match provider_id {
|
||||
"codex" => crate::codex_config::get_codex_config_dir().join("sessions"),
|
||||
"claude" => crate::config::get_claude_config_dir().join("projects"),
|
||||
"opencode" => opencode::get_opencode_data_dir(),
|
||||
"openclaw" => crate::openclaw_config::get_openclaw_dir().join("agents"),
|
||||
"gemini" => crate::gemini_config::get_gemini_dir().join("tmp"),
|
||||
_ => return Err(format!("Unsupported provider: {provider_id}")),
|
||||
};
|
||||
|
||||
Ok(root)
|
||||
}
|
||||
|
||||
fn canonicalize_existing_path(path: &Path, label: &str) -> Result<PathBuf, String> {
|
||||
if !path.exists() {
|
||||
return Err(format!("{label} not found: {}", path.display()));
|
||||
}
|
||||
|
||||
path.canonicalize()
|
||||
.map_err(|e| format!("Failed to resolve {label} {}: {e}", path.display()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn rejects_source_path_outside_provider_root() {
|
||||
let root = tempdir().expect("tempdir");
|
||||
let outside = tempdir().expect("tempdir");
|
||||
let source = outside.path().join("session.jsonl");
|
||||
std::fs::write(&source, "{}").expect("write source");
|
||||
|
||||
let err = delete_session_with_root("codex", "session-1", &source, root.path())
|
||||
.expect_err("expected outside-root path to be rejected");
|
||||
|
||||
assert!(err.contains("outside provider root"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_source_path() {
|
||||
let root = tempdir().expect("tempdir");
|
||||
let missing = root.path().join("missing.jsonl");
|
||||
|
||||
let err = delete_session_with_root("codex", "session-1", &missing, root.path())
|
||||
.expect_err("expected missing source path to fail");
|
||||
|
||||
assert!(err.contains("session source not found"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,41 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
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 Claude session metadata: {}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
if meta.session_id != session_id {
|
||||
return Err(format!(
|
||||
"Claude session ID mismatch: expected {session_id}, found {}",
|
||||
meta.session_id
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(stem) = path.file_stem() {
|
||||
let sibling = path.parent().unwrap_or_else(|| Path::new("")).join(stem);
|
||||
remove_path_if_exists(&sibling).map_err(|e| {
|
||||
format!(
|
||||
"Failed to delete Claude session sidecar {}: {e}",
|
||||
sibling.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
std::fs::remove_file(path).map_err(|e| {
|
||||
format!(
|
||||
"Failed to delete Claude session file {}: {e}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
if is_agent_session(path) {
|
||||
return None;
|
||||
@@ -187,3 +222,50 @@ fn collect_jsonl_files(root: &Path, files: &mut Vec<PathBuf>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_path_if_exists(path: &Path) -> std::io::Result<()> {
|
||||
match std::fs::metadata(path) {
|
||||
Ok(meta) => {
|
||||
if meta.is_dir() {
|
||||
std::fs::remove_dir_all(path)
|
||||
} else {
|
||||
std::fs::remove_file(path)
|
||||
}
|
||||
}
|
||||
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_main_file_and_sidecar_directory() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("abc123-session.jsonl");
|
||||
let sidecar = temp.path().join("abc123-session");
|
||||
let subagents = sidecar.join("subagents");
|
||||
let tool_results = sidecar.join("tool-results");
|
||||
|
||||
std::fs::create_dir_all(&subagents).expect("create subagents");
|
||||
std::fs::create_dir_all(&tool_results).expect("create tool-results");
|
||||
std::fs::write(subagents.join("agent-1.jsonl"), "{}").expect("write subagent");
|
||||
std::fs::write(tool_results.join("tool-1.txt"), "result").expect("write tool result");
|
||||
std::fs::write(
|
||||
&path,
|
||||
concat!(
|
||||
"{\"sessionId\":\"session-123\",\"cwd\":\"/tmp/project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
|
||||
"{\"message\":{\"role\":\"user\",\"content\":\"hello\"},\"timestamp\":\"2026-03-06T10:01:00Z\"}\n"
|
||||
),
|
||||
)
|
||||
.expect("write session");
|
||||
|
||||
delete_session(temp.path(), &path, "session-123").expect("delete session");
|
||||
|
||||
assert!(!path.exists());
|
||||
assert!(!sidecar.exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,27 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
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 Codex session metadata: {}", path.display()))?;
|
||||
|
||||
if meta.session_id != session_id {
|
||||
return Err(format!(
|
||||
"Codex session ID mismatch: expected {session_id}, found {}",
|
||||
meta.session_id
|
||||
));
|
||||
}
|
||||
|
||||
std::fs::remove_file(path).map_err(|e| {
|
||||
format!(
|
||||
"Failed to delete Codex session file {}: {e}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?;
|
||||
|
||||
@@ -192,3 +213,30 @@ fn collect_jsonl_files(root: &Path, files: &mut Vec<PathBuf>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn delete_session_removes_jsonl_file() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp
|
||||
.path()
|
||||
.join("rollout-2026-03-06T21-50-12-019cc369-bd7c-7891-b371-7b20b4fe0b18.jsonl");
|
||||
std::fs::write(
|
||||
&path,
|
||||
concat!(
|
||||
"{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"019cc369-bd7c-7891-b371-7b20b4fe0b18\",\"cwd\":\"/tmp/project\"}}\n",
|
||||
"{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"hello\"}}\n"
|
||||
),
|
||||
)
|
||||
.expect("write session");
|
||||
|
||||
delete_session(temp.path(), &path, "019cc369-bd7c-7891-b371-7b20b4fe0b18")
|
||||
.expect("delete session");
|
||||
|
||||
assert!(!path.exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,31 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||
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()?;
|
||||
@@ -115,3 +140,36 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@ use std::path::Path;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::openclaw_config::get_openclaw_dir;
|
||||
use crate::session_manager::{SessionMessage, SessionMeta};
|
||||
use crate::{
|
||||
config::write_json_file,
|
||||
session_manager::{SessionMessage, SessionMeta},
|
||||
};
|
||||
|
||||
use super::utils::{
|
||||
extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary,
|
||||
@@ -115,6 +118,37 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
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 OpenClaw session metadata: {}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
if meta.session_id != session_id {
|
||||
return Err(format!(
|
||||
"OpenClaw session ID mismatch: expected {session_id}, found {}",
|
||||
meta.session_id
|
||||
));
|
||||
}
|
||||
|
||||
let index_path = path
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new(""))
|
||||
.join("sessions.json");
|
||||
prune_sessions_index(&index_path, session_id, path)?;
|
||||
|
||||
std::fs::remove_file(path).map_err(|e| {
|
||||
format!(
|
||||
"Failed to delete OpenClaw session file {}: {e}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?;
|
||||
|
||||
@@ -206,3 +240,92 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
resume_command: None, // OpenClaw sessions are gateway-managed, no CLI resume
|
||||
})
|
||||
}
|
||||
|
||||
fn prune_sessions_index(
|
||||
index_path: &Path,
|
||||
session_id: &str,
|
||||
source_path: &Path,
|
||||
) -> Result<(), String> {
|
||||
if !index_path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(index_path).map_err(|e| {
|
||||
format!(
|
||||
"Failed to read OpenClaw sessions index {}: {e}",
|
||||
index_path.display()
|
||||
)
|
||||
})?;
|
||||
let mut index: serde_json::Map<String, Value> =
|
||||
serde_json::from_str(&content).map_err(|e| {
|
||||
format!(
|
||||
"Failed to parse OpenClaw sessions index {}: {e}",
|
||||
index_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
let source = source_path.to_string_lossy();
|
||||
index.retain(|_, entry| {
|
||||
let same_id = entry.get("sessionId").and_then(Value::as_str) == Some(session_id);
|
||||
let same_file = entry.get("sessionFile").and_then(Value::as_str) == Some(source.as_ref());
|
||||
!(same_id || same_file)
|
||||
});
|
||||
|
||||
write_json_file(index_path, &index).map_err(|e| {
|
||||
format!(
|
||||
"Failed to update OpenClaw sessions index {}: {e}",
|
||||
index_path.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn delete_session_updates_index_and_removes_jsonl() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let sessions_dir = temp.path().join("main").join("sessions");
|
||||
std::fs::create_dir_all(&sessions_dir).expect("create sessions dir");
|
||||
|
||||
let session_path = sessions_dir.join("session-123.jsonl");
|
||||
std::fs::write(
|
||||
&session_path,
|
||||
concat!(
|
||||
"{\"type\":\"session\",\"id\":\"session-123\",\"cwd\":\"/tmp/project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
|
||||
"{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"hello\"},\"timestamp\":\"2026-03-06T10:01:00Z\"}\n"
|
||||
),
|
||||
)
|
||||
.expect("write session");
|
||||
std::fs::write(
|
||||
sessions_dir.join("sessions.json"),
|
||||
format!(
|
||||
r#"{{
|
||||
"agent:main:main": {{
|
||||
"sessionId": "session-123",
|
||||
"sessionFile": "{}"
|
||||
}},
|
||||
"agent:main:other": {{
|
||||
"sessionId": "session-456",
|
||||
"sessionFile": "{}/session-456.jsonl"
|
||||
}}
|
||||
}}"#,
|
||||
session_path.display(),
|
||||
sessions_dir.display()
|
||||
),
|
||||
)
|
||||
.expect("write index");
|
||||
|
||||
delete_session(temp.path(), &session_path, "session-123").expect("delete session");
|
||||
|
||||
assert!(!session_path.exists());
|
||||
let updated: serde_json::Value = serde_json::from_str(
|
||||
&std::fs::read_to_string(sessions_dir.join("sessions.json")).expect("read index"),
|
||||
)
|
||||
.expect("parse index");
|
||||
assert!(updated.get("agent:main:main").is_none());
|
||||
assert!(updated.get("agent:main:other").is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ const PROVIDER_ID: &str = "opencode";
|
||||
///
|
||||
/// Respects `XDG_DATA_HOME` on all platforms; falls back to
|
||||
/// `~/.local/share/opencode/storage/`.
|
||||
fn get_opencode_data_dir() -> PathBuf {
|
||||
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");
|
||||
@@ -111,6 +111,71 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||
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()?;
|
||||
@@ -274,3 +339,97 @@ fn collect_json_files(root: &Path, files: &mut Vec<PathBuf>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,19 @@ import {
|
||||
RefreshCw,
|
||||
Search,
|
||||
Play,
|
||||
Trash2,
|
||||
MessageSquare,
|
||||
Clock,
|
||||
FolderOpen,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useSessionMessagesQuery, useSessionsQuery } from "@/lib/query";
|
||||
import {
|
||||
useDeleteSessionMutation,
|
||||
useSessionMessagesQuery,
|
||||
useSessionsQuery,
|
||||
} from "@/lib/query";
|
||||
import { sessionsApi } from "@/lib/api";
|
||||
import type { SessionMeta } from "@/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -25,6 +31,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -66,6 +73,7 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
);
|
||||
const [tocDialogOpen, setTocDialogOpen] = useState(false);
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<SessionMeta | null>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -113,6 +121,7 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
selectedSession?.providerId,
|
||||
selectedSession?.sourcePath,
|
||||
);
|
||||
const deleteSessionMutation = useDeleteSessionMutation();
|
||||
|
||||
// 提取用户消息用于目录
|
||||
const userMessagesToc = useMemo(() => {
|
||||
@@ -184,6 +193,19 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteTarget?.sourcePath || deleteSessionMutation.isPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleteTarget(null);
|
||||
await deleteSessionMutation.mutateAsync({
|
||||
providerId: deleteTarget.providerId,
|
||||
sessionId: deleteTarget.sessionId,
|
||||
sourcePath: deleteTarget.sourcePath,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="mx-auto px-4 sm:px-6 flex flex-col h-[calc(100vh-8rem)]">
|
||||
@@ -517,6 +539,36 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="gap-1.5"
|
||||
onClick={() => setDeleteTarget(selectedSession)}
|
||||
disabled={
|
||||
!selectedSession.sourcePath ||
|
||||
deleteSessionMutation.isPending
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
<span className="hidden sm:inline">
|
||||
{deleteSessionMutation.isPending
|
||||
? t("sessionManager.deleting", {
|
||||
defaultValue: "删除中...",
|
||||
})
|
||||
: t("sessionManager.delete", {
|
||||
defaultValue: "删除会话",
|
||||
})}
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("sessionManager.deleteTooltip", {
|
||||
defaultValue: "永久删除此本地会话记录",
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -629,6 +681,33 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
isOpen={Boolean(deleteTarget)}
|
||||
title={t("sessionManager.deleteConfirmTitle", {
|
||||
defaultValue: "删除会话",
|
||||
})}
|
||||
message={
|
||||
deleteTarget
|
||||
? t("sessionManager.deleteConfirmMessage", {
|
||||
defaultValue:
|
||||
"将永久删除本地会话“{{title}}”\nSession ID: {{sessionId}}\n\n此操作不可恢复。",
|
||||
title: formatSessionTitle(deleteTarget),
|
||||
sessionId: deleteTarget.sessionId,
|
||||
})
|
||||
: ""
|
||||
}
|
||||
confirmText={t("sessionManager.deleteConfirmAction", {
|
||||
defaultValue: "删除会话",
|
||||
})}
|
||||
cancelText={t("common.cancel", { defaultValue: "取消" })}
|
||||
variant="destructive"
|
||||
onConfirm={() => void handleDeleteConfirm()}
|
||||
onCancel={() => {
|
||||
if (!deleteSessionMutation.isPending) {
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -604,6 +604,14 @@
|
||||
"projectDirCopied": "Directory copied",
|
||||
"copySourcePath": "Copy source file",
|
||||
"sourcePathCopied": "Source file copied",
|
||||
"delete": "Delete session",
|
||||
"deleting": "Deleting...",
|
||||
"deleteTooltip": "Permanently delete this local session record",
|
||||
"deleteConfirmTitle": "Delete session",
|
||||
"deleteConfirmMessage": "This will permanently delete the local session \"{{title}}\"\nSession ID: {{sessionId}}\n\nThis action cannot be undone.",
|
||||
"deleteConfirmAction": "Delete session",
|
||||
"sessionDeleted": "Session deleted",
|
||||
"deleteFailed": "Failed to delete session: {{error}}",
|
||||
"loadingMessages": "Loading transcript...",
|
||||
"emptySession": "No messages available",
|
||||
"clickToCopyPath": "Click to copy path",
|
||||
|
||||
@@ -604,6 +604,14 @@
|
||||
"projectDirCopied": "ディレクトリをコピーしました",
|
||||
"copySourcePath": "元ファイルをコピー",
|
||||
"sourcePathCopied": "元ファイルをコピーしました",
|
||||
"delete": "セッションを削除",
|
||||
"deleting": "削除中...",
|
||||
"deleteTooltip": "このローカルセッション記録を完全に削除します",
|
||||
"deleteConfirmTitle": "セッションを削除",
|
||||
"deleteConfirmMessage": "ローカルセッション「{{title}}」を完全に削除します\nSession ID: {{sessionId}}\n\nこの操作は元に戻せません。",
|
||||
"deleteConfirmAction": "セッションを削除",
|
||||
"sessionDeleted": "セッションを削除しました",
|
||||
"deleteFailed": "セッションの削除に失敗しました: {{error}}",
|
||||
"loadingMessages": "内容を読み込み中...",
|
||||
"emptySession": "表示できる内容がありません",
|
||||
"clickToCopyPath": "クリックしてパスをコピー",
|
||||
|
||||
@@ -604,6 +604,14 @@
|
||||
"projectDirCopied": "目录已复制",
|
||||
"copySourcePath": "复制原始文件",
|
||||
"sourcePathCopied": "原始文件已复制",
|
||||
"delete": "删除会话",
|
||||
"deleting": "删除中...",
|
||||
"deleteTooltip": "永久删除此本地会话记录",
|
||||
"deleteConfirmTitle": "删除会话",
|
||||
"deleteConfirmMessage": "将永久删除本地会话“{{title}}”\nSession ID: {{sessionId}}\n\n此操作不可恢复。",
|
||||
"deleteConfirmAction": "删除会话",
|
||||
"sessionDeleted": "会话已删除",
|
||||
"deleteFailed": "删除会话失败: {{error}}",
|
||||
"loadingMessages": "加载会话内容中...",
|
||||
"emptySession": "该会话暂无可展示内容",
|
||||
"clickToCopyPath": "点击复制路径",
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { SessionMessage, SessionMeta } from "@/types";
|
||||
|
||||
export interface DeleteSessionOptions {
|
||||
providerId: string;
|
||||
sessionId: string;
|
||||
sourcePath: string;
|
||||
}
|
||||
|
||||
export const sessionsApi = {
|
||||
async list(): Promise<SessionMeta[]> {
|
||||
return await invoke("list_sessions");
|
||||
@@ -13,6 +19,15 @@ export const sessionsApi = {
|
||||
return await invoke("get_session_messages", { providerId, sourcePath });
|
||||
},
|
||||
|
||||
async delete(options: DeleteSessionOptions): Promise<boolean> {
|
||||
const { providerId, sessionId, sourcePath } = options;
|
||||
return await invoke("delete_session", {
|
||||
providerId,
|
||||
sessionId,
|
||||
sourcePath,
|
||||
});
|
||||
},
|
||||
|
||||
async launchTerminal(options: {
|
||||
command: string;
|
||||
cwd?: string | null;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { providersApi, settingsApi, type AppId } from "@/lib/api";
|
||||
import { providersApi, sessionsApi, settingsApi, type AppId } from "@/lib/api";
|
||||
import type { DeleteSessionOptions } from "@/lib/api/sessions";
|
||||
import type { SwitchResult } from "@/lib/api/providers";
|
||||
import type { Provider, Settings } from "@/types";
|
||||
import type { Provider, SessionMeta, Settings } from "@/types";
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
import { generateUUID } from "@/utils/uuid";
|
||||
import { openclawKeys } from "@/hooks/useOpenClaw";
|
||||
@@ -267,6 +268,50 @@ export const useSwitchProviderMutation = (appId: AppId) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteSessionMutation = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (input: DeleteSessionOptions) => {
|
||||
await sessionsApi.delete(input);
|
||||
return input;
|
||||
},
|
||||
onSuccess: async (input) => {
|
||||
queryClient.setQueryData<SessionMeta[]>(["sessions"], (current) =>
|
||||
(current ?? []).filter(
|
||||
(session) =>
|
||||
!(
|
||||
session.providerId === input.providerId &&
|
||||
session.sessionId === input.sessionId &&
|
||||
session.sourcePath === input.sourcePath
|
||||
),
|
||||
),
|
||||
);
|
||||
queryClient.removeQueries({
|
||||
queryKey: ["sessionMessages", input.providerId, input.sourcePath],
|
||||
});
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ["sessions"] });
|
||||
|
||||
toast.success(
|
||||
t("sessionManager.sessionDeleted", {
|
||||
defaultValue: "会话已删除",
|
||||
}),
|
||||
);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
const detail = extractErrorMessage(error) || t("common.unknown");
|
||||
toast.error(
|
||||
t("sessionManager.deleteFailed", {
|
||||
defaultValue: "删除会话失败: {{error}}",
|
||||
error: detail,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useSaveSettingsMutation = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
||||
140
tests/components/SessionManagerPage.test.tsx
Normal file
140
tests/components/SessionManagerPage.test.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SessionManagerPage } from "@/components/sessions/SessionManagerPage";
|
||||
import type { SessionMessage, SessionMeta } from "@/types";
|
||||
import { setSessionFixtures } from "../msw/state";
|
||||
|
||||
const toastSuccessMock = vi.fn();
|
||||
const toastErrorMock = vi.fn();
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
success: (...args: unknown[]) => toastSuccessMock(...args),
|
||||
error: (...args: unknown[]) => toastErrorMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/sessions/SessionToc", () => ({
|
||||
SessionTocSidebar: () => null,
|
||||
SessionTocDialog: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ConfirmDialog", () => ({
|
||||
ConfirmDialog: ({
|
||||
isOpen,
|
||||
title,
|
||||
message,
|
||||
confirmText,
|
||||
cancelText,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText: string;
|
||||
cancelText: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) =>
|
||||
isOpen ? (
|
||||
<div data-testid="confirm-dialog">
|
||||
<div>{title}</div>
|
||||
<div>{message}</div>
|
||||
<button onClick={onConfirm}>{confirmText}</button>
|
||||
<button onClick={onCancel}>{cancelText}</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
const renderPage = () => {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<SessionManagerPage appId="codex" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe("SessionManagerPage", () => {
|
||||
beforeEach(() => {
|
||||
toastSuccessMock.mockReset();
|
||||
toastErrorMock.mockReset();
|
||||
|
||||
const sessions: SessionMeta[] = [
|
||||
{
|
||||
providerId: "codex",
|
||||
sessionId: "codex-session-1",
|
||||
title: "Alpha Session",
|
||||
summary: "Alpha summary",
|
||||
projectDir: "/mock/codex",
|
||||
createdAt: 2,
|
||||
lastActiveAt: 20,
|
||||
sourcePath: "/mock/codex/session-1.jsonl",
|
||||
resumeCommand: "codex resume codex-session-1",
|
||||
},
|
||||
{
|
||||
providerId: "codex",
|
||||
sessionId: "codex-session-2",
|
||||
title: "Beta Session",
|
||||
summary: "Beta summary",
|
||||
projectDir: "/mock/codex",
|
||||
createdAt: 1,
|
||||
lastActiveAt: 10,
|
||||
sourcePath: "/mock/codex/session-2.jsonl",
|
||||
resumeCommand: "codex resume codex-session-2",
|
||||
},
|
||||
];
|
||||
const messages: Record<string, SessionMessage[]> = {
|
||||
"codex:/mock/codex/session-1.jsonl": [
|
||||
{ role: "user", content: "alpha", ts: 20 },
|
||||
],
|
||||
"codex:/mock/codex/session-2.jsonl": [
|
||||
{ role: "user", content: "beta", ts: 10 },
|
||||
],
|
||||
};
|
||||
|
||||
setSessionFixtures(sessions, messages);
|
||||
});
|
||||
|
||||
it("deletes the selected session and selects the next visible session", async () => {
|
||||
renderPage();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Alpha Session" }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /删除会话/i }));
|
||||
|
||||
const dialog = screen.getByTestId("confirm-dialog");
|
||||
expect(dialog).toBeInTheDocument();
|
||||
expect(within(dialog).getByText(/Alpha Session/)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: /删除会话/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Beta Session" }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Alpha Session")).not.toBeInTheDocument();
|
||||
expect(toastErrorMock).not.toHaveBeenCalled();
|
||||
expect(toastSuccessMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -4,9 +4,12 @@ import type { McpServer, Provider, Settings } from "@/types";
|
||||
import {
|
||||
addProvider,
|
||||
deleteProvider,
|
||||
deleteSession,
|
||||
getCurrentProviderId,
|
||||
getSessionMessages,
|
||||
getProviders,
|
||||
listProviders,
|
||||
listSessions,
|
||||
resetProviderState,
|
||||
setCurrentProviderId,
|
||||
updateProvider,
|
||||
@@ -37,7 +40,9 @@ const success = <T>(payload: T) => HttpResponse.json(payload as any);
|
||||
|
||||
export const handlers = [
|
||||
http.post(`${TAURI_ENDPOINT}/get_migration_result`, () => success(false)),
|
||||
http.post(`${TAURI_ENDPOINT}/get_skills_migration_result`, () => success(null)),
|
||||
http.post(`${TAURI_ENDPOINT}/get_skills_migration_result`, () =>
|
||||
success(null),
|
||||
),
|
||||
http.post(`${TAURI_ENDPOINT}/get_providers`, async ({ request }) => {
|
||||
const { app } = await withJson<{ app: AppId }>(request);
|
||||
return success(getProviders(app));
|
||||
@@ -105,6 +110,25 @@ export const handlers = [
|
||||
|
||||
http.post(`${TAURI_ENDPOINT}/open_external`, () => success(true)),
|
||||
|
||||
http.post(`${TAURI_ENDPOINT}/list_sessions`, () => success(listSessions())),
|
||||
|
||||
http.post(`${TAURI_ENDPOINT}/get_session_messages`, async ({ request }) => {
|
||||
const { providerId, sourcePath } = await withJson<{
|
||||
providerId: string;
|
||||
sourcePath: string;
|
||||
}>(request);
|
||||
return success(getSessionMessages(providerId, sourcePath));
|
||||
}),
|
||||
|
||||
http.post(`${TAURI_ENDPOINT}/delete_session`, async ({ request }) => {
|
||||
const { providerId, sessionId, sourcePath } = await withJson<{
|
||||
providerId: string;
|
||||
sessionId: string;
|
||||
sourcePath: string;
|
||||
}>(request);
|
||||
return success(deleteSession(providerId, sessionId, sourcePath));
|
||||
}),
|
||||
|
||||
// MCP APIs
|
||||
http.post(`${TAURI_ENDPOINT}/get_mcp_config`, async ({ request }) => {
|
||||
const { app } = await withJson<{ app: AppId }>(request);
|
||||
@@ -178,9 +202,13 @@ export const handlers = [
|
||||
},
|
||||
),
|
||||
|
||||
http.post(`${TAURI_ENDPOINT}/apply_claude_onboarding_skip`, () => success(true)),
|
||||
http.post(`${TAURI_ENDPOINT}/apply_claude_onboarding_skip`, () =>
|
||||
success(true),
|
||||
),
|
||||
|
||||
http.post(`${TAURI_ENDPOINT}/clear_claude_onboarding_skip`, () => success(true)),
|
||||
http.post(`${TAURI_ENDPOINT}/clear_claude_onboarding_skip`, () =>
|
||||
success(true),
|
||||
),
|
||||
|
||||
http.post(`${TAURI_ENDPOINT}/get_config_dir`, async ({ request }) => {
|
||||
const { app } = await withJson<{ app: AppId }>(request);
|
||||
@@ -280,7 +308,9 @@ export const handlers = [
|
||||
success([]),
|
||||
),
|
||||
http.post(`${TAURI_ENDPOINT}/add_to_failover_queue`, () => success(true)),
|
||||
http.post(`${TAURI_ENDPOINT}/remove_from_failover_queue`, () => success(true)),
|
||||
http.post(`${TAURI_ENDPOINT}/remove_from_failover_queue`, () =>
|
||||
success(true),
|
||||
),
|
||||
http.post(`${TAURI_ENDPOINT}/reorder_failover_queue`, () => success(true)),
|
||||
http.post(`${TAURI_ENDPOINT}/set_failover_item_enabled`, () => success(true)),
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { AppId } from "@/lib/api/types";
|
||||
import type { McpServer, Provider, Settings } from "@/types";
|
||||
import type {
|
||||
McpServer,
|
||||
Provider,
|
||||
SessionMessage,
|
||||
SessionMeta,
|
||||
Settings,
|
||||
} from "@/types";
|
||||
|
||||
type ProvidersByApp = Record<AppId, Record<string, Provider>>;
|
||||
type CurrentProviderState = Record<AppId, string>;
|
||||
@@ -80,13 +86,69 @@ let settingsState: Settings = {
|
||||
language: "zh",
|
||||
};
|
||||
let appConfigDirOverride: string | null = null;
|
||||
const sessionMessageKey = (providerId: string, sourcePath: string) =>
|
||||
`${providerId}:${sourcePath}`;
|
||||
|
||||
const createDefaultSessions = (): SessionMeta[] => {
|
||||
const now = Date.now();
|
||||
return [
|
||||
{
|
||||
providerId: "codex",
|
||||
sessionId: "codex-session-1",
|
||||
title: "Codex Session One",
|
||||
summary: "Codex summary",
|
||||
projectDir: "/mock/codex",
|
||||
createdAt: now - 2000,
|
||||
lastActiveAt: now - 1000,
|
||||
sourcePath: "/mock/codex/session-1.jsonl",
|
||||
resumeCommand: "codex resume codex-session-1",
|
||||
},
|
||||
{
|
||||
providerId: "claude",
|
||||
sessionId: "claude-session-1",
|
||||
title: "Claude Session One",
|
||||
summary: "Claude summary",
|
||||
projectDir: "/mock/claude",
|
||||
createdAt: now - 4000,
|
||||
lastActiveAt: now - 3000,
|
||||
sourcePath: "/mock/claude/session-1.jsonl",
|
||||
resumeCommand: "claude --resume claude-session-1",
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const createDefaultSessionMessages = (): Record<string, SessionMessage[]> => ({
|
||||
[sessionMessageKey("codex", "/mock/codex/session-1.jsonl")]: [
|
||||
{
|
||||
role: "user",
|
||||
content: "First codex message",
|
||||
ts: Date.now() - 1000,
|
||||
},
|
||||
],
|
||||
[sessionMessageKey("claude", "/mock/claude/session-1.jsonl")]: [
|
||||
{
|
||||
role: "user",
|
||||
content: "First claude message",
|
||||
ts: Date.now() - 3000,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let sessionsState = createDefaultSessions();
|
||||
let sessionMessagesState = createDefaultSessionMessages();
|
||||
let mcpConfigs: McpConfigState = {
|
||||
claude: {
|
||||
sample: {
|
||||
id: "sample",
|
||||
name: "Sample Claude Server",
|
||||
enabled: true,
|
||||
apps: { claude: true, codex: false, gemini: false, opencode: false, openclaw: false },
|
||||
apps: {
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
openclaw: false,
|
||||
},
|
||||
server: {
|
||||
type: "stdio",
|
||||
command: "claude-server",
|
||||
@@ -98,7 +160,13 @@ let mcpConfigs: McpConfigState = {
|
||||
id: "httpServer",
|
||||
name: "HTTP Codex Server",
|
||||
enabled: false,
|
||||
apps: { claude: false, codex: true, gemini: false, opencode: false, openclaw: false },
|
||||
apps: {
|
||||
claude: false,
|
||||
codex: true,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
openclaw: false,
|
||||
},
|
||||
server: {
|
||||
type: "http",
|
||||
url: "http://localhost:3000",
|
||||
@@ -116,6 +184,8 @@ const cloneProviders = (value: ProvidersByApp) =>
|
||||
export const resetProviderState = () => {
|
||||
providers = createDefaultProviders();
|
||||
current = createDefaultCurrent();
|
||||
sessionsState = createDefaultSessions();
|
||||
sessionMessagesState = createDefaultSessionMessages();
|
||||
settingsState = {
|
||||
showInTray: true,
|
||||
minimizeToTrayOnClose: true,
|
||||
@@ -131,7 +201,13 @@ export const resetProviderState = () => {
|
||||
id: "sample",
|
||||
name: "Sample Claude Server",
|
||||
enabled: true,
|
||||
apps: { claude: true, codex: false, gemini: false, opencode: false, openclaw: false },
|
||||
apps: {
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
openclaw: false,
|
||||
},
|
||||
server: {
|
||||
type: "stdio",
|
||||
command: "claude-server",
|
||||
@@ -143,7 +219,13 @@ export const resetProviderState = () => {
|
||||
id: "httpServer",
|
||||
name: "HTTP Codex Server",
|
||||
enabled: false,
|
||||
apps: { claude: false, codex: true, gemini: false, opencode: false, openclaw: false },
|
||||
apps: {
|
||||
claude: false,
|
||||
codex: true,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
openclaw: false,
|
||||
},
|
||||
server: {
|
||||
type: "http",
|
||||
url: "http://localhost:3000",
|
||||
@@ -285,3 +367,41 @@ export const deleteMcpServer = (appType: AppId, id: string) => {
|
||||
if (!mcpConfigs[appType]) return;
|
||||
delete mcpConfigs[appType][id];
|
||||
};
|
||||
|
||||
export const listSessions = () =>
|
||||
JSON.parse(JSON.stringify(sessionsState)) as SessionMeta[];
|
||||
|
||||
export const getSessionMessages = (providerId: string, sourcePath: string) =>
|
||||
JSON.parse(
|
||||
JSON.stringify(
|
||||
sessionMessagesState[sessionMessageKey(providerId, sourcePath)] ?? [],
|
||||
),
|
||||
) as SessionMessage[];
|
||||
|
||||
export const deleteSession = (
|
||||
providerId: string,
|
||||
sessionId: string,
|
||||
sourcePath: string,
|
||||
) => {
|
||||
sessionsState = sessionsState.filter(
|
||||
(session) =>
|
||||
!(
|
||||
session.providerId === providerId &&
|
||||
session.sessionId === sessionId &&
|
||||
session.sourcePath === sourcePath
|
||||
),
|
||||
);
|
||||
delete sessionMessagesState[sessionMessageKey(providerId, sourcePath)];
|
||||
return true;
|
||||
};
|
||||
|
||||
export const setSessionFixtures = (
|
||||
sessions: SessionMeta[],
|
||||
messages: Record<string, SessionMessage[]>,
|
||||
) => {
|
||||
sessionsState = JSON.parse(JSON.stringify(sessions)) as SessionMeta[];
|
||||
sessionMessagesState = JSON.parse(JSON.stringify(messages)) as Record<
|
||||
string,
|
||||
SessionMessage[]
|
||||
>;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user