Skip to content

Commit 1e2e221

Browse files
committed
feat: implement project session loading and caching functionality, enhancing conversation management in the ConversationList component
1 parent ec37924 commit 1e2e221

File tree

7 files changed

+308
-254
lines changed

7 files changed

+308
-254
lines changed

src-tauri/src/lib.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ use commands::{
1818
use crate::config::provider::ensure_default_providers;
1919
use session_files::{
2020
delete::{delete_session_file, delete_sessions_files},
21-
save::get_project_sessions,
22-
scan::scan_projects,
21+
cache::{load_project_sessions, write_project_cache},
22+
scanner::scan_projects,
2323
update::update_cache_title,
2424
};
2525
use terminal::open_terminal_with_command;
@@ -107,11 +107,12 @@ pub fn run() {
107107
cmd::respond_exec_command_request,
108108
cmd::delete_file,
109109
scan_projects,
110-
get_project_sessions,
110+
load_project_sessions,
111111
delete_session_file,
112112
update_cache_title,
113113
open_terminal_with_command,
114114
delete_sessions_files,
115+
write_project_cache,
115116
])
116117
.setup(|_app| {
117118
#[cfg(debug_assertions)]
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
use super::get::get_cache_path_for_project;
2+
use super::scanner::scan_sessions_after;
3+
use chrono::{DateTime, Utc};
4+
use serde::{Deserialize, Serialize};
5+
use serde_json::{json, Value};
6+
use std::fs::{read_to_string, File};
7+
use std::io::Write;
8+
9+
/// Structure stored in cache file
10+
#[derive(Serialize, Deserialize)]
11+
#[serde(rename_all = "camelCase")]
12+
struct ProjectCache {
13+
last_scanned: String,
14+
sessions: Vec<Value>,
15+
favorites: Vec<String>,
16+
}
17+
18+
/// Read cache file for a project
19+
fn read_project_cache(project_path: &str) -> Result<Option<(DateTime<Utc>, Vec<Value>, Vec<String>)>, String> {
20+
let cache_path = get_cache_path_for_project(project_path)?;
21+
if !cache_path.exists() {
22+
return Ok(None);
23+
}
24+
25+
let cache_str =
26+
read_to_string(&cache_path).map_err(|e| format!("Failed to read cache: {}", e))?;
27+
28+
let cache: ProjectCache = match serde_json::from_str(&cache_str) {
29+
Ok(v) => v,
30+
Err(e) => {
31+
eprintln!("Cache parse failed ({}), fallback to full scan.", e);
32+
return Ok(None); // fallback gracefully
33+
}
34+
};
35+
36+
let last_scanned = DateTime::parse_from_rfc3339(&cache.last_scanned)
37+
.ok()
38+
.map(|dt| dt.with_timezone(&Utc));
39+
40+
match last_scanned {
41+
Some(dt) => Ok(Some((dt, cache.sessions, cache.favorites))),
42+
None => Ok(None),
43+
}
44+
}
45+
46+
/// Write updated cache to disk
47+
#[tauri::command]
48+
pub fn write_project_cache(project_path: String, sessions: Vec<Value>, favorites: Vec<String>) -> Result<(), String> {
49+
let cache_path = get_cache_path_for_project(&project_path)?;
50+
let data = ProjectCache {
51+
last_scanned: Utc::now().to_rfc3339(),
52+
sessions,
53+
favorites,
54+
};
55+
56+
let json_str =
57+
serde_json::to_string_pretty(&data).map_err(|e| format!("Failed to serialize cache: {}", e))?;
58+
let mut file =
59+
File::create(&cache_path).map_err(|e| format!("Failed to create cache file: {}", e))?;
60+
file.write_all(json_str.as_bytes())
61+
.map_err(|e| format!("Failed to write cache file: {}", e))?;
62+
63+
Ok(())
64+
}
65+
66+
/// Main tauri command: load or refresh sessions for given project
67+
#[tauri::command]
68+
pub async fn load_project_sessions(project_path: String) -> Result<Value, String> {
69+
match read_project_cache(&project_path)? {
70+
Some((last_scanned, mut cached_sessions, favorites)) => {
71+
// Incremental scan
72+
let new_sessions = scan_sessions_after(&project_path, Some(last_scanned))?;
73+
74+
// Deduplicate by conversationId
75+
let new_ids: std::collections::HashSet<_> = new_sessions
76+
.iter()
77+
.filter_map(|s| s["conversationId"].as_str())
78+
.collect();
79+
80+
cached_sessions.retain(|s| {
81+
!new_ids.contains(s["conversationId"].as_str().unwrap_or_default())
82+
});
83+
84+
cached_sessions.extend(new_sessions);
85+
86+
// Sort newest first
87+
cached_sessions.sort_by(|a, b| {
88+
use super::utils::extract_datetime;
89+
let a_dt = extract_datetime(a["path"].as_str().unwrap_or_default());
90+
let b_dt = extract_datetime(b["path"].as_str().unwrap_or_default());
91+
match (a_dt, b_dt) {
92+
(Some(a_dt), Some(b_dt)) => b_dt.cmp(&a_dt),
93+
(Some(_), None) => std::cmp::Ordering::Less,
94+
(None, Some(_)) => std::cmp::Ordering::Greater,
95+
(None, None) => a["path"].as_str().cmp(&b["path"].as_str()),
96+
}
97+
});
98+
99+
write_project_cache(project_path.clone(), cached_sessions.clone(), favorites.clone())?;
100+
Ok(json!({ "sessions": cached_sessions, "favorites": favorites }))
101+
}
102+
None => {
103+
// No cache or broken cache → full scan
104+
let sessions = scan_sessions_after(&project_path, None)?;
105+
let favorites: Vec<String> = Vec::new();
106+
write_project_cache(project_path.clone(), sessions.clone(), favorites.clone())?;
107+
Ok(json!({ "sessions": sessions, "favorites": favorites }))
108+
}
109+
}
110+
}

src-tauri/src/session_files/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1+
pub mod cache;
12
pub mod delete;
23
pub mod file;
34
pub mod get;
4-
pub mod save;
5-
pub mod scan;
5+
pub mod scanner;
66
pub mod update;
77
pub mod utils;

src-tauri/src/session_files/save.rs

Lines changed: 0 additions & 94 deletions
This file was deleted.

src-tauri/src/session_files/scan.rs renamed to src-tauri/src/session_files/scanner.rs

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use std::collections::HashSet;
66
use std::path::Path;
77
use walkdir::WalkDir;
88

9+
/// Walk through all `.jsonl` files in sessions directory
910
pub fn scan_jsonl_files<P: AsRef<Path>>(dir_path: P) -> impl Iterator<Item = walkdir::DirEntry> {
1011
WalkDir::new(dir_path)
1112
.into_iter()
@@ -14,7 +15,8 @@ pub fn scan_jsonl_files<P: AsRef<Path>>(dir_path: P) -> impl Iterator<Item = wal
1415
.filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("jsonl"))
1516
}
1617

17-
pub fn scan_project_sessions_incremental(
18+
/// Scan sessions incrementally — only include files modified after cutoff
19+
pub fn scan_sessions_after(
1820
project_path: &str,
1921
after: Option<DateTime<Utc>>,
2022
) -> Result<Vec<Value>, String> {
@@ -24,19 +26,21 @@ pub fn scan_project_sessions_incremental(
2426
for entry in scan_jsonl_files(&sessions_dir) {
2527
let path = entry.path();
2628

27-
// Skip files that haven't been modified since last scan
29+
// Skip unmodified files
2830
if let Some(cutoff) = after {
2931
if let Ok(metadata) = std::fs::metadata(path) {
3032
if let Ok(modified) = metadata.modified() {
3133
let modified_datetime: DateTime<Utc> = modified.into();
3234
if modified_datetime <= cutoff {
33-
continue; // Skip this file
35+
continue;
3436
}
3537
}
3638
}
3739
}
3840

3941
let file_path = path.to_string_lossy().to_string();
42+
43+
// Read first line and parse JSON
4044
match read_first_line(path) {
4145
Ok(line) => {
4246
if let Ok(value) = serde_json::from_str::<Value>(&line) {
@@ -57,6 +61,7 @@ pub fn scan_project_sessions_incremental(
5761
}
5862
}
5963

64+
// Sort newest first
6065
results.sort_by(|a, b| {
6166
let a_dt = extract_datetime(a["path"].as_str().unwrap_or_default());
6267
let b_dt = extract_datetime(b["path"].as_str().unwrap_or_default());
@@ -71,6 +76,7 @@ pub fn scan_project_sessions_incremental(
7176
Ok(results)
7277
}
7378

79+
/// Scan all projects that appear in sessions folder
7480
#[tauri::command]
7581
pub async fn scan_projects() -> Result<Vec<Value>, String> {
7682
let sessions_dir = get_sessions_path()?;
@@ -79,6 +85,7 @@ pub async fn scan_projects() -> Result<Vec<Value>, String> {
7985
for entry in scan_jsonl_files(&sessions_dir) {
8086
let file_path = entry.path().to_path_buf();
8187

88+
// Filter out invalid small files
8289
match count_lines(&file_path) {
8390
Ok(line_count) if line_count < 4 => {
8491
eprintln!("Deleting file with {} lines: {:?}", line_count, file_path);
@@ -87,15 +94,15 @@ pub async fn scan_projects() -> Result<Vec<Value>, String> {
8794
}
8895
continue;
8996
}
90-
Ok(_) => { /* File has enough lines, proceed */ }
97+
Ok(_) => {}
9198
Err(e) => {
9299
eprintln!("Failed to count lines for {:?}: {}", file_path, e);
93100
continue;
94101
}
95102
}
96103

97104
let mut project_path: Option<String> = None;
98-
match read_first_line(file_path.clone()) {
105+
match read_first_line(&file_path) {
99106
Ok(line) => {
100107
if let Ok(value) = serde_json::from_str::<Value>(&line) {
101108
if let Some(cwd) = value["payload"]["cwd"].as_str() {
@@ -116,10 +123,10 @@ pub async fn scan_projects() -> Result<Vec<Value>, String> {
116123
.map(|path| {
117124
json!({
118125
"path": path,
119-
"trust_level": Some("no"),
126+
"trust_level": "no",
120127
})
121128
})
122129
.collect();
123130

124131
Ok(results)
125-
}
132+
}

src/components/ConversationList.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useMemo, type SetStateAction, type Dispatch } from "react";
1+
import { useMemo, useEffect, type SetStateAction, type Dispatch } from "react";
22
import { Button } from "@/components/ui/button";
33
import { Trash2, MoreVertical, Star, StarOff, FolderPlus } from "lucide-react";
44
import { invoke } from "@tauri-apps/api/core";
@@ -8,7 +8,7 @@ import {
88
DropdownMenuItem,
99
DropdownMenuTrigger,
1010
} from "@/components/ui/dropdown-menu"
11-
import { useConversationListStore } from "@/stores/useConversationListStore";
11+
import { useConversationListStore, loadProjectSessions } from "@/stores/useConversationListStore";
1212
import { useCodexStore } from "@/stores/useCodexStore";
1313
import { useActiveConversationStore } from "@/stores/useActiveConversationStore";
1414
import { Checkbox } from "@/components/ui/checkbox";
@@ -42,6 +42,13 @@ export function ConversationList({
4242
const { activeConversationId, setActiveConversationId } = useActiveConversationStore();
4343
const { cwd } = useCodexStore();
4444

45+
useEffect(() => {
46+
if (cwd) {
47+
console.log("cwd", cwd)
48+
loadProjectSessions(cwd);
49+
}
50+
}, [cwd]);
51+
4552
const favoriteIds = useMemo(() => {
4653
const list = favoriteConversationIdsByCwd[cwd || ""] ?? [];
4754
return new Set(list);
@@ -145,9 +152,9 @@ export function ConversationList({
145152
</div>
146153
<DropdownMenuContent align="end">
147154
<DropdownMenuItem
148-
onClick={(event) => {
155+
onClick={async (event) => {
149156
event.stopPropagation();
150-
toggleFavorite(conv.conversationId);
157+
await toggleFavorite(conv.conversationId);
151158
}}
152159
>
153160
{isFavorite ? (

0 commit comments

Comments
 (0)