Skip to content

Commit 16c0c11

Browse files
committed
feat(projects): add project metadata tracking with enrichment and throttling
- Introduce ProjectMetadata and EnrichedProject types for enhanced project information - Implement lazy creation and update tracking of project.json files - Add TouchThrottle to limit updated_at touches to once per minute - Create backend functions ensure_project_metadata, maybe_touch_updated_at, and make_enriched_project - Expose list_projects_enriched and get_enriched_project endpoints in server and tauri-app - Update frontend to display enriched project metadata including friendly names and timestamps - Remove older duplicate block from list_projects function - Adjust UI elements to use enriched data and improve path truncation display
1 parent 3981886 commit 16c0c11

File tree

7 files changed

+377
-35
lines changed

7 files changed

+377
-35
lines changed

crates/backend/src/lib.rs

Lines changed: 270 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use chrono::{SecondsFormat, Utc};
1+
use chrono::{DateTime, FixedOffset, Local, SecondsFormat, Utc};
22
use serde::{Deserialize, Serialize};
33
use sha2::{Digest, Sha256};
44
use std::collections::{HashMap, HashSet};
@@ -7,6 +7,7 @@ use std::io::{BufRead, BufWriter, Write};
77
use std::path::{Path, PathBuf};
88
use std::process::Stdio;
99
use std::sync::{Arc, Mutex};
10+
use std::time::{Duration, Instant};
1011
use thiserror::Error;
1112
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader as AsyncBufReader};
1213
use tokio::process::{Child, ChildStdin, ChildStdout, Command};
@@ -331,6 +332,9 @@ impl FileRpcLogger {
331332

332333
fs::create_dir_all(&log_dir).map_err(|e| BackendError::IoError(e))?;
333334

335+
// Create project.json for new projects
336+
let _ = ensure_project_metadata(&project_hash, Some(std::path::Path::new(&project_dir)));
337+
334338
// Create log file with timestamp
335339
let timestamp = std::time::SystemTime::now()
336340
.duration_since(std::time::UNIX_EPOCH)
@@ -1711,6 +1715,252 @@ pub fn list_projects(limit: u32, offset: u32) -> BackendResult<ProjectsResponse>
17111715

17121716
// (removed older duplicate block)
17131717

1718+
1719+
// =====================================
1720+
// Project Metadata Types
1721+
// =====================================
1722+
1723+
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1724+
pub struct ProjectMetadata {
1725+
pub path: PathBuf,
1726+
#[serde(default)]
1727+
pub sha256: Option<String>,
1728+
#[serde(default)]
1729+
pub friendly_name: Option<String>,
1730+
#[serde(default)]
1731+
pub first_used: Option<DateTime<FixedOffset>>,
1732+
#[serde(default)]
1733+
pub updated_at: Option<DateTime<FixedOffset>>,
1734+
}
1735+
1736+
#[derive(Debug, Clone, Serialize, Deserialize)]
1737+
pub struct ProjectMetadataView {
1738+
pub path: String,
1739+
pub sha256: String,
1740+
pub friendly_name: String,
1741+
#[serde(skip_serializing_if = "Option::is_none")]
1742+
pub first_used: Option<String>,
1743+
#[serde(skip_serializing_if = "Option::is_none")]
1744+
pub updated_at: Option<String>,
1745+
}
1746+
1747+
#[derive(Debug, Clone, Serialize, Deserialize)]
1748+
pub struct EnrichedProject {
1749+
pub sha256: String,
1750+
pub root_path: PathBuf,
1751+
pub metadata: ProjectMetadataView,
1752+
}
1753+
1754+
1755+
#[derive(Default, Clone)]
1756+
pub struct TouchThrottle {
1757+
inner: Arc<Mutex<HashMap<PathBuf, Instant>>>,
1758+
min_interval: Duration,
1759+
}
1760+
1761+
impl TouchThrottle {
1762+
pub fn new(min_interval: Duration) -> Self {
1763+
Self { inner: Arc::new(Mutex::new(HashMap::new())), min_interval }
1764+
}
1765+
}
1766+
1767+
fn now_fixed_offset() -> DateTime<FixedOffset> {
1768+
let now = Local::now();
1769+
now.with_timezone(now.offset())
1770+
}
1771+
1772+
fn canonicalize_project_root(path: &Path) -> PathBuf {
1773+
match path.canonicalize() {
1774+
Ok(canonical) => canonical,
1775+
Err(_) => {
1776+
eprintln!("warn: failed to canonicalize path {}, using as-is", path.display());
1777+
path.to_path_buf()
1778+
}
1779+
}
1780+
}
1781+
1782+
fn derive_friendly_name_from_path(path: &Path) -> String {
1783+
let s = path.display().to_string();
1784+
#[cfg(windows)]
1785+
{
1786+
let replaced = s.replace('\\', "-").replace(':', "");
1787+
let collapsed = replaced.split('-').filter(|p| !p.is_empty()).collect::<Vec<_>>().join("-");
1788+
collapsed
1789+
}
1790+
#[cfg(not(windows))]
1791+
{
1792+
let replaced = s.replace('/', "-");
1793+
let collapsed = replaced.split('-').filter(|p| !p.is_empty()).collect::<Vec<_>>().join("-");
1794+
collapsed
1795+
}
1796+
}
1797+
1798+
fn projects_root_dir() -> Option<PathBuf> {
1799+
home_projects_root()
1800+
}
1801+
1802+
fn project_json_path(sha256: &str) -> Option<PathBuf> {
1803+
projects_root_dir().map(|root| root.join(sha256).join("project.json"))
1804+
}
1805+
1806+
fn read_project_metadata(root_sha: &str) -> BackendResult<ProjectMetadata> {
1807+
let Some(path) = project_json_path(root_sha) else {
1808+
return Err(BackendError::SessionInitFailed("projects root not found".to_string()));
1809+
};
1810+
if !path.exists() {
1811+
return Err(BackendError::SessionInitFailed("project.json not found".to_string()));
1812+
}
1813+
let content = std::fs::read_to_string(&path).map_err(BackendError::IoError)?;
1814+
serde_json::from_str::<ProjectMetadata>(&content).map_err(BackendError::SerializationError)
1815+
}
1816+
1817+
fn write_project_metadata(sha256: &str, meta: &ProjectMetadata) -> BackendResult<()> {
1818+
let Some(json_path) = project_json_path(sha256) else {
1819+
return Err(BackendError::SessionInitFailed("projects root not found".to_string()));
1820+
};
1821+
if let Some(dir) = json_path.parent() {
1822+
std::fs::create_dir_all(dir).map_err(BackendError::IoError)?;
1823+
}
1824+
let tmp_path = json_path.with_extension("json.tmp");
1825+
let content = serde_json::to_string_pretty(meta).map_err(BackendError::SerializationError)?;
1826+
std::fs::write(&tmp_path, content.as_bytes()).map_err(BackendError::IoError)?;
1827+
std::fs::rename(&tmp_path, &json_path).map_err(BackendError::IoError)?;
1828+
Ok(())
1829+
}
1830+
1831+
fn to_view(meta: &ProjectMetadata, canonical_root: &Path, sha256: &str) -> ProjectMetadataView {
1832+
let friendly = meta.friendly_name.clone().unwrap_or_else(|| derive_friendly_name_from_path(canonical_root));
1833+
let first_used = meta.first_used.as_ref().map(|d| d.to_rfc3339());
1834+
let updated_at = meta.updated_at.as_ref().map(|d| d.to_rfc3339());
1835+
ProjectMetadataView {
1836+
path: meta.path.display().to_string(),
1837+
sha256: meta.sha256.clone().unwrap_or_else(|| sha256.to_string()),
1838+
friendly_name: friendly,
1839+
first_used,
1840+
updated_at,
1841+
}
1842+
}
1843+
1844+
/// Ensure metadata exists for project sha; create lazily if missing using provided external_root_canonical.
1845+
/// Returns Ok(ProjectMetadata) on success; for corrupt files, logs warning and returns a default in-memory struct.
1846+
pub fn ensure_project_metadata(sha256: &str, external_root_canonical: Option<&Path>) -> BackendResult<ProjectMetadata> {
1847+
match read_project_metadata(sha256) {
1848+
Ok(meta) => Ok(meta),
1849+
Err(e) => {
1850+
// If not found or corrupt, try to create if we have external root
1851+
if let Some(ext) = external_root_canonical {
1852+
let now = now_fixed_offset();
1853+
let meta = ProjectMetadata {
1854+
path: ext.to_path_buf(), // Store original path, not canonicalized
1855+
sha256: Some(sha256.to_string()),
1856+
friendly_name: Some(derive_friendly_name_from_path(ext)),
1857+
first_used: Some(now),
1858+
updated_at: Some(now),
1859+
};
1860+
write_project_metadata(sha256, &meta)?;
1861+
eprintln!("info: created project.json for {sha256}");
1862+
Ok(meta)
1863+
} else {
1864+
Err(e)
1865+
}
1866+
}
1867+
}
1868+
}
1869+
1870+
pub fn maybe_touch_updated_at(sha256: &str, throttle: &TouchThrottle) -> BackendResult<()> {
1871+
// Attempt to read metadata; if missing or corrupt, do nothing
1872+
let mut meta = match read_project_metadata(sha256) {
1873+
Ok(m) => m,
1874+
Err(_) => return Ok(()),
1875+
};
1876+
1877+
let root = meta.path.clone();
1878+
let mut guard = throttle.inner.lock().unwrap();
1879+
let last = guard.get(&root).copied();
1880+
let now_inst = Instant::now();
1881+
if let Some(last_instant) = last {
1882+
if now_inst.duration_since(last_instant) < throttle.min_interval {
1883+
// throttle skip
1884+
return Ok(());
1885+
}
1886+
}
1887+
guard.insert(root, now_inst);
1888+
drop(guard);
1889+
1890+
meta.updated_at = Some(now_fixed_offset());
1891+
write_project_metadata(sha256, &meta)?;
1892+
eprintln!("debug: touched updated_at for {sha256}");
1893+
Ok(())
1894+
}
1895+
1896+
/// Create an EnrichedProject view. If metadata missing, it will not auto-create unless external_root is provided and should_create_if_missing is true.
1897+
pub fn make_enriched_project(sha256: &str, external_root: Option<&Path>, should_create_if_missing: bool) -> EnrichedProject {
1898+
// Get metadata and determine display path
1899+
let meta_opt = read_project_metadata(sha256).ok();
1900+
1901+
let display_root = if let Some(ref meta) = meta_opt {
1902+
meta.path.clone() // Use stored path from metadata (user-friendly)
1903+
} else if let Some(er) = external_root {
1904+
er.to_path_buf() // Use original external root (user-friendly)
1905+
} else {
1906+
// fallback to ~/.gemini-desktop/projects/<sha256> as a virtual root
1907+
projects_root_dir().unwrap_or_else(|| PathBuf::from(".")).join(sha256)
1908+
};
1909+
1910+
let meta = if meta_opt.is_some() {
1911+
meta_opt.unwrap()
1912+
} else if should_create_if_missing {
1913+
ensure_project_metadata(sha256, external_root).unwrap_or_else(|_| ProjectMetadata {
1914+
path: display_root.clone(),
1915+
sha256: Some(sha256.to_string()),
1916+
friendly_name: Some(derive_friendly_name_from_path(&display_root)),
1917+
first_used: None,
1918+
updated_at: None,
1919+
})
1920+
} else {
1921+
ProjectMetadata {
1922+
path: display_root.clone(),
1923+
sha256: Some(sha256.to_string()),
1924+
friendly_name: Some(derive_friendly_name_from_path(&display_root)),
1925+
first_used: None,
1926+
updated_at: None,
1927+
}
1928+
};
1929+
1930+
EnrichedProject {
1931+
sha256: sha256.to_string(),
1932+
root_path: display_root.clone(),
1933+
metadata: to_view(&meta, &display_root, sha256),
1934+
}
1935+
}
1936+
1937+
/// Enumerate projects and return EnrichedProject list without touching updated_at or creating project.json unless explicitly asked.
1938+
/// This wraps the existing list_projects fast-path to produce EnrichedProject-lite views.
1939+
pub fn list_enriched_projects() -> BackendResult<Vec<EnrichedProject>> {
1940+
let Some(root) = home_projects_root() else {
1941+
return Ok(vec![]);
1942+
};
1943+
if !root.exists() || !root.is_dir() {
1944+
return Ok(vec![]);
1945+
}
1946+
let mut all_ids: Vec<String> = Vec::new();
1947+
for entry in fs::read_dir(&root).map_err(BackendError::IoError)? {
1948+
let entry = match entry { Ok(e) => e, Err(_) => continue };
1949+
let path = entry.path();
1950+
if !path.is_dir() { continue; }
1951+
if let Some(name) = path.file_name().and_then(|s| s.to_str()) {
1952+
if name.len() == 64 && name.chars().all(|c| c.is_ascii_hexdigit()) {
1953+
all_ids.push(name.to_string());
1954+
}
1955+
}
1956+
}
1957+
all_ids.sort();
1958+
let enriched = all_ids.iter()
1959+
.map(|id| make_enriched_project(id, None, false))
1960+
.collect();
1961+
Ok(enriched)
1962+
}
1963+
17141964
// =====================================
17151965
// Public API (to be implemented)
17161966
// =====================================
@@ -1720,6 +1970,7 @@ pub struct GeminiBackend<E: EventEmitter> {
17201970
emitter: E,
17211971
session_manager: SessionManager,
17221972
next_request_id: Arc<Mutex<u32>>,
1973+
touch_throttle: TouchThrottle,
17231974
}
17241975

17251976
impl<E: EventEmitter + 'static> GeminiBackend<E> {
@@ -1729,6 +1980,7 @@ impl<E: EventEmitter + 'static> GeminiBackend<E> {
17291980
emitter,
17301981
session_manager: SessionManager::new(),
17311982
next_request_id: Arc::new(Mutex::new(1000)),
1983+
touch_throttle: TouchThrottle::new(Duration::from_secs(60)),
17321984
}
17331985
}
17341986

@@ -2332,6 +2584,23 @@ impl<E: EventEmitter + 'static> GeminiBackend<E> {
23322584
list_projects(lim, offset)
23332585
}
23342586

2587+
/// Return enriched projects (friendly metadata view). Does not mutate files.
2588+
pub async fn list_enriched_projects(&self) -> BackendResult<Vec<EnrichedProject>> {
2589+
list_enriched_projects()
2590+
}
2591+
2592+
/// Get an enriched project for a given sha256. Lazily creates project.json if missing using provided external_root_path.
2593+
/// Also touches updated_at with a simple throttle.
2594+
pub async fn get_enriched_project(&self, sha256: String, external_root_path: String) -> BackendResult<EnrichedProject> {
2595+
// Ensure metadata exists (lazy create) using provided external root
2596+
let external = std::path::Path::new(&external_root_path);
2597+
ensure_project_metadata(&sha256, Some(external))?;
2598+
// Touch updated_at with throttle
2599+
let _ = maybe_touch_updated_at(&sha256, &self.touch_throttle);
2600+
// Build and return view
2601+
Ok(make_enriched_project(&sha256, Some(external), false))
2602+
}
2603+
23352604
/// Get discussions (conversations) for a specific project
23362605
pub async fn get_project_discussions(&self, project_id: &str) -> BackendResult<Vec<RecentChat>> {
23372606
use std::ffi::OsStr;

crates/server/src/main.rs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use std::sync::{
1616
use tokio::sync::{Mutex, mpsc as tokio_mpsc};
1717

1818
// Import backend functionality
19-
use backend::{DirEntry, EventEmitter, GeminiBackend, ProcessStatus, RecentChat};
19+
use backend::{DirEntry, EventEmitter, GeminiBackend, ProcessStatus, RecentChat, EnrichedProject};
2020

2121
static FRONTEND_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/../../frontend/dist");
2222

@@ -291,6 +291,29 @@ async fn list_projects(
291291
}
292292
}
293293

294+
#[get("/projects-enriched")]
295+
async fn list_projects_enriched(state: &State<AppState>) -> Result<Json<Vec<EnrichedProject>>, Status> {
296+
let backend = state.backend.lock().await;
297+
match backend.list_enriched_projects().await {
298+
Ok(list) => Ok(Json(list)),
299+
Err(_e) => Err(Status::InternalServerError),
300+
}
301+
}
302+
303+
#[get("/project?<sha256>&<external_root_path>")]
304+
async fn get_enriched_project_http(
305+
state: &State<AppState>,
306+
sha256: String,
307+
external_root_path: String,
308+
) -> Result<Json<EnrichedProject>, Status> {
309+
let backend = state.backend.lock().await;
310+
match backend.get_enriched_project(sha256, external_root_path).await {
311+
Ok(p) => Ok(Json(p)),
312+
Err(_e) => Err(Status::InternalServerError),
313+
}
314+
}
315+
316+
294317
#[get("/projects/<project_id>/discussions")]
295318
async fn get_project_discussions(
296319
project_id: &str,
@@ -589,6 +612,8 @@ fn rocket() -> _ {
589612
list_volumes,
590613
get_recent_chats,
591614
list_projects,
615+
list_projects_enriched,
616+
get_enriched_project_http,
592617
get_project_discussions,
593618
],
594619
)

crates/tauri-app/src/lib.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use std::sync::Arc;
77
use backend::{
88
EventEmitter, GeminiBackend,
99
ProcessStatus, DirEntry, RecentChat,
10-
ProjectsResponse,
10+
ProjectsResponse, EnrichedProject,
1111
};
1212

1313
// =====================================
@@ -202,6 +202,16 @@ async fn list_projects(limit: Option<u32>, offset: Option<u32>, state: State<'_,
202202
state.backend.list_projects(lim, off).await.map_err(|e| e.to_string())
203203
}
204204

205+
#[tauri::command]
206+
async fn list_enriched_projects(state: State<'_, AppState>) -> Result<Vec<EnrichedProject>, String> {
207+
state.backend.list_enriched_projects().await.map_err(|e| e.to_string())
208+
}
209+
210+
#[tauri::command]
211+
async fn get_project(sha256: String, external_root_path: String, state: State<'_, AppState>) -> Result<EnrichedProject, String> {
212+
state.backend.get_enriched_project(sha256, external_root_path).await.map_err(|e| e.to_string())
213+
}
214+
205215
#[tauri::command]
206216
async fn get_project_discussions(projectId: String, state: State<'_, AppState>) -> Result<Vec<RecentChat>, String> {
207217
state.backend.get_project_discussions(&projectId).await.map_err(|e| e.to_string())
@@ -322,6 +332,8 @@ pub fn run() {
322332
debug_environment,
323333
get_recent_chats,
324334
list_projects,
335+
list_enriched_projects,
336+
get_project,
325337
get_project_discussions
326338
]);
327339

0 commit comments

Comments
 (0)