mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-05-14 16:29:39 +08:00
feat(sessions): add session browsing for Gemini CLI
This commit is contained in:
@@ -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<SessionMeta> {
|
||||
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<Vec<Session
|
||||
"claude" => 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}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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)
|
||||
}
|
||||
|
||||
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}")),
|
||||
})
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod claude;
|
||||
pub mod codex;
|
||||
pub mod gemini;
|
||||
pub mod openclaw;
|
||||
pub mod opencode;
|
||||
mod utils;
|
||||
|
||||
+4
-2
@@ -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,
|
||||
|
||||
@@ -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 }) {
|
||||
<span>OpenClaw</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="gemini">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="gemini"
|
||||
name="gemini"
|
||||
size={14}
|
||||
/>
|
||||
<span>Gemini CLI</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -515,7 +515,7 @@
|
||||
},
|
||||
"sessionManager": {
|
||||
"title": "セッション管理",
|
||||
"subtitle": "Claude Code / Codex / OpenCode / OpenClaw のセッションを管理",
|
||||
"subtitle": "Claude Code / Codex / OpenCode / OpenClaw / Gemini CLI のセッションを管理",
|
||||
"searchPlaceholder": "内容・ディレクトリ・ID で検索",
|
||||
"searchSessions": "セッションを検索",
|
||||
"providerFilterAll": "すべて",
|
||||
|
||||
@@ -515,7 +515,7 @@
|
||||
},
|
||||
"sessionManager": {
|
||||
"title": "会话管理",
|
||||
"subtitle": "管理 Claude Code、Codex、OpenCode 与 OpenClaw 会话记录",
|
||||
"subtitle": "管理 Claude Code、Codex、OpenCode、OpenClaw 与 Gemini CLI 会话记录",
|
||||
"searchPlaceholder": "搜索会话内容、目录或 ID",
|
||||
"searchSessions": "搜索会话",
|
||||
"providerFilterAll": "全部",
|
||||
|
||||
Reference in New Issue
Block a user