1
- use chrono:: { SecondsFormat , Utc } ;
1
+ use chrono:: { DateTime , FixedOffset , Local , SecondsFormat , Utc } ;
2
2
use serde:: { Deserialize , Serialize } ;
3
3
use sha2:: { Digest , Sha256 } ;
4
4
use std:: collections:: { HashMap , HashSet } ;
@@ -7,6 +7,7 @@ use std::io::{BufRead, BufWriter, Write};
7
7
use std:: path:: { Path , PathBuf } ;
8
8
use std:: process:: Stdio ;
9
9
use std:: sync:: { Arc , Mutex } ;
10
+ use std:: time:: { Duration , Instant } ;
10
11
use thiserror:: Error ;
11
12
use tokio:: io:: { AsyncBufReadExt , AsyncWriteExt , BufReader as AsyncBufReader } ;
12
13
use tokio:: process:: { Child , ChildStdin , ChildStdout , Command } ;
@@ -331,6 +332,9 @@ impl FileRpcLogger {
331
332
332
333
fs:: create_dir_all ( & log_dir) . map_err ( |e| BackendError :: IoError ( e) ) ?;
333
334
335
+ // Create project.json for new projects
336
+ let _ = ensure_project_metadata ( & project_hash, Some ( std:: path:: Path :: new ( & project_dir) ) ) ;
337
+
334
338
// Create log file with timestamp
335
339
let timestamp = std:: time:: SystemTime :: now ( )
336
340
. duration_since ( std:: time:: UNIX_EPOCH )
@@ -1711,6 +1715,252 @@ pub fn list_projects(limit: u32, offset: u32) -> BackendResult<ProjectsResponse>
1711
1715
1712
1716
// (removed older duplicate block)
1713
1717
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
+
1714
1964
// =====================================
1715
1965
// Public API (to be implemented)
1716
1966
// =====================================
@@ -1720,6 +1970,7 @@ pub struct GeminiBackend<E: EventEmitter> {
1720
1970
emitter : E ,
1721
1971
session_manager : SessionManager ,
1722
1972
next_request_id : Arc < Mutex < u32 > > ,
1973
+ touch_throttle : TouchThrottle ,
1723
1974
}
1724
1975
1725
1976
impl < E : EventEmitter + ' static > GeminiBackend < E > {
@@ -1729,6 +1980,7 @@ impl<E: EventEmitter + 'static> GeminiBackend<E> {
1729
1980
emitter,
1730
1981
session_manager : SessionManager :: new ( ) ,
1731
1982
next_request_id : Arc :: new ( Mutex :: new ( 1000 ) ) ,
1983
+ touch_throttle : TouchThrottle :: new ( Duration :: from_secs ( 60 ) ) ,
1732
1984
}
1733
1985
}
1734
1986
@@ -2332,6 +2584,23 @@ impl<E: EventEmitter + 'static> GeminiBackend<E> {
2332
2584
list_projects ( lim, offset)
2333
2585
}
2334
2586
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
+
2335
2604
/// Get discussions (conversations) for a specific project
2336
2605
pub async fn get_project_discussions ( & self , project_id : & str ) -> BackendResult < Vec < RecentChat > > {
2337
2606
use std:: ffi:: OsStr ;
0 commit comments