Skip to content

Commit 5816b91

Browse files
committed
feat(projects): fast-path project listing, wire desktop/web APIs, and add discussions endpoint
- Optimize project enumeration for `/api/projects` by avoiding file I/O on logs and using filename/metadata for derivations - Add helper `parse_millis_from_log_name(name: &str) -> Option<u64>` to extract timestamps from `rpc-log-<millis>.(log|json)` ([crates/backend/src/lib.rs:1560](crates/backend/src/lib.rs:1560)) - Replace per-file open/parse with a lightweight pass over directory entries: - Count matching `rpc-log-*.{log,json}` - Track `earliest_ts_millis` and `latest_ts_millis` using filename-derived millis - Track `latest_mtime_secs` fallback from metadata for `lastActivity` - This materially reduces overhead for large project folders by avoiding disk reads, focusing on filenames and metadata only - Rename exposed route from `get_projects` to `list_projects` and register new route - Update Rocket mount to use `list_projects` and add `get_project_discussions` route ([crates/backend/src/lib.rs:588](crates/backend/src/lib.rs:588)) - Rename tauri command `get_projects` → `list_projects` and add performance timing around lock and total execution ([crates/tauri-app/src/lib.rs:213](crates/tauri-app/src/lib.rs:213)) - Print basic timing: lock took N secs, and overall `list_projects` took total N secs - Add new command `get_project_discussions(projectId)` delegating to backend ([crates/tauri-app/src/lib.rs:334](crates/tauri-app/src/lib.rs:334)) - Update command registration to include `list_projects` and `get_project_discussions` ([crates/tauri-app/src/lib.rs:348](crates/tauri-app/src/lib.rs:348)) - Centralize invocation layer and expose it for both modes - Export api from `App.tsx` with `invoke(command, args)` abstraction; add desktop/web switches for new commands ([frontend/src/App.tsx:142,166](frontend/src/App.tsx:142)) - Wire `list_projects` and `get_project_discussions` through `api.invoke` so both Tauri and web paths are supported ([frontend/src/App.tsx:166–175](frontend/src/App.tsx:166)) - Implement REST helpers for web mode - Add `webApi.list_projects({ limit, offset })` calling `GET /projects` ([frontend/src/lib/webApi.ts:184](frontend/src/lib/webApi.ts:184)) - Add `webApi.get_project_discussions(project_id)` calling `GET /projects/:id/discussions` ([frontend/src/lib/webApi.ts:196](frontend/src/lib/webApi.ts:196)) - Comment out legacy dual-mode exported helpers to favor `api.invoke`-based access in pages - UI integration - Projects page now loads via `api.invoke("list_projects", { limit, offset })` and renders items ([frontend/src/pages/Projects.tsx:21](frontend/src/pages/Projects.tsx:21)) - Project detail page now loads discussions via `api.invoke("get_project_discussions", { projectId })` ([frontend/src/pages/ProjectDetail.tsx:6,27](frontend/src/pages/ProjectDetail.tsx:6)) - Layout refactor - Extract shared header and scrollable content shell into new `PageLayout` component ([frontend/src/components/PageLayout.tsx:1](frontend/src/components/PageLayout.tsx:1)) - Replace duplicated header markup in `App.tsx` for `/projects` and `/project/:id` routes with `PageLayout` ([frontend/src/App.tsx:815,827](frontend/src/App.tsx:815)) - **Performance**: Listing projects is now O(n) over directory entries with zero log file parsing, dramatically reducing latency for large histories and making the UI snappier. - **Consistency**: Command naming aligned across backend, Tauri, and web layers (`list_projects` vs `get_projects`), reducing confusion and easing future extension. - **Extensibility**: Introduces `get_project_discussions` plumbing across Tauri and frontend; web API stubs allow REST path while desktop uses native invoke. - **Maintainability**: `PageLayout` removes duplicated header/branding code and standardizes page shells. - Backend route mount updated to reflect `list_projects` and new `get_project_discussions` handler exposure. - Chrome DevTools `.well-known` requests still forward to 422 via SPA guard; unchanged and benign. - **Backend**: [crates/backend/src/lib.rs:1560](crates/backend/src/lib.rs:1560) - **Tauri**: [crates/tauri-app/src/lib.rs:213](crates/tauri-app/src/lib.rs:213) - **Frontend**: - [frontend/src/App.tsx:142](frontend/src/App.tsx:142) - [frontend/src/components/PageLayout.tsx:1](frontend/src/components/PageLayout.tsx:1) - [frontend/src/lib/webApi.ts:184](frontend/src/lib/webApi.ts:184) - [frontend/src/pages/Projects.tsx:21](frontend/src/pages/Projects.tsx:21) - [frontend/src/pages/ProjectDetail.tsx:1](frontend/src/pages/ProjectDetail.tsx:1)
1 parent b287de3 commit 5816b91

File tree

8 files changed

+311
-152
lines changed

8 files changed

+311
-152
lines changed

crates/backend/src/lib.rs

Lines changed: 167 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1560,7 +1560,17 @@ fn analyze_log_file(
15601560
)
15611561
}
15621562

1563-
/// Enumerate projects and return a paginated ProjectsResponse.
1563+
fn parse_millis_from_log_name(name: &str) -> Option<u64> {
1564+
// Accept rpc-log-<millis>.log or .json
1565+
if !name.starts_with("rpc-log-") {
1566+
return None;
1567+
}
1568+
let rest = name.strip_prefix("rpc-log-")?;
1569+
let ts_part = rest.strip_suffix(".log").or_else(|| rest.strip_suffix(".json"))?;
1570+
ts_part.parse::<u64>().ok()
1571+
}
1572+
1573+
/// Enumerate projects and return a paginated ProjectsResponse (fast path).
15641574
pub fn list_projects(limit: u32, offset: u32) -> BackendResult<ProjectsResponse> {
15651575
let Some(root) = home_projects_root() else {
15661576
return Ok(ProjectsResponse {
@@ -1603,72 +1613,88 @@ pub fn list_projects(limit: u32, offset: u32) -> BackendResult<ProjectsResponse>
16031613
let end = std::cmp::min(start + limit as usize, all_ids.len());
16041614
let page_ids = &all_ids[start..end];
16051615

1606-
// Build items
1616+
// Build items (lightweight)
16071617
let mut items: Vec<ProjectListItem> = Vec::new();
16081618
for id in page_ids {
16091619
let proj_path = root.join(id);
16101620

1611-
// enumerate rpc-log-*.log or *.json files
1612-
let mut logs: Vec<PathBuf> = Vec::new();
1621+
// Enumerate only filenames to compute counts and timestamps. Do not open files.
1622+
let mut log_count: u32 = 0;
1623+
let mut earliest_ts_millis: Option<u64> = None;
1624+
let mut latest_ts_millis: Option<u64> = None;
1625+
let mut latest_mtime_secs: Option<u64> = None;
1626+
16131627
if let Ok(rd) = fs::read_dir(&proj_path) {
16141628
for e in rd.flatten() {
16151629
let p = e.path();
1616-
if let Some(fname) = p.file_name().and_then(|s| s.to_str()) {
1617-
if fname.starts_with("rpc-log-")
1618-
&& (fname.ends_with(".log") || fname.ends_with(".json"))
1619-
{
1620-
logs.push(p);
1630+
let fname_opt = p.file_name().and_then(|s| s.to_str());
1631+
if let Some(fname) = fname_opt {
1632+
if fname.starts_with("rpc-log-") && (fname.ends_with(".log") || fname.ends_with(".json")) {
1633+
log_count = log_count.saturating_add(1);
1634+
1635+
// Prefer timestamp embedded in filename for created/updated derivation
1636+
if let Some(millis) = parse_millis_from_log_name(fname) {
1637+
earliest_ts_millis = match earliest_ts_millis {
1638+
Some(cur) => Some(cur.min(millis)),
1639+
None => Some(millis),
1640+
};
1641+
latest_ts_millis = match latest_ts_millis {
1642+
Some(cur) => Some(cur.max(millis)),
1643+
None => Some(millis),
1644+
};
1645+
}
1646+
1647+
// Also track latest mtime as fallback for lastActivity
1648+
if let Ok(md) = e.metadata() {
1649+
if let Ok(modified) = md.modified() {
1650+
if let Ok(dur) = modified.duration_since(std::time::UNIX_EPOCH) {
1651+
let secs = dur.as_secs();
1652+
latest_mtime_secs = Some(latest_mtime_secs.map_or(secs, |cur| cur.max(secs)));
1653+
}
1654+
}
1655+
}
16211656
}
16221657
}
16231658
}
16241659
}
1625-
logs.sort(); // name sort; adequate for picking "latest" by name if timestamp is embedded
1626-
1627-
let log_count = logs.len() as u32;
1628-
let mut created: Option<String> = None;
1629-
let mut updated: Option<String> = None;
1630-
let mut title: Option<String> = None;
1631-
let mut status = "unknown".to_string();
1632-
let mut any_activity = false;
1633-
let mut any_error = false;
1634-
1635-
for log in logs.iter() {
1636-
let (u, a, t, latest_user_title, earliest_iso, latest_iso, parse_err) =
1637-
analyze_log_file(log);
1638-
if earliest_iso.is_some() && created.is_none() {
1639-
created = earliest_iso.clone();
1640-
}
1641-
if let Some(li) = latest_iso {
1642-
updated = Some(li);
1643-
}
1644-
if let Some(ti) = latest_user_title {
1645-
title = Some(ti);
1646-
}
1647-
if u + a + t > 0 {
1648-
any_activity = true;
1649-
}
1650-
if parse_err {
1651-
any_error = true;
1652-
}
1653-
}
16541660

1655-
let last_activity = updated.clone();
1661+
// Compute created/updated/lastActivity as ISO strings
1662+
let created_at_iso: Option<String> = earliest_ts_millis.map(|ms| {
1663+
// millis -> seconds
1664+
let secs = ms / 1000;
1665+
chrono::DateTime::<chrono::Utc>::from(std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs))
1666+
.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
1667+
});
1668+
1669+
let updated_at_iso_from_name: Option<String> = latest_ts_millis.map(|ms| {
1670+
let secs = ms / 1000;
1671+
chrono::DateTime::<chrono::Utc>::from(std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs))
1672+
.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
1673+
});
16561674

1657-
if any_error {
1658-
status = "error".to_string();
1659-
} else if any_activity {
1660-
status = "active".to_string();
1661-
} else {
1662-
status = "unknown".to_string();
1663-
}
1675+
let last_activity_iso_from_mtime: Option<String> = latest_mtime_secs.map(|secs| {
1676+
chrono::DateTime::<chrono::Utc>::from(std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs))
1677+
.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
1678+
});
1679+
1680+
// Prefer filename-derived updatedAt; fallback to mtime-derived last activity
1681+
let updated_at_iso = updated_at_iso_from_name.clone().or_else(|| last_activity_iso_from_mtime.clone());
1682+
let last_activity_iso = updated_at_iso_from_name.or(last_activity_iso_from_mtime);
1683+
1684+
// Title: avoid opening files. Provide None to keep fast path.
1685+
// Frontend renders gracefully without title.
1686+
let title: Option<String> = None;
1687+
1688+
// Status: "active" if there is any log; otherwise "unknown"
1689+
let status = if log_count > 0 { "active".to_string() } else { "unknown".to_string() };
16641690

16651691
items.push(ProjectListItem {
16661692
id: id.clone(),
16671693
title,
16681694
status: Some(status),
1669-
created_at: created,
1670-
updated_at: updated.clone(),
1671-
last_activity_at: last_activity,
1695+
created_at: created_at_iso,
1696+
updated_at: updated_at_iso.clone(),
1697+
last_activity_at: last_activity_iso,
16721698
log_count: Some(log_count),
16731699
});
16741700
}
@@ -2306,6 +2332,98 @@ impl<E: EventEmitter + 'static> GeminiBackend<E> {
23062332
list_projects(lim, offset)
23072333
}
23082334

2335+
/// Get discussions (conversations) for a specific project
2336+
pub async fn get_project_discussions(&self, project_id: &str) -> BackendResult<Vec<RecentChat>> {
2337+
use std::ffi::OsStr;
2338+
use std::time::{SystemTime, UNIX_EPOCH};
2339+
2340+
// Resolve projects root
2341+
let home = std::env::var("HOME")
2342+
.unwrap_or_else(|_| std::env::var("USERPROFILE").unwrap_or_else(|_| "".to_string()));
2343+
if home.is_empty() {
2344+
return Err(BackendError::SessionInitFailed(
2345+
"Could not determine home directory".to_string(),
2346+
));
2347+
}
2348+
let project_path = std::path::Path::new(&home)
2349+
.join(".gemini-desktop")
2350+
.join("projects")
2351+
.join(project_id);
2352+
2353+
if !project_path.exists() || !project_path.is_dir() {
2354+
return Ok(vec![]);
2355+
}
2356+
2357+
let mut discussions: Vec<RecentChat> = Vec::new();
2358+
2359+
// Enumerate rpc-log-*.log files in this project folder
2360+
let logs_iter = match std::fs::read_dir(&project_path) {
2361+
Ok(l) => l,
2362+
Err(_) => return Ok(vec![]),
2363+
};
2364+
2365+
for log_entry in logs_iter {
2366+
let log_entry = match log_entry {
2367+
Ok(e) => e,
2368+
Err(_) => continue,
2369+
};
2370+
let log_path = log_entry.path();
2371+
let Some(name) = log_path.file_name().and_then(OsStr::to_str) else {
2372+
continue;
2373+
};
2374+
2375+
// Select only files named rpc-log-*.log
2376+
if !(name.starts_with("rpc-log-") && name.ends_with(".log")) {
2377+
continue;
2378+
}
2379+
2380+
// Extract timestamp from filename for started_at_iso
2381+
let timestamp_str = name
2382+
.strip_prefix("rpc-log-")
2383+
.and_then(|s| s.strip_suffix(".log"))
2384+
.unwrap_or("0");
2385+
2386+
let timestamp_millis = timestamp_str.parse::<u64>().unwrap_or(0);
2387+
let started_secs = timestamp_millis / 1000;
2388+
let started_iso = chrono::DateTime::<chrono::Utc>::from(
2389+
UNIX_EPOCH + std::time::Duration::from_secs(started_secs),
2390+
)
2391+
.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
2392+
2393+
// Extract message_count and first user text from this specific log file
2394+
let (message_count, first_user_text) = Self::extract_chat_data(Some(log_path.clone()));
2395+
2396+
// Generate title from first user message or use default
2397+
let title = if let Some(t) = first_user_text.clone() {
2398+
let t = t.trim();
2399+
if t.is_empty() {
2400+
format!("Conversation {}", &name[8..14]) // Use part of timestamp
2401+
} else if t.len() > 50 {
2402+
format!("{}…", &t[..50])
2403+
} else {
2404+
t.to_string()
2405+
}
2406+
} else {
2407+
format!("Conversation {}", &name[8..14]) // Use part of timestamp
2408+
};
2409+
2410+
// Per-log unique id: "{project_id}:{file_name}"
2411+
let id = format!("{}:{}", project_id, name);
2412+
2413+
discussions.push(RecentChat {
2414+
id,
2415+
title,
2416+
started_at_iso: started_iso,
2417+
message_count,
2418+
});
2419+
}
2420+
2421+
// Sort by timestamp descending (newest first)
2422+
discussions.sort_by(|a, b| b.started_at_iso.cmp(&a.started_at_iso));
2423+
2424+
Ok(discussions)
2425+
}
2426+
23092427
/// Get the user's home directory path
23102428
pub async fn get_home_directory(&self) -> BackendResult<String> {
23112429
let home = std::env::var("HOME")

crates/server/src/main.rs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,7 @@ fn index(path: PathBuf) -> Result<(ContentType, &'static [u8]), Status> {
274274
// =====================================
275275

276276
#[get("/projects?<limit>&<offset>")]
277-
async fn get_projects(
277+
async fn list_projects(
278278
limit: Option<u32>,
279279
offset: Option<u32>,
280280
state: &State<AppState>
@@ -291,6 +291,18 @@ async fn get_projects(
291291
}
292292
}
293293

294+
#[get("/projects/<project_id>/discussions")]
295+
async fn get_project_discussions(
296+
project_id: &str,
297+
state: &State<AppState>
298+
) -> Result<Json<Vec<RecentChat>>, Status> {
299+
let backend = state.backend.lock().await;
300+
match backend.get_project_discussions(project_id).await {
301+
Ok(discussions) => Ok(Json(discussions)),
302+
Err(_e) => Err(Status::InternalServerError),
303+
}
304+
}
305+
294306
#[get("/recent-chats")]
295307
async fn get_recent_chats(state: &State<AppState>) -> Result<Json<Vec<RecentChat>>, Status> {
296308
let backend = state.backend.lock().await;
@@ -576,7 +588,8 @@ fn rocket() -> _ {
576588
list_directory_contents,
577589
list_volumes,
578590
get_recent_chats,
579-
get_projects,
591+
list_projects,
592+
get_project_discussions,
580593
],
581594
)
582595
}

crates/tauri-app/src/lib.rs

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -213,11 +213,25 @@ async fn get_recent_chats(state: State<'_, AppState>) -> Result<Vec<RecentChat>,
213213
}
214214

215215
#[tauri::command]
216-
async fn get_projects(limit: Option<u32>, offset: Option<u32>, state: State<'_, AppState>) -> Result<ProjectsResponse, String> {
216+
async fn list_projects(limit: Option<u32>, offset: Option<u32>, state: State<'_, AppState>) -> Result<ProjectsResponse, String> {
217+
// log and count secs
218+
println!("list_projects");
219+
let start = std::time::Instant::now();
217220
let backend = state.backend.lock().await;
221+
let mut elapsed = start.elapsed().as_secs_f64();
222+
println!("lock took {} secs", elapsed);
218223
let lim = limit.unwrap_or(25);
219224
let off = offset.unwrap_or(0);
220-
backend.list_projects(lim, off).await.map_err(|e| e.to_string())
225+
let resp = backend.list_projects(lim, off).await.map_err(|e| e.to_string())?;
226+
elapsed = start.elapsed().as_secs_f64();
227+
println!("list_projects took total {} secs", elapsed);
228+
Ok(resp)
229+
}
230+
231+
#[tauri::command]
232+
async fn get_project_discussions(projectId: String, state: State<'_, AppState>) -> Result<Vec<RecentChat>, String> {
233+
let backend = state.backend.lock().await;
234+
backend.get_project_discussions(&projectId).await.map_err(|e| e.to_string())
221235
}
222236

223237
#[tauri::command]
@@ -334,7 +348,8 @@ pub fn run() {
334348
list_volumes,
335349
debug_environment,
336350
get_recent_chats,
337-
get_projects
351+
list_projects,
352+
get_project_discussions
338353
]);
339354

340355
builder

0 commit comments

Comments
 (0)