diff --git a/src-tauri/src/session_manager/mod.rs b/src-tauri/src/session_manager/mod.rs index db96e424..8d833636 100644 --- a/src-tauri/src/session_manager/mod.rs +++ b/src-tauri/src/session_manager/mod.rs @@ -4,7 +4,7 @@ pub mod terminal; use serde::Serialize; use std::path::Path; -use providers::{claude, codex, openclaw, opencode}; +use providers::{claude, codex, gemini, openclaw, opencode}; #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -42,6 +42,7 @@ pub fn scan_sessions() -> Vec { sessions.extend(claude::scan_sessions()); sessions.extend(opencode::scan_sessions()); sessions.extend(openclaw::scan_sessions()); + sessions.extend(gemini::scan_sessions()); sessions.sort_by(|a, b| { let a_ts = a.last_active_at.or(a.created_at).unwrap_or(0); @@ -59,6 +60,7 @@ pub fn load_messages(provider_id: &str, source_path: &str) -> Result claude::load_messages(path), "opencode" => opencode::load_messages(path), "openclaw" => openclaw::load_messages(path), + "gemini" => gemini::load_messages(path), _ => Err(format!("Unsupported provider: {provider_id}")), } } diff --git a/src-tauri/src/session_manager/providers/gemini.rs b/src-tauri/src/session_manager/providers/gemini.rs new file mode 100644 index 00000000..9487ec87 --- /dev/null +++ b/src-tauri/src/session_manager/providers/gemini.rs @@ -0,0 +1,117 @@ +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 { + 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//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, 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) +} + +fn parse_session(path: &Path) -> Option { + 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}")), + }) +} diff --git a/src-tauri/src/session_manager/providers/mod.rs b/src-tauri/src/session_manager/providers/mod.rs index 0d824c39..a2902c4d 100644 --- a/src-tauri/src/session_manager/providers/mod.rs +++ b/src-tauri/src/session_manager/providers/mod.rs @@ -1,5 +1,6 @@ pub mod claude; pub mod codex; +pub mod gemini; pub mod openclaw; pub mod opencode; mod utils; diff --git a/src/App.tsx b/src/App.tsx index 548a307c..89e3c923 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -181,7 +181,8 @@ function App() { activeApp !== "claude" && activeApp !== "codex" && activeApp !== "opencode" && - activeApp !== "openclaw" + activeApp !== "openclaw" && + activeApp !== "gemini" ) { setCurrentView("providers"); } @@ -229,7 +230,8 @@ function App() { activeApp === "claude" || activeApp === "codex" || activeApp === "opencode" || - activeApp === "openclaw"; + activeApp === "openclaw" || + activeApp === "gemini"; const { addProvider, diff --git a/src/components/sessions/SessionManagerPage.tsx b/src/components/sessions/SessionManagerPage.tsx index 4ad02dd5..f7041a39 100644 --- a/src/components/sessions/SessionManagerPage.tsx +++ b/src/components/sessions/SessionManagerPage.tsx @@ -46,7 +46,13 @@ import { getSessionKey, } from "./utils"; -type ProviderFilter = "all" | "codex" | "claude" | "opencode" | "openclaw"; +type ProviderFilter = + | "all" + | "codex" + | "claude" + | "opencode" + | "openclaw" + | "gemini"; export function SessionManagerPage({ appId }: { appId: string }) { const { t } = useTranslation(); @@ -329,6 +335,16 @@ export function SessionManagerPage({ appId }: { appId: string }) { OpenClaw + +
+ + Gemini CLI +
+
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 670bd272..b3899828 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -515,7 +515,7 @@ }, "sessionManager": { "title": "Session Manager", - "subtitle": "Manage Claude Code, Codex, OpenCode and OpenClaw sessions", + "subtitle": "Manage Claude Code, Codex, OpenCode, OpenClaw and Gemini CLI sessions", "searchPlaceholder": "Search by content, directory, or ID", "searchSessions": "Search sessions", "providerFilterAll": "All", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index c2068765..427f1326 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -515,7 +515,7 @@ }, "sessionManager": { "title": "セッション管理", - "subtitle": "Claude Code / Codex / OpenCode / OpenClaw のセッションを管理", + "subtitle": "Claude Code / Codex / OpenCode / OpenClaw / Gemini CLI のセッションを管理", "searchPlaceholder": "内容・ディレクトリ・ID で検索", "searchSessions": "セッションを検索", "providerFilterAll": "すべて", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index e2aa15ad..8d25fedc 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -515,7 +515,7 @@ }, "sessionManager": { "title": "会话管理", - "subtitle": "管理 Claude Code、Codex、OpenCode 与 OpenClaw 会话记录", + "subtitle": "管理 Claude Code、Codex、OpenCode、OpenClaw 与 Gemini CLI 会话记录", "searchPlaceholder": "搜索会话内容、目录或 ID", "searchSessions": "搜索会话", "providerFilterAll": "全部",