Use rocksdb to store project paths' public/private state

Max Brunsfeld created

Change summary

Cargo.lock                                  |  45 +++++
crates/collab/src/integration_tests.rs      |   2 
crates/contacts_panel/src/contacts_panel.rs |   2 
crates/project/Cargo.toml                   |   1 
crates/project/src/db.rs                    | 161 +++++++++++++++++++++++
crates/project/src/project.rs               |  73 +++++++++
crates/workspace/src/workspace.rs           |  53 +++++-
crates/zed/src/main.rs                      |  15 +
8 files changed, 324 insertions(+), 28 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -616,6 +616,17 @@ version = "1.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c4872d67bab6358e59559027aa3b9157c53d9358c51423c17554809a8858e0f8"
 
+[[package]]
+name = "bzip2-sys"
+version = "0.1.11+1.0.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "736a955f3fa7875102d57c82b8cac37ec45224a07fd32d58f9f7a186b6cd4cdc"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+]
+
 [[package]]
 name = "cache-padded"
 version = "1.1.1"
@@ -2506,6 +2517,21 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "librocksdb-sys"
+version = "0.6.1+6.28.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81bc587013734dadb7cf23468e531aa120788b87243648be42e2d3a072186291"
+dependencies = [
+ "bindgen",
+ "bzip2-sys",
+ "cc",
+ "glob",
+ "libc",
+ "libz-sys",
+ "zstd-sys",
+]
+
 [[package]]
 name = "libz-sys"
 version = "1.1.3"
@@ -3395,6 +3421,7 @@ dependencies = [
  "postage",
  "rand 0.8.3",
  "regex",
+ "rocksdb",
  "rpc",
  "serde",
  "serde_json",
@@ -3713,9 +3740,9 @@ dependencies = [
 
 [[package]]
 name = "regex"
-version = "1.5.4"
+version = "1.5.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d07a8629359eb56f1e2fb1652bb04212c072a87ba68546a04065d525673ac461"
+checksum = "d83f127d94bdbcda4c8cc2e50f6f84f4b611f69c902699ca385a39c3a75f9ff1"
 dependencies = [
  "aho-corasick",
  "memchr",
@@ -3733,9 +3760,9 @@ dependencies = [
 
 [[package]]
 name = "regex-syntax"
-version = "0.6.25"
+version = "0.6.26"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b"
+checksum = "49b3de9ec5dc0a3417da371aab17d729997c15010e7fd24ff707773a33bddb64"
 
 [[package]]
 name = "remove_dir_all"
@@ -3822,6 +3849,16 @@ dependencies = [
  "winapi 0.3.9",
 ]
 
+[[package]]
+name = "rocksdb"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "620f4129485ff1a7128d184bc687470c21c7951b64779ebc9cfdad3dcd920290"
+dependencies = [
+ "libc",
+ "librocksdb-sys",
+]
+
 [[package]]
 name = "roxmltree"
 version = "0.14.1"

crates/collab/src/integration_tests.rs 🔗

@@ -4604,7 +4604,7 @@ impl TestServer {
             });
 
         let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http, cx));
-        let project_store = cx.add_model(|_| ProjectStore::default());
+        let project_store = cx.add_model(|_| ProjectStore::new(project::Db::open_fake()));
         let app_state = Arc::new(workspace::AppState {
             client: client.clone(),
             user_store: user_store.clone(),

crates/contacts_panel/src/contacts_panel.rs 🔗

@@ -1175,7 +1175,7 @@ mod tests {
         let http_client = FakeHttpClient::with_404_response();
         let client = Client::new(http_client.clone());
         let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx));
-        let project_store = cx.add_model(|_| ProjectStore::default());
+        let project_store = cx.add_model(|_| ProjectStore::new(project::Db::open_fake()));
         let server = FakeServer::for_client(current_user_id, &client, &cx).await;
         let fs = FakeFs::new(cx.background());
         fs.insert_tree("/private_dir", json!({ "one.rs": "" }))

crates/project/Cargo.toml 🔗

@@ -47,6 +47,7 @@ similar = "1.3"
 smol = "1.2.5"
 thiserror = "1.0.29"
 toml = "0.5"
+rocksdb = "0.18"
 
 [dev-dependencies]
 client = { path = "../client", features = ["test-support"] }

crates/project/src/db.rs 🔗

@@ -0,0 +1,161 @@
+use anyhow::Result;
+use std::path::PathBuf;
+use std::sync::Arc;
+
+pub struct Db(DbStore);
+
+enum DbStore {
+    Null,
+    Real(rocksdb::DB),
+
+    #[cfg(any(test, feature = "test-support"))]
+    Fake {
+        data: parking_lot::Mutex<collections::HashMap<Vec<u8>, Vec<u8>>>,
+    },
+}
+
+impl Db {
+    /// Open or create a database at the given file path.
+    pub fn open(path: PathBuf) -> Result<Arc<Self>> {
+        let db = rocksdb::DB::open_default(&path)?;
+        Ok(Arc::new(Self(DbStore::Real(db))))
+    }
+
+    /// Open a null database that stores no data, for use as a fallback
+    /// when there is an error opening the real database.
+    pub fn null() -> Arc<Self> {
+        Arc::new(Self(DbStore::Null))
+    }
+
+    /// Open a fake database for testing.
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn open_fake() -> Arc<Self> {
+        Arc::new(Self(DbStore::Fake {
+            data: Default::default(),
+        }))
+    }
+
+    pub fn read<K, I>(&self, keys: I) -> Result<Vec<Option<Vec<u8>>>>
+    where
+        K: AsRef<[u8]>,
+        I: IntoIterator<Item = K>,
+    {
+        match &self.0 {
+            DbStore::Real(db) => db
+                .multi_get(keys)
+                .into_iter()
+                .map(|e| e.map_err(Into::into))
+                .collect(),
+
+            DbStore::Null => Ok(keys.into_iter().map(|_| None).collect()),
+
+            #[cfg(any(test, feature = "test-support"))]
+            DbStore::Fake { data: db } => {
+                let db = db.lock();
+                Ok(keys
+                    .into_iter()
+                    .map(|key| db.get(key.as_ref()).cloned())
+                    .collect())
+            }
+        }
+    }
+
+    pub fn delete<K, I>(&self, keys: I) -> Result<()>
+    where
+        K: AsRef<[u8]>,
+        I: IntoIterator<Item = K>,
+    {
+        match &self.0 {
+            DbStore::Real(db) => {
+                let mut batch = rocksdb::WriteBatch::default();
+                for key in keys {
+                    batch.delete(key);
+                }
+                db.write(batch)?;
+            }
+
+            DbStore::Null => {}
+
+            #[cfg(any(test, feature = "test-support"))]
+            DbStore::Fake { data: db } => {
+                let mut db = db.lock();
+                for key in keys {
+                    db.remove(key.as_ref());
+                }
+            }
+        }
+        Ok(())
+    }
+
+    pub fn write<K, V, I>(&self, entries: I) -> Result<()>
+    where
+        K: AsRef<[u8]>,
+        V: AsRef<[u8]>,
+        I: IntoIterator<Item = (K, V)>,
+    {
+        match &self.0 {
+            DbStore::Real(db) => {
+                let mut batch = rocksdb::WriteBatch::default();
+                for (key, value) in entries {
+                    batch.put(key, value);
+                }
+                db.write(batch)?;
+            }
+
+            DbStore::Null => {}
+
+            #[cfg(any(test, feature = "test-support"))]
+            DbStore::Fake { data: db } => {
+                let mut db = db.lock();
+                for (key, value) in entries {
+                    db.insert(key.as_ref().into(), value.as_ref().into());
+                }
+            }
+        }
+        Ok(())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use tempdir::TempDir;
+
+    #[gpui::test]
+    fn test_db() {
+        let dir = TempDir::new("db-test").unwrap();
+        let fake_db = Db::open_fake();
+        let real_db = Db::open(dir.path().join("test.db")).unwrap();
+
+        for db in [&real_db, &fake_db] {
+            assert_eq!(
+                db.read(["key-1", "key-2", "key-3"]).unwrap(),
+                &[None, None, None]
+            );
+
+            db.write([("key-1", "one"), ("key-3", "three")]).unwrap();
+            assert_eq!(
+                db.read(["key-1", "key-2", "key-3"]).unwrap(),
+                &[
+                    Some("one".as_bytes().to_vec()),
+                    None,
+                    Some("three".as_bytes().to_vec())
+                ]
+            );
+
+            db.delete(["key-3", "key-4"]).unwrap();
+            assert_eq!(
+                db.read(["key-1", "key-2", "key-3"]).unwrap(),
+                &[Some("one".as_bytes().to_vec()), None, None,]
+            );
+        }
+
+        drop(real_db);
+
+        let real_db = Db::open(dir.path().join("test.db")).unwrap();
+        assert_eq!(
+            real_db.read(["key-1", "key-2", "key-3"]).unwrap(),
+            &[Some("one".as_bytes().to_vec()), None, None,]
+        );
+    }
+}

crates/project/src/project.rs 🔗

@@ -1,3 +1,4 @@
+mod db;
 pub mod fs;
 mod ignore;
 mod lsp_command;
@@ -53,6 +54,7 @@ use std::{
 use thiserror::Error;
 use util::{post_inc, ResultExt, TryFutureExt as _};
 
+pub use db::Db;
 pub use fs::*;
 pub use worktree::*;
 
@@ -60,8 +62,8 @@ pub trait Item: Entity {
     fn entry_id(&self, cx: &AppContext) -> Option<ProjectEntryId>;
 }
 
-#[derive(Default)]
 pub struct ProjectStore {
+    db: Arc<Db>,
     projects: Vec<WeakModelHandle<Project>>,
 }
 
@@ -533,7 +535,7 @@ impl Project {
         let http_client = client::test::FakeHttpClient::with_404_response();
         let client = client::Client::new(http_client.clone());
         let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx));
-        let project_store = cx.add_model(|_| ProjectStore::default());
+        let project_store = cx.add_model(|_| ProjectStore::new(Db::open_fake()));
         let project = cx.update(|cx| {
             Project::local(true, client, user_store, project_store, languages, fs, cx)
         });
@@ -568,6 +570,10 @@ impl Project {
         self.user_store.clone()
     }
 
+    pub fn project_store(&self) -> ModelHandle<ProjectStore> {
+        self.project_store.clone()
+    }
+
     #[cfg(any(test, feature = "test-support"))]
     pub fn check_invariants(&self, cx: &AppContext) {
         if self.is_local() {
@@ -743,9 +749,6 @@ impl Project {
     }
 
     fn metadata_changed(&mut self, cx: &mut ModelContext<Self>) {
-        cx.notify();
-        self.project_store.update(cx, |_, cx| cx.notify());
-
         if let ProjectClientState::Local {
             remote_id_rx,
             public_rx,
@@ -768,6 +771,9 @@ impl Project {
                     })
                     .log_err();
             }
+
+            self.project_store.update(cx, |_, cx| cx.notify());
+            cx.notify();
         }
     }
 
@@ -5215,6 +5221,13 @@ impl Project {
 }
 
 impl ProjectStore {
+    pub fn new(db: Arc<Db>) -> Self {
+        Self {
+            db,
+            projects: Default::default(),
+        }
+    }
+
     pub fn projects<'a>(
         &'a self,
         cx: &'a AppContext,
@@ -5248,6 +5261,56 @@ impl ProjectStore {
             cx.notify();
         }
     }
+
+    pub fn are_all_project_paths_public(
+        &self,
+        project: &Project,
+        cx: &AppContext,
+    ) -> Task<Result<bool>> {
+        let project_path_keys = self.project_path_keys(project, cx);
+        let db = self.db.clone();
+        cx.background().spawn(async move {
+            let values = db.read(project_path_keys)?;
+            Ok(values.into_iter().all(|e| e.is_some()))
+        })
+    }
+
+    pub fn set_project_paths_public(
+        &self,
+        project: &Project,
+        public: bool,
+        cx: &AppContext,
+    ) -> Task<Result<()>> {
+        let project_path_keys = self.project_path_keys(project, cx);
+        let db = self.db.clone();
+        cx.background().spawn(async move {
+            if public {
+                db.write(project_path_keys.into_iter().map(|key| (key, &[])))
+            } else {
+                db.delete(project_path_keys)
+            }
+        })
+    }
+
+    fn project_path_keys(&self, project: &Project, cx: &AppContext) -> Vec<String> {
+        project
+            .worktrees
+            .iter()
+            .filter_map(|worktree| {
+                worktree.upgrade(&cx).map(|worktree| {
+                    format!(
+                        "public-project-path:{}",
+                        worktree
+                            .read(cx)
+                            .as_local()
+                            .unwrap()
+                            .abs_path()
+                            .to_string_lossy()
+                    )
+                })
+            })
+            .collect::<Vec<_>>()
+    }
 }
 
 impl WorktreeHandle {

crates/workspace/src/workspace.rs 🔗

@@ -692,7 +692,7 @@ impl AppState {
         let languages = Arc::new(LanguageRegistry::test());
         let http_client = client::test::FakeHttpClient::with_404_response();
         let client = Client::new(http_client.clone());
-        let project_store = cx.add_model(|_| ProjectStore::default());
+        let project_store = cx.add_model(|_| ProjectStore::new(project::Db::open_fake()));
         let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx));
         let themes = ThemeRegistry::new((), cx.font_cache().clone());
         Arc::new(Self {
@@ -1055,8 +1055,15 @@ impl Workspace {
             .clone()
             .unwrap_or_else(|| self.project.clone());
         project.update(cx, |project, cx| {
-            let is_public = project.is_public();
-            project.set_public(!is_public, cx);
+            let public = !project.is_public();
+            eprintln!("toggle_project_public => {}", public);
+            project.set_public(public, cx);
+            project.project_store().update(cx, |store, cx| {
+                store
+                    .set_project_paths_public(project, public, cx)
+                    .detach_and_log_err(cx);
+                cx.notify();
+            });
         });
     }
 
@@ -2407,6 +2414,7 @@ pub fn open_paths(
     let app_state = app_state.clone();
     let abs_paths = abs_paths.to_vec();
     cx.spawn(|mut cx| async move {
+        let mut new_project = None;
         let workspace = if let Some(existing) = existing {
             existing
         } else {
@@ -2416,18 +2424,17 @@ pub fn open_paths(
                     .contains(&false);
 
             cx.add_window((app_state.build_window_options)(), |cx| {
-                let mut workspace = Workspace::new(
-                    Project::local(
-                        false,
-                        app_state.client.clone(),
-                        app_state.user_store.clone(),
-                        app_state.project_store.clone(),
-                        app_state.languages.clone(),
-                        app_state.fs.clone(),
-                        cx,
-                    ),
+                let project = Project::local(
+                    false,
+                    app_state.client.clone(),
+                    app_state.user_store.clone(),
+                    app_state.project_store.clone(),
+                    app_state.languages.clone(),
+                    app_state.fs.clone(),
                     cx,
                 );
+                new_project = Some(project.clone());
+                let mut workspace = Workspace::new(project, cx);
                 (app_state.initialize_workspace)(&mut workspace, &app_state, cx);
                 if contains_directory {
                     workspace.toggle_sidebar_item(
@@ -2446,6 +2453,26 @@ pub fn open_paths(
         let items = workspace
             .update(&mut cx, |workspace, cx| workspace.open_paths(abs_paths, cx))
             .await;
+
+        if let Some(project) = new_project {
+            let public = project
+                .read_with(&cx, |project, cx| {
+                    app_state
+                        .project_store
+                        .read(cx)
+                        .are_all_project_paths_public(project, cx)
+                })
+                .await
+                .log_err()
+                .unwrap_or(false);
+            if public {
+                project.update(&mut cx, |project, cx| {
+                    eprintln!("initialize new project public");
+                    project.set_public(true, cx);
+                });
+            }
+        }
+
         (workspace, items)
     })
 }

crates/zed/src/main.rs 🔗

@@ -48,9 +48,10 @@ use zed::{
 
 fn main() {
     let http = http::client();
-    let logs_dir_path = dirs::home_dir()
-        .expect("could not find home dir")
-        .join("Library/Logs/Zed");
+    let home_dir = dirs::home_dir().expect("could not find home dir");
+    let db_dir_path = home_dir.join("Library/Application Support/Zed");
+    let logs_dir_path = home_dir.join("Library/Logs/Zed");
+    fs::create_dir_all(&db_dir_path).expect("could not create database path");
     fs::create_dir_all(&logs_dir_path).expect("could not create logs path");
     init_logger(&logs_dir_path);
 
@@ -59,6 +60,11 @@ fn main() {
         .or_else(|| app.platform().app_version().ok())
         .map_or("dev".to_string(), |v| v.to_string());
     init_panic_hook(logs_dir_path, app_version, http.clone(), app.background());
+    let db = app.background().spawn(async move {
+        project::Db::open(db_dir_path.join("zed.db"))
+            .log_err()
+            .unwrap_or(project::Db::null())
+    });
 
     load_embedded_fonts(&app);
 
@@ -136,7 +142,6 @@ fn main() {
         let client = client::Client::new(http.clone());
         let mut languages = languages::build_language_registry(login_shell_env_loaded);
         let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http.clone(), cx));
-        let project_store = cx.add_model(|_| ProjectStore::default());
 
         context_menu::init(cx);
         auto_update::init(http, client::ZED_SERVER_URL.clone(), cx);
@@ -156,6 +161,7 @@ fn main() {
         search::init(cx);
         vim::init(cx);
 
+        let db = cx.background().block(db);
         let (settings_file, keymap_file) = cx.background().block(config_files).unwrap();
         let mut settings_rx = settings_from_files(
             default_settings,
@@ -191,6 +197,7 @@ fn main() {
         .detach();
         cx.set_global(settings);
 
+        let project_store = cx.add_model(|_| ProjectStore::new(db));
         let app_state = Arc::new(AppState {
             languages,
             themes,