Merge pull request #1084 from zed-industries/private-projects

Max Brunsfeld created

Offline projects

Change summary

Cargo.lock                                  | 140 ++-
assets/icons/lock-8.svg                     |   3 
crates/client/src/client.rs                 |  59 
crates/client/src/test.rs                   |  11 
crates/client/src/user.rs                   |  17 
crates/collab/Cargo.toml                    |   2 
crates/collab/src/integration_tests.rs      | 703 ++++++++++++-------
crates/collab/src/main.rs                   |   1 
crates/collab/src/rpc.rs                    |  80 -
crates/collab/src/rpc/store.rs              |  56 
crates/command_palette/Cargo.toml           |   2 
crates/contacts_panel/Cargo.toml            |   1 
crates/contacts_panel/src/contacts_panel.rs | 810 ++++++++++++++++++----
crates/editor/Cargo.toml                    |   2 
crates/file_finder/Cargo.toml               |   2 
crates/gpui/Cargo.toml                      |   6 
crates/gpui/src/app.rs                      |  13 
crates/gpui/src/platform.rs                 |   6 
crates/language/Cargo.toml                  |   2 
crates/lsp/Cargo.toml                       |   2 
crates/picker/Cargo.toml                    |   2 
crates/project/Cargo.toml                   |   1 
crates/project/src/db.rs                    | 161 ++++
crates/project/src/project.rs               | 432 +++++++++--
crates/project/src/worktree.rs              |  75 -
crates/rpc/Cargo.toml                       |   2 
crates/rpc/proto/zed.proto                  |  26 
crates/rpc/src/proto.rs                     |   8 
crates/rpc/src/rpc.rs                       |   2 
crates/settings/src/settings.rs             |   9 
crates/sum_tree/Cargo.toml                  |   2 
crates/text/Cargo.toml                      |   2 
crates/theme/src/theme.rs                   |   5 
crates/workspace/src/waiting_room.rs        |   3 
crates/workspace/src/workspace.rs           | 143 ++-
crates/zed/Cargo.toml                       |   4 
crates/zed/src/main.rs                      |  17 
crates/zed/src/zed.rs                       |   9 
styles/src/styleTree/contactsPanel.ts       |   8 
39 files changed, 1,968 insertions(+), 861 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -485,9 +485,9 @@ dependencies = [
 
 [[package]]
 name = "bindgen"
-version = "0.58.1"
+version = "0.59.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0f8523b410d7187a43085e7e064416ea32ded16bd0a4e6fc025e21616d01258f"
+checksum = "2bd2a9a458e8f4304c52c43ebb0cfbd520289f8379a52e329a38afda99bf8eb8"
 dependencies = [
  "bitflags",
  "cexpr",
@@ -503,7 +503,7 @@ dependencies = [
  "regex",
  "rustc-hash",
  "shlex",
- "which 3.1.1",
+ "which",
 ]
 
 [[package]]
@@ -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"
@@ -639,11 +650,11 @@ dependencies = [
 
 [[package]]
 name = "cexpr"
-version = "0.4.0"
+version = "0.6.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f4aedb84272dbe89af497cf81375129abda4fc0a9e7c5d317498c15cc30c0d27"
+checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
 dependencies = [
- "nom 5.1.2",
+ "nom 7.1.1",
 ]
 
 [[package]]
@@ -956,6 +967,7 @@ version = "0.1.0"
 dependencies = [
  "anyhow",
  "client",
+ "collections",
  "editor",
  "futures",
  "fuzzy",
@@ -1444,9 +1456,9 @@ dependencies = [
 
 [[package]]
 name = "env_logger"
-version = "0.8.3"
+version = "0.9.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "17392a012ea30ef05a610aa97dfb49496e71c9f676b27879922ea5bdf60d9d3f"
+checksum = "0b2cf0344971ee6c64c31be0d530793fba457d322dfec2810c453d0ef228f9c3"
 dependencies = [
  "atty",
  "humantime",
@@ -1539,9 +1551,9 @@ dependencies = [
 
 [[package]]
 name = "fixedbitset"
-version = "0.2.0"
+version = "0.4.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "37ab347416e802de484e4d03c7316c48f1ecb56574dfd4a46a80f173ce1de04d"
+checksum = "279fb028e20b3c4c320317955b77c5e0c9701f05a1d309905d6fc702cdc5053e"
 
 [[package]]
 name = "flate2"
@@ -1987,12 +1999,6 @@ dependencies = [
  "tracing",
 ]
 
-[[package]]
-name = "hashbrown"
-version = "0.9.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d7afe4a420e3fe79967a00898cc1f4db7c8a49a9333a29f8a4bd76a253d5cd04"
-
 [[package]]
 name = "hashbrown"
 version = "0.11.2"
@@ -2008,7 +2014,7 @@ version = "0.7.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7249a3129cbc1ffccd74857f81464a323a152173cdb134e0fd81bc803b29facf"
 dependencies = [
- "hashbrown 0.11.2",
+ "hashbrown",
 ]
 
 [[package]]
@@ -2240,12 +2246,12 @@ dependencies = [
 
 [[package]]
 name = "indexmap"
-version = "1.6.2"
+version = "1.8.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "824845a0bf897a9042383849b02c1bc219c2383772efcd5c6f9766fa4b81aef3"
+checksum = "e6012d540c5baa3589337a98ce73408de9b5a25ec9fc2c6fd6be8f0d39e0ca5a"
 dependencies = [
  "autocfg 1.0.1",
- "hashbrown 0.9.1",
+ "hashbrown",
 ]
 
 [[package]]
@@ -2481,9 +2487,9 @@ dependencies = [
 
 [[package]]
 name = "libc"
-version = "0.2.119"
+version = "0.2.126"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1bf2e165bb3457c8e098ea76f3e3bc9db55f87aa90d52d0e6be741470916aaa4"
+checksum = "349d5a591cd28b49e1d1037471617a32ddcda5731b99419008085f72d5a53836"
 
 [[package]]
 name = "libloading"
@@ -2511,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"
@@ -2721,6 +2742,12 @@ version = "0.3.16"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d"
 
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
 [[package]]
 name = "miniz_oxide"
 version = "0.3.7"
@@ -2849,25 +2876,25 @@ dependencies = [
 
 [[package]]
 name = "nom"
-version = "5.1.2"
+version = "6.1.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ffb4262d26ed83a1c0a33a38fe2bb15797329c85770da05e6b828ddb782627af"
+checksum = "e7413f999671bd4745a7b624bd370a569fb6bc574b23c83a3c5ed2e453f3d5e2"
 dependencies = [
+ "bitvec",
+ "funty",
+ "lexical-core",
  "memchr",
  "version_check",
 ]
 
 [[package]]
 name = "nom"
-version = "6.1.2"
+version = "7.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e7413f999671bd4745a7b624bd370a569fb6bc574b23c83a3c5ed2e453f3d5e2"
+checksum = "a8903e5a29a317527874d0402f867152a3d21c908bb0b933e416c65e301d4c36"
 dependencies = [
- "bitvec",
- "funty",
- "lexical-core",
  "memchr",
- "version_check",
+ "minimal-lexical",
 ]
 
 [[package]]
@@ -3190,9 +3217,9 @@ checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e"
 
 [[package]]
 name = "petgraph"
-version = "0.5.1"
+version = "0.6.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "467d164a6de56270bd7c4d070df81d07beace25012d5103ced4e9ff08d6afdb7"
+checksum = "e6d5014253a1331579ce62aa67443b4a658c5e7dd03d4bc6d302b94474888143"
 dependencies = [
  "fixedbitset",
  "indexmap",
@@ -3394,6 +3421,7 @@ dependencies = [
  "postage",
  "rand 0.8.3",
  "regex",
+ "rocksdb",
  "rpc",
  "serde",
  "serde_json",
@@ -3473,20 +3501,22 @@ dependencies = [
 
 [[package]]
 name = "prost-build"
-version = "0.8.0"
+version = "0.9.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "355f634b43cdd80724ee7848f95770e7e70eefa6dcf14fea676216573b8fd603"
+checksum = "62941722fb675d463659e49c4f3fe1fe792ff24fe5bbaa9c08cd3b98a1c354f5"
 dependencies = [
  "bytes",
  "heck 0.3.3",
  "itertools",
+ "lazy_static",
  "log",
  "multimap",
  "petgraph",
- "prost 0.8.0",
+ "prost 0.9.0",
  "prost-types",
+ "regex",
  "tempfile",
- "which 4.1.0",
+ "which",
 ]
 
 [[package]]
@@ -3517,12 +3547,12 @@ dependencies = [
 
 [[package]]
 name = "prost-types"
-version = "0.8.0"
+version = "0.9.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "603bbd6394701d13f3f25aada59c7de9d35a6a5887cfc156181234a44002771b"
+checksum = "534b7a0e836e3c482d2693070f982e39e7611da9695d4d1f5a4b186b51faef0a"
 dependencies = [
  "bytes",
- "prost 0.8.0",
+ "prost 0.9.0",
 ]
 
 [[package]]
@@ -3710,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",
@@ -3730,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"
@@ -3819,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"
@@ -5821,20 +5861,12 @@ dependencies = [
 
 [[package]]
 name = "which"
-version = "3.1.1"
+version = "4.2.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d011071ae14a2f6671d0b74080ae0cd8ebf3a6f8c9589a2cd45f23126fe29724"
-dependencies = [
- "libc",
-]
-
-[[package]]
-name = "which"
-version = "4.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b55551e42cbdf2ce2bedd2203d0cc08dba002c27510f86dab6d0ce304cba3dfe"
+checksum = "5c4fb54e6113b6a8772ee41c3404fb0301ac79604489467e0a9ce1f3e97c24ae"
 dependencies = [
  "either",
+ "lazy_static",
  "libc",
 ]
 

assets/icons/lock-8.svg 🔗

@@ -0,0 +1,3 @@
+<svg width="8" height="8" viewBox="0 0 8 8" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M1.75 3V2.25C1.75 1.00734 2.75781 0 4 0C5.24219 0 6.25 1.00734 6.25 2.25V3H6.5C7.05156 3 7.5 3.44844 7.5 4V7C7.5 7.55156 7.05156 8 6.5 8H1.5C0.947656 8 0.5 7.55156 0.5 7V4C0.5 3.44844 0.947656 3 1.5 3H1.75ZM2.75 3H5.25V2.25C5.25 1.55969 4.69063 1 4 1C3.30938 1 2.75 1.55969 2.75 2.25V3Z" fill="#8B8792"/>
+</svg>

crates/client/src/client.rs 🔗

@@ -67,17 +67,23 @@ pub struct Client {
     peer: Arc<Peer>,
     http: Arc<dyn HttpClient>,
     state: RwLock<ClientState>,
-    authenticate:
+
+    #[cfg(any(test, feature = "test-support"))]
+    authenticate: RwLock<
         Option<Box<dyn 'static + Send + Sync + Fn(&AsyncAppContext) -> Task<Result<Credentials>>>>,
-    establish_connection: Option<
-        Box<
-            dyn 'static
-                + Send
-                + Sync
-                + Fn(
-                    &Credentials,
-                    &AsyncAppContext,
-                ) -> Task<Result<Connection, EstablishConnectionError>>,
+    >,
+    #[cfg(any(test, feature = "test-support"))]
+    establish_connection: RwLock<
+        Option<
+            Box<
+                dyn 'static
+                    + Send
+                    + Sync
+                    + Fn(
+                        &Credentials,
+                        &AsyncAppContext,
+                    ) -> Task<Result<Connection, EstablishConnectionError>>,
+            >,
         >,
     >,
 }
@@ -235,8 +241,11 @@ impl Client {
             peer: Peer::new(),
             http,
             state: Default::default(),
-            authenticate: None,
-            establish_connection: None,
+
+            #[cfg(any(test, feature = "test-support"))]
+            authenticate: Default::default(),
+            #[cfg(any(test, feature = "test-support"))]
+            establish_connection: Default::default(),
         })
     }
 
@@ -260,23 +269,23 @@ impl Client {
     }
 
     #[cfg(any(test, feature = "test-support"))]
-    pub fn override_authenticate<F>(&mut self, authenticate: F) -> &mut Self
+    pub fn override_authenticate<F>(&self, authenticate: F) -> &Self
     where
         F: 'static + Send + Sync + Fn(&AsyncAppContext) -> Task<Result<Credentials>>,
     {
-        self.authenticate = Some(Box::new(authenticate));
+        *self.authenticate.write() = Some(Box::new(authenticate));
         self
     }
 
     #[cfg(any(test, feature = "test-support"))]
-    pub fn override_establish_connection<F>(&mut self, connect: F) -> &mut Self
+    pub fn override_establish_connection<F>(&self, connect: F) -> &Self
     where
         F: 'static
             + Send
             + Sync
             + Fn(&Credentials, &AsyncAppContext) -> Task<Result<Connection, EstablishConnectionError>>,
     {
-        self.establish_connection = Some(Box::new(connect));
+        *self.establish_connection.write() = Some(Box::new(connect));
         self
     }
 
@@ -755,11 +764,12 @@ impl Client {
     }
 
     fn authenticate(self: &Arc<Self>, cx: &AsyncAppContext) -> Task<Result<Credentials>> {
-        if let Some(callback) = self.authenticate.as_ref() {
-            callback(cx)
-        } else {
-            self.authenticate_with_browser(cx)
+        #[cfg(any(test, feature = "test-support"))]
+        if let Some(callback) = self.authenticate.read().as_ref() {
+            return callback(cx);
         }
+
+        self.authenticate_with_browser(cx)
     }
 
     fn establish_connection(
@@ -767,11 +777,12 @@ impl Client {
         credentials: &Credentials,
         cx: &AsyncAppContext,
     ) -> Task<Result<Connection, EstablishConnectionError>> {
-        if let Some(callback) = self.establish_connection.as_ref() {
-            callback(credentials, cx)
-        } else {
-            self.establish_websocket_connection(credentials, cx)
+        #[cfg(any(test, feature = "test-support"))]
+        if let Some(callback) = self.establish_connection.read().as_ref() {
+            return callback(credentials, cx);
         }
+
+        self.establish_websocket_connection(credentials, cx)
     }
 
     fn establish_websocket_connection(

crates/client/src/test.rs 🔗

@@ -28,7 +28,7 @@ struct FakeServerState {
 impl FakeServer {
     pub async fn for_client(
         client_user_id: u64,
-        client: &mut Arc<Client>,
+        client: &Arc<Client>,
         cx: &TestAppContext,
     ) -> Self {
         let server = Self {
@@ -38,8 +38,7 @@ impl FakeServer {
             executor: cx.foreground(),
         };
 
-        Arc::get_mut(client)
-            .unwrap()
+        client
             .override_authenticate({
                 let state = Arc::downgrade(&server.state);
                 move |cx| {
@@ -179,6 +178,12 @@ impl FakeServer {
     }
 }
 
+impl Drop for FakeServer {
+    fn drop(&mut self) {
+        self.disconnect();
+    }
+}
+
 pub struct FakeHttpClient {
     handler: Box<
         dyn 'static

crates/client/src/user.rs 🔗

@@ -42,7 +42,7 @@ pub struct Contact {
     pub projects: Vec<ProjectMetadata>,
 }
 
-#[derive(Debug)]
+#[derive(Clone, Debug, PartialEq)]
 pub struct ProjectMetadata {
     pub id: u64,
     pub worktree_root_names: Vec<String>,
@@ -99,6 +99,7 @@ impl Entity for UserStore {
 
 enum UpdateContacts {
     Update(proto::UpdateContacts),
+    Wait(postage::barrier::Sender),
     Clear(postage::barrier::Sender),
 }
 
@@ -217,6 +218,10 @@ impl UserStore {
         cx: &mut ModelContext<Self>,
     ) -> Task<Result<()>> {
         match message {
+            UpdateContacts::Wait(barrier) => {
+                drop(barrier);
+                Task::ready(Ok(()))
+            }
             UpdateContacts::Clear(barrier) => {
                 self.contacts.clear();
                 self.incoming_contact_requests.clear();
@@ -497,6 +502,16 @@ impl UserStore {
         }
     }
 
+    pub fn contact_updates_done(&mut self) -> impl Future<Output = ()> {
+        let (tx, mut rx) = postage::barrier::channel();
+        self.update_contacts_tx
+            .unbounded_send(UpdateContacts::Wait(tx))
+            .unwrap();
+        async move {
+            rx.recv().await;
+        }
+    }
+
     pub fn get_users(
         &mut self,
         mut user_ids: Vec<u64>,

crates/collab/Cargo.toml 🔗

@@ -65,7 +65,7 @@ settings = { path = "../settings", features = ["test-support"] }
 theme = { path = "../theme" }
 workspace = { path = "../workspace", features = ["test-support"] }
 ctor = "0.1"
-env_logger = "0.8"
+env_logger = "0.9"
 util = { path = "../util" }
 lazy_static = "1.4"
 serde_json = { version = "1.0", features = ["preserve_order"] }

crates/collab/src/integration_tests.rs 🔗

@@ -7,7 +7,7 @@ use ::rpc::Peer;
 use anyhow::anyhow;
 use client::{
     self, proto, test::FakeHttpClient, Channel, ChannelDetails, ChannelList, Client, Connection,
-    Credentials, EstablishConnectionError, UserStore, RECEIVE_TIMEOUT,
+    Credentials, EstablishConnectionError, ProjectMetadata, UserStore, RECEIVE_TIMEOUT,
 };
 use collections::{BTreeMap, HashMap, HashSet};
 use editor::{
@@ -30,7 +30,7 @@ use project::{
     fs::{FakeFs, Fs as _},
     search::SearchQuery,
     worktree::WorktreeHandle,
-    DiagnosticSummary, Project, ProjectPath, WorktreeId,
+    DiagnosticSummary, Project, ProjectPath, ProjectStore, WorktreeId,
 };
 use rand::prelude::*;
 use rpc::PeerId;
@@ -70,28 +70,29 @@ async fn test_share_project(
     cx_a.foreground().forbid_parking();
     let (window_b, _) = cx_b.add_window(|_| EmptyView);
     let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
-    let mut client_a = server.create_client(cx_a, "user_a").await;
-    let mut client_b = server.create_client(cx_b, "user_b").await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
     server
         .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)])
         .await;
 
-    let fs = FakeFs::new(cx_a.background());
-    fs.insert_tree(
-        "/a",
-        json!({
-            ".gitignore": "ignored-dir",
-            "a.txt": "a-contents",
-            "b.txt": "b-contents",
-            "ignored-dir": {
-                "c.txt": "",
-                "d.txt": "",
-            }
-        }),
-    )
-    .await;
+    client_a
+        .fs
+        .insert_tree(
+            "/a",
+            json!({
+                ".gitignore": "ignored-dir",
+                "a.txt": "a-contents",
+                "b.txt": "b-contents",
+                "ignored-dir": {
+                    "c.txt": "",
+                    "d.txt": "",
+                }
+            }),
+        )
+        .await;
 
-    let (project_a, worktree_id) = client_a.build_local_project(fs, "/a", cx_a).await;
+    let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await;
     let project_id = project_a.read_with(cx_a, |project, _| project.remote_id().unwrap());
 
     // Join that project as client B
@@ -174,9 +175,10 @@ async fn test_share_project(
         project_id,
         client_b2.client.clone(),
         client_b2.user_store.clone(),
+        client_b2.project_store.clone(),
         client_b2.language_registry.clone(),
         FakeFs::new(cx_b2.background()),
-        &mut cx_b2.to_async(),
+        cx_b2.to_async(),
     )
     .await
     .unwrap();
@@ -192,10 +194,7 @@ async fn test_share_project(
     });
 
     // Dropping client B's first project removes only that from client A's collaborators.
-    cx_b.update(move |_| {
-        drop(client_b.project.take());
-        drop(project_b);
-    });
+    cx_b.update(move |_| drop(project_b));
     deterministic.run_until_parked();
     project_a.read_with(cx_a, |project, _| {
         assert_eq!(project.collaborators().len(), 1);
@@ -213,23 +212,24 @@ async fn test_unshare_project(
 ) {
     cx_a.foreground().forbid_parking();
     let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
-    let mut client_a = server.create_client(cx_a, "user_a").await;
-    let mut client_b = server.create_client(cx_b, "user_b").await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
     server
         .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)])
         .await;
 
-    let fs = FakeFs::new(cx_a.background());
-    fs.insert_tree(
-        "/a",
-        json!({
-            "a.txt": "a-contents",
-            "b.txt": "b-contents",
-        }),
-    )
-    .await;
+    client_a
+        .fs
+        .insert_tree(
+            "/a",
+            json!({
+                "a.txt": "a-contents",
+                "b.txt": "b-contents",
+            }),
+        )
+        .await;
 
-    let (project_a, worktree_id) = client_a.build_local_project(fs, "/a", cx_a).await;
+    let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await;
     let worktree_a = project_a.read_with(cx_a, |project, cx| project.worktrees(cx).next().unwrap());
     let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await;
     assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared()));
@@ -240,10 +240,7 @@ async fn test_unshare_project(
         .unwrap();
 
     // When client B leaves the project, it gets automatically unshared.
-    cx_b.update(|_| {
-        drop(client_b.project.take());
-        drop(project_b);
-    });
+    cx_b.update(|_| drop(project_b));
     deterministic.run_until_parked();
     assert!(worktree_a.read_with(cx_a, |tree, _| !tree.as_local().unwrap().is_shared()));
 
@@ -256,10 +253,7 @@ async fn test_unshare_project(
         .unwrap();
 
     // When client A (the host) leaves, the project gets unshared and guests are notified.
-    cx_a.update(|_| {
-        drop(project_a);
-        client_a.project.take();
-    });
+    cx_a.update(|_| drop(project_a));
     deterministic.run_until_parked();
     project_b2.read_with(cx_b, |project, _| {
         assert!(project.is_read_only());
@@ -276,8 +270,8 @@ async fn test_host_disconnect(
 ) {
     cx_a.foreground().forbid_parking();
     let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
-    let mut client_a = server.create_client(cx_a, "user_a").await;
-    let mut client_b = server.create_client(cx_b, "user_b").await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
     let client_c = server.create_client(cx_c, "user_c").await;
     server
         .make_contacts(vec![
@@ -287,17 +281,18 @@ async fn test_host_disconnect(
         ])
         .await;
 
-    let fs = FakeFs::new(cx_a.background());
-    fs.insert_tree(
-        "/a",
-        json!({
-            "a.txt": "a-contents",
-            "b.txt": "b-contents",
-        }),
-    )
-    .await;
+    client_a
+        .fs
+        .insert_tree(
+            "/a",
+            json!({
+                "a.txt": "a-contents",
+                "b.txt": "b-contents",
+            }),
+        )
+        .await;
 
-    let (project_a, worktree_id) = client_a.build_local_project(fs, "/a", cx_a).await;
+    let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await;
     let worktree_a = project_a.read_with(cx_a, |project, cx| project.worktrees(cx).next().unwrap());
     let project_id = project_a.read_with(cx_a, |project, _| project.remote_id().unwrap());
 
@@ -310,16 +305,16 @@ async fn test_host_disconnect(
         .unwrap();
 
     // Request to join that project as client C
-    let project_c = cx_c.spawn(|mut cx| async move {
+    let project_c = cx_c.spawn(|cx| {
         Project::remote(
             project_id,
             client_c.client.clone(),
             client_c.user_store.clone(),
+            client_c.project_store.clone(),
             client_c.language_registry.clone(),
             FakeFs::new(cx.background()),
-            &mut cx,
+            cx,
         )
-        .await
     });
     deterministic.run_until_parked();
 
@@ -359,34 +354,28 @@ async fn test_decline_join_request(
 ) {
     cx_a.foreground().forbid_parking();
     let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
-    let mut client_a = server.create_client(cx_a, "user_a").await;
+    let client_a = server.create_client(cx_a, "user_a").await;
     let client_b = server.create_client(cx_b, "user_b").await;
     server
         .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)])
         .await;
 
-    let fs = FakeFs::new(cx_a.background());
-    fs.insert_tree("/a", json!({})).await;
+    client_a.fs.insert_tree("/a", json!({})).await;
 
-    let (project_a, _) = client_a.build_local_project(fs, "/a", cx_a).await;
+    let (project_a, _) = client_a.build_local_project("/a", cx_a).await;
     let project_id = project_a.read_with(cx_a, |project, _| project.remote_id().unwrap());
 
     // Request to join that project as client B
-    let project_b = cx_b.spawn(|mut cx| {
-        let client = client_b.client.clone();
-        let user_store = client_b.user_store.clone();
-        let language_registry = client_b.language_registry.clone();
-        async move {
-            Project::remote(
-                project_id,
-                client,
-                user_store,
-                language_registry,
-                FakeFs::new(cx.background()),
-                &mut cx,
-            )
-            .await
-        }
+    let project_b = cx_b.spawn(|cx| {
+        Project::remote(
+            project_id,
+            client_b.client.clone(),
+            client_b.user_store.clone(),
+            client_b.project_store.clone(),
+            client_b.language_registry.clone(),
+            FakeFs::new(cx.background()),
+            cx,
+        )
     });
     deterministic.run_until_parked();
     project_a.update(cx_a, |project, cx| {
@@ -398,28 +387,21 @@ async fn test_decline_join_request(
     ));
 
     // Request to join the project again as client B
-    let project_b = cx_b.spawn(|mut cx| {
-        let client = client_b.client.clone();
-        let user_store = client_b.user_store.clone();
-        async move {
-            Project::remote(
-                project_id,
-                client,
-                user_store,
-                client_b.language_registry.clone(),
-                FakeFs::new(cx.background()),
-                &mut cx,
-            )
-            .await
-        }
+    let project_b = cx_b.spawn(|cx| {
+        Project::remote(
+            project_id,
+            client_b.client.clone(),
+            client_b.user_store.clone(),
+            client_b.project_store.clone(),
+            client_b.language_registry.clone(),
+            FakeFs::new(cx.background()),
+            cx,
+        )
     });
 
     // Close the project on the host
     deterministic.run_until_parked();
-    cx_a.update(|_| {
-        drop(project_a);
-        client_a.project.take();
-    });
+    cx_a.update(|_| drop(project_a));
     deterministic.run_until_parked();
     assert!(matches!(
         project_b.await.unwrap_err(),
@@ -435,16 +417,14 @@ async fn test_cancel_join_request(
 ) {
     cx_a.foreground().forbid_parking();
     let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
-    let mut client_a = server.create_client(cx_a, "user_a").await;
+    let client_a = server.create_client(cx_a, "user_a").await;
     let client_b = server.create_client(cx_b, "user_b").await;
     server
         .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)])
         .await;
 
-    let fs = FakeFs::new(cx_a.background());
-    fs.insert_tree("/a", json!({})).await;
-
-    let (project_a, _) = client_a.build_local_project(fs, "/a", cx_a).await;
+    client_a.fs.insert_tree("/a", json!({})).await;
+    let (project_a, _) = client_a.build_local_project("/a", cx_a).await;
     let project_id = project_a.read_with(cx_a, |project, _| project.remote_id().unwrap());
 
     let user_b = client_a
@@ -467,21 +447,16 @@ async fn test_cancel_join_request(
     });
 
     // Request to join that project as client B
-    let project_b = cx_b.spawn(|mut cx| {
-        let client = client_b.client.clone();
-        let user_store = client_b.user_store.clone();
-        let language_registry = client_b.language_registry.clone();
-        async move {
-            Project::remote(
-                project_id,
-                client,
-                user_store,
-                language_registry.clone(),
-                FakeFs::new(cx.background()),
-                &mut cx,
-            )
-            .await
-        }
+    let project_b = cx_b.spawn(|cx| {
+        Project::remote(
+            project_id,
+            client_b.client.clone(),
+            client_b.user_store.clone(),
+            client_b.project_store.clone(),
+            client_b.language_registry.clone().clone(),
+            FakeFs::new(cx.background()),
+            cx,
+        )
     });
     deterministic.run_until_parked();
     assert_eq!(
@@ -504,6 +479,241 @@ async fn test_cancel_join_request(
     );
 }
 
+#[gpui::test(iterations = 10)]
+async fn test_offline_projects(
+    deterministic: Arc<Deterministic>,
+    cx_a: &mut TestAppContext,
+    cx_b: &mut TestAppContext,
+) {
+    cx_a.foreground().forbid_parking();
+    let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
+    let user_a = UserId::from_proto(client_a.user_id().unwrap());
+    server
+        .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)])
+        .await;
+
+    // Set up observers of the project and user stores. Any time either of
+    // these models update, they should be in a consistent state with each
+    // other. There should not be an observable moment where the current
+    // user's contact entry contains a project that does not match one of
+    // the current open projects. That would cause a duplicate entry to be
+    // shown in the contacts panel.
+    let mut subscriptions = vec![];
+    let (window_id, view) = cx_a.add_window(|cx| {
+        subscriptions.push(cx.observe(&client_a.user_store, {
+            let project_store = client_a.project_store.clone();
+            let user_store = client_a.user_store.clone();
+            move |_, _, cx| check_project_list(project_store.clone(), user_store.clone(), cx)
+        }));
+
+        subscriptions.push(cx.observe(&client_a.project_store, {
+            let project_store = client_a.project_store.clone();
+            let user_store = client_a.user_store.clone();
+            move |_, _, cx| check_project_list(project_store.clone(), user_store.clone(), cx)
+        }));
+
+        fn check_project_list(
+            project_store: ModelHandle<ProjectStore>,
+            user_store: ModelHandle<UserStore>,
+            cx: &mut gpui::MutableAppContext,
+        ) {
+            let open_project_ids = project_store
+                .read(cx)
+                .projects(cx)
+                .filter_map(|project| project.read(cx).remote_id())
+                .collect::<Vec<_>>();
+
+            let user_store = user_store.read(cx);
+            for contact in user_store.contacts() {
+                if contact.user.id == user_store.current_user().unwrap().id {
+                    for project in &contact.projects {
+                        if !open_project_ids.contains(&project.id) {
+                            panic!(
+                                concat!(
+                                    "current user's contact data has a project",
+                                    "that doesn't match any open project {:?}",
+                                ),
+                                project
+                            );
+                        }
+                    }
+                }
+            }
+        }
+
+        EmptyView
+    });
+
+    // Build an offline project with two worktrees.
+    client_a
+        .fs
+        .insert_tree(
+            "/code",
+            json!({
+                "crate1": { "a.rs": "" },
+                "crate2": { "b.rs": "" },
+            }),
+        )
+        .await;
+    let project = cx_a.update(|cx| {
+        Project::local(
+            false,
+            client_a.client.clone(),
+            client_a.user_store.clone(),
+            client_a.project_store.clone(),
+            client_a.language_registry.clone(),
+            client_a.fs.clone(),
+            cx,
+        )
+    });
+    project
+        .update(cx_a, |p, cx| {
+            p.find_or_create_local_worktree("/code/crate1", true, cx)
+        })
+        .await
+        .unwrap();
+    project
+        .update(cx_a, |p, cx| {
+            p.find_or_create_local_worktree("/code/crate2", true, cx)
+        })
+        .await
+        .unwrap();
+    project
+        .update(cx_a, |p, cx| p.restore_state(cx))
+        .await
+        .unwrap();
+
+    // When a project is offline, no information about it is sent to the server.
+    deterministic.run_until_parked();
+    assert!(server
+        .store
+        .read()
+        .await
+        .project_metadata_for_user(user_a)
+        .is_empty());
+    assert!(project.read_with(cx_a, |project, _| project.remote_id().is_none()));
+    assert!(client_b
+        .user_store
+        .read_with(cx_b, |store, _| { store.contacts()[0].projects.is_empty() }));
+
+    // When the project is taken online, its metadata is sent to the server
+    // and broadcasted to other users.
+    project.update(cx_a, |p, cx| p.set_online(true, cx));
+    deterministic.run_until_parked();
+    let project_id = project.read_with(cx_a, |p, _| p.remote_id()).unwrap();
+    client_b.user_store.read_with(cx_b, |store, _| {
+        assert_eq!(
+            store.contacts()[0].projects,
+            &[ProjectMetadata {
+                id: project_id,
+                worktree_root_names: vec!["crate1".into(), "crate2".into()],
+                guests: Default::default(),
+            }]
+        );
+    });
+
+    // The project is registered again when the host loses and regains connection.
+    server.disconnect_client(user_a);
+    server.forbid_connections();
+    cx_a.foreground().advance_clock(rpc::RECEIVE_TIMEOUT);
+    assert!(server
+        .store
+        .read()
+        .await
+        .project_metadata_for_user(user_a)
+        .is_empty());
+    assert!(project.read_with(cx_a, |p, _| p.remote_id().is_none()));
+    assert!(client_b
+        .user_store
+        .read_with(cx_b, |store, _| { store.contacts()[0].projects.is_empty() }));
+
+    server.allow_connections();
+    cx_b.foreground().advance_clock(Duration::from_secs(10));
+    let project_id = project.read_with(cx_a, |p, _| p.remote_id()).unwrap();
+    client_b.user_store.read_with(cx_b, |store, _| {
+        assert_eq!(
+            store.contacts()[0].projects,
+            &[ProjectMetadata {
+                id: project_id,
+                worktree_root_names: vec!["crate1".into(), "crate2".into()],
+                guests: Default::default(),
+            }]
+        );
+    });
+
+    project
+        .update(cx_a, |p, cx| {
+            p.find_or_create_local_worktree("/code/crate3", true, cx)
+        })
+        .await
+        .unwrap();
+    deterministic.run_until_parked();
+    client_b.user_store.read_with(cx_b, |store, _| {
+        assert_eq!(
+            store.contacts()[0].projects,
+            &[ProjectMetadata {
+                id: project_id,
+                worktree_root_names: vec!["crate1".into(), "crate2".into(), "crate3".into()],
+                guests: Default::default(),
+            }]
+        );
+    });
+
+    // Build another project using a directory which was previously part of
+    // an online project. Restore the project's state from the host's database.
+    let project2 = cx_a.update(|cx| {
+        Project::local(
+            false,
+            client_a.client.clone(),
+            client_a.user_store.clone(),
+            client_a.project_store.clone(),
+            client_a.language_registry.clone(),
+            client_a.fs.clone(),
+            cx,
+        )
+    });
+    project2
+        .update(cx_a, |p, cx| {
+            p.find_or_create_local_worktree("/code/crate3", true, cx)
+        })
+        .await
+        .unwrap();
+    project2
+        .update(cx_a, |project, cx| project.restore_state(cx))
+        .await
+        .unwrap();
+
+    // This project is now online, because its directory was previously online.
+    project2.read_with(cx_a, |project, _| assert!(project.is_online()));
+    deterministic.run_until_parked();
+    let project2_id = project2.read_with(cx_a, |p, _| p.remote_id()).unwrap();
+    client_b.user_store.read_with(cx_b, |store, _| {
+        assert_eq!(
+            store.contacts()[0].projects,
+            &[
+                ProjectMetadata {
+                    id: project_id,
+                    worktree_root_names: vec!["crate1".into(), "crate2".into(), "crate3".into()],
+                    guests: Default::default(),
+                },
+                ProjectMetadata {
+                    id: project2_id,
+                    worktree_root_names: vec!["crate3".into()],
+                    guests: Default::default(),
+                }
+            ]
+        );
+    });
+
+    cx_a.update(|cx| {
+        drop(subscriptions);
+        drop(view);
+        cx.remove_window(window_id);
+    });
+}
+
 #[gpui::test(iterations = 10)]
 async fn test_propagate_saves_and_fs_changes(
     cx_a: &mut TestAppContext,
@@ -512,9 +722,9 @@ async fn test_propagate_saves_and_fs_changes(
 ) {
     cx_a.foreground().forbid_parking();
     let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
-    let mut client_a = server.create_client(cx_a, "user_a").await;
-    let mut client_b = server.create_client(cx_b, "user_b").await;
-    let mut client_c = server.create_client(cx_c, "user_c").await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
+    let client_c = server.create_client(cx_c, "user_c").await;
     server
         .make_contacts(vec![
             (&client_a, cx_a),
@@ -523,17 +733,17 @@ async fn test_propagate_saves_and_fs_changes(
         ])
         .await;
 
-    let fs = FakeFs::new(cx_a.background());
-    fs.insert_tree(
-        "/a",
-        json!({
-            "file1": "",
-            "file2": ""
-        }),
-    )
-    .await;
-
-    let (project_a, worktree_id) = client_a.build_local_project(fs.clone(), "/a", cx_a).await;
+    client_a
+        .fs
+        .insert_tree(
+            "/a",
+            json!({
+                "file1": "",
+                "file2": ""
+            }),
+        )
+        .await;
+    let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await;
     let worktree_a = project_a.read_with(cx_a, |p, cx| p.worktrees(cx).next().unwrap());
 
     // Join that worktree as clients B and C.
@@ -583,7 +793,7 @@ async fn test_propagate_saves_and_fs_changes(
     buffer_a.update(cx_a, |buf, cx| buf.edit([(0..0, "hi-a, ")], cx));
     save_b.await.unwrap();
     assert_eq!(
-        fs.load("/a/file1".as_ref()).await.unwrap(),
+        client_a.fs.load("/a/file1".as_ref()).await.unwrap(),
         "hi-a, i-am-c, i-am-b, i-am-a"
     );
     buffer_a.read_with(cx_a, |buf, _| assert!(!buf.is_dirty()));
@@ -593,18 +803,22 @@ async fn test_propagate_saves_and_fs_changes(
     worktree_a.flush_fs_events(cx_a).await;
 
     // Make changes on host's file system, see those changes on guest worktrees.
-    fs.rename(
-        "/a/file1".as_ref(),
-        "/a/file1-renamed".as_ref(),
-        Default::default(),
-    )
-    .await
-    .unwrap();
+    client_a
+        .fs
+        .rename(
+            "/a/file1".as_ref(),
+            "/a/file1-renamed".as_ref(),
+            Default::default(),
+        )
+        .await
+        .unwrap();
 
-    fs.rename("/a/file2".as_ref(), "/a/file3".as_ref(), Default::default())
+    client_a
+        .fs
+        .rename("/a/file2".as_ref(), "/a/file3".as_ref(), Default::default())
         .await
         .unwrap();
-    fs.insert_file(Path::new("/a/file4"), "4".into()).await;
+    client_a.fs.insert_file("/a/file4", "4".into()).await;
 
     worktree_a
         .condition(&cx_a, |tree, _| {
@@ -656,27 +870,24 @@ async fn test_fs_operations(
     cx_b: &mut TestAppContext,
 ) {
     executor.forbid_parking();
-    let fs = FakeFs::new(cx_a.background());
-
-    // Connect to a server as 2 clients.
     let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
-    let mut client_a = server.create_client(cx_a, "user_a").await;
-    let mut client_b = server.create_client(cx_b, "user_b").await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
     server
         .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)])
         .await;
 
-    // Share a project as client A
-    fs.insert_tree(
-        "/dir",
-        json!({
-            "a.txt": "a-contents",
-            "b.txt": "b-contents",
-        }),
-    )
-    .await;
-
-    let (project_a, worktree_id) = client_a.build_local_project(fs, "/dir", cx_a).await;
+    client_a
+        .fs
+        .insert_tree(
+            "/dir",
+            json!({
+                "a.txt": "a-contents",
+                "b.txt": "b-contents",
+            }),
+        )
+        .await;
+    let (project_a, worktree_id) = client_a.build_local_project("/dir", cx_a).await;
     let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await;
 
     let worktree_a = project_a.read_with(cx_a, |project, cx| project.worktrees(cx).next().unwrap());
@@ -921,22 +1132,22 @@ async fn test_fs_operations(
 async fn test_buffer_conflict_after_save(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
     cx_a.foreground().forbid_parking();
     let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
-    let mut client_a = server.create_client(cx_a, "user_a").await;
-    let mut client_b = server.create_client(cx_b, "user_b").await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
     server
         .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)])
         .await;
 
-    let fs = FakeFs::new(cx_a.background());
-    fs.insert_tree(
-        "/dir",
-        json!({
-            "a.txt": "a-contents",
-        }),
-    )
-    .await;
-
-    let (project_a, worktree_id) = client_a.build_local_project(fs, "/dir", cx_a).await;
+    client_a
+        .fs
+        .insert_tree(
+            "/dir",
+            json!({
+                "a.txt": "a-contents",
+            }),
+        )
+        .await;
+    let (project_a, worktree_id) = client_a.build_local_project("/dir", cx_a).await;
     let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await;
 
     // Open a buffer as client B
@@ -970,22 +1181,22 @@ async fn test_buffer_conflict_after_save(cx_a: &mut TestAppContext, cx_b: &mut T
 async fn test_buffer_reloading(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
     cx_a.foreground().forbid_parking();
     let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
-    let mut client_a = server.create_client(cx_a, "user_a").await;
-    let mut client_b = server.create_client(cx_b, "user_b").await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
     server
         .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)])
         .await;
 
-    let fs = FakeFs::new(cx_a.background());
-    fs.insert_tree(
-        "/dir",
-        json!({
-            "a.txt": "a-contents",
-        }),
-    )
-    .await;
-
-    let (project_a, worktree_id) = client_a.build_local_project(fs.clone(), "/dir", cx_a).await;
+    client_a
+        .fs
+        .insert_tree(
+            "/dir",
+            json!({
+                "a.txt": "a-contents",
+            }),
+        )
+        .await;
+    let (project_a, worktree_id) = client_a.build_local_project("/dir", cx_a).await;
     let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await;
 
     // Open a buffer as client B
@@ -998,7 +1209,9 @@ async fn test_buffer_reloading(cx_a: &mut TestAppContext, cx_b: &mut TestAppCont
         assert!(!buf.has_conflict());
     });
 
-    fs.save(Path::new("/dir/a.txt"), &"new contents".into())
+    client_a
+        .fs
+        .save("/dir/a.txt".as_ref(), &"new contents".into())
         .await
         .unwrap();
     buffer_b
@@ -1006,9 +1219,7 @@ async fn test_buffer_reloading(cx_a: &mut TestAppContext, cx_b: &mut TestAppCont
             buf.text() == "new contents" && !buf.is_dirty()
         })
         .await;
-    buffer_b.read_with(cx_b, |buf, _| {
-        assert!(!buf.has_conflict());
-    });
+    buffer_b.read_with(cx_b, |buf, _| assert!(!buf.has_conflict()));
 }
 
 #[gpui::test(iterations = 10)]
@@ -1018,22 +1229,17 @@ async fn test_editing_while_guest_opens_buffer(
 ) {
     cx_a.foreground().forbid_parking();
     let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
-    let mut client_a = server.create_client(cx_a, "user_a").await;
-    let mut client_b = server.create_client(cx_b, "user_b").await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
     server
         .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)])
         .await;
 
-    let fs = FakeFs::new(cx_a.background());
-    fs.insert_tree(
-        "/dir",
-        json!({
-            "a.txt": "a-contents",
-        }),
-    )
-    .await;
-
-    let (project_a, worktree_id) = client_a.build_local_project(fs, "/dir", cx_a).await;
+    client_a
+        .fs
+        .insert_tree("/dir", json!({ "a.txt": "a-contents" }))
+        .await;
+    let (project_a, worktree_id) = client_a.build_local_project("/dir", cx_a).await;
     let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await;
 
     // Open a buffer as client A
@@ -1065,22 +1271,17 @@ async fn test_leaving_worktree_while_opening_buffer(
 ) {
     cx_a.foreground().forbid_parking();
     let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
-    let mut client_a = server.create_client(cx_a, "user_a").await;
-    let mut client_b = server.create_client(cx_b, "user_b").await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
     server
         .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)])
         .await;
 
-    let fs = FakeFs::new(cx_a.background());
-    fs.insert_tree(
-        "/dir",
-        json!({
-            "a.txt": "a-contents",
-        }),
-    )
-    .await;
-
-    let (project_a, worktree_id) = client_a.build_local_project(fs, "/dir", cx_a).await;
+    client_a
+        .fs
+        .insert_tree("/dir", json!({ "a.txt": "a-contents" }))
+        .await;
+    let (project_a, worktree_id) = client_a.build_local_project("/dir", cx_a).await;
     let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await;
 
     // See that a guest has joined as client A.
@@ -1092,10 +1293,7 @@ async fn test_leaving_worktree_while_opening_buffer(
     let buffer_b = cx_b
         .background()
         .spawn(project_b.update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx)));
-    cx_b.update(|_| {
-        drop(client_b.project.take());
-        drop(project_b);
-    });
+    cx_b.update(|_| drop(project_b));
     drop(buffer_b);
 
     // See that the guest has left.
@@ -1108,23 +1306,23 @@ async fn test_leaving_worktree_while_opening_buffer(
 async fn test_leaving_project(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
     cx_a.foreground().forbid_parking();
     let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
-    let mut client_a = server.create_client(cx_a, "user_a").await;
-    let mut client_b = server.create_client(cx_b, "user_b").await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
     server
         .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)])
         .await;
 
-    let fs = FakeFs::new(cx_a.background());
-    fs.insert_tree(
-        "/a",
-        json!({
-            "a.txt": "a-contents",
-            "b.txt": "b-contents",
-        }),
-    )
-    .await;
-
-    let (project_a, _) = client_a.build_local_project(fs, "/a", cx_a).await;
+    client_a
+        .fs
+        .insert_tree(
+            "/a",
+            json!({
+                "a.txt": "a-contents",
+                "b.txt": "b-contents",
+            }),
+        )
+        .await;
+    let (project_a, _) = client_a.build_local_project("/a", cx_a).await;
     let _project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await;
 
     // Client A sees that a guest has joined.
@@ -1164,9 +1362,9 @@ async fn test_collaborating_with_diagnostics(
 ) {
     deterministic.forbid_parking();
     let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
-    let mut client_a = server.create_client(cx_a, "user_a").await;
-    let mut client_b = server.create_client(cx_b, "user_b").await;
-    let mut client_c = server.create_client(cx_c, "user_c").await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
+    let client_c = server.create_client(cx_c, "user_c").await;
     server
         .make_contacts(vec![
             (&client_a, cx_a),
@@ -1187,19 +1385,18 @@ async fn test_collaborating_with_diagnostics(
     let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default());
     client_a.language_registry.add(Arc::new(language));
 
-    // Connect to a server as 2 clients.
-
     // Share a project as client A
-    let fs = FakeFs::new(cx_a.background());
-    fs.insert_tree(
-        "/a",
-        json!({
-            "a.rs": "let one = two",
-            "other.rs": "",
-        }),
-    )
-    .await;
-    let (project_a, worktree_id) = client_a.build_local_project(fs, "/a", cx_a).await;
+    client_a
+        .fs
+        .insert_tree(
+            "/a",
+            json!({
+                "a.rs": "let one = two",
+                "other.rs": "",
+            }),
+        )
+        .await;
+    let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await;
     let project_id = project_a.update(cx_a, |p, _| p.next_remote_id()).await;
 
     // Cause the language server to start.
@@ -1404,8 +1601,8 @@ async fn test_collaborating_with_diagnostics(
 async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
     cx_a.foreground().forbid_parking();
     let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
-    let mut client_a = server.create_client(cx_a, "user_a").await;
-    let mut client_b = server.create_client(cx_b, "user_b").await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
     server
         .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)])
         .await;
@@ -1431,17 +1628,17 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu
     });
     client_a.language_registry.add(Arc::new(language));
 
-    let fs = FakeFs::new(cx_a.background());
-    fs.insert_tree(
-        "/a",
-        json!({
-            "main.rs": "fn main() { a }",
-            "other.rs": "",
-        }),
-    )
-    .await;
-
-    let (project_a, worktree_id) = client_a.build_local_project(fs, "/a", cx_a).await;
+    client_a
+        .fs
+        .insert_tree(
+            "/a",
+            json!({
+                "main.rs": "fn main() { a }",
+                "other.rs": "",
+            }),
+        )
+        .await;
+    let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await;
     let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await;
 
     // Open a file in an editor as the guest.

crates/collab/src/main.rs 🔗

@@ -82,7 +82,6 @@ pub fn init_tracing(config: &Config) -> Option<()> {
     use tracing_subscriber::layer::SubscriberExt;
     let rust_log = config.rust_log.clone()?;
 
-    println!("HEY!");
     LogTracer::init().log_err()?;
 
     let subscriber = tracing_subscriber::Registry::default()

crates/collab/src/rpc.rs 🔗

@@ -141,12 +141,11 @@ impl Server {
         server
             .add_request_handler(Server::ping)
             .add_request_handler(Server::register_project)
-            .add_message_handler(Server::unregister_project)
+            .add_request_handler(Server::unregister_project)
             .add_request_handler(Server::join_project)
             .add_message_handler(Server::leave_project)
             .add_message_handler(Server::respond_to_join_project_request)
-            .add_request_handler(Server::register_worktree)
-            .add_message_handler(Server::unregister_worktree)
+            .add_message_handler(Server::update_project)
             .add_request_handler(Server::update_worktree)
             .add_message_handler(Server::start_language_server)
             .add_message_handler(Server::update_language_server)
@@ -477,21 +476,22 @@ impl Server {
         request: TypedEnvelope<proto::RegisterProject>,
         response: Response<proto::RegisterProject>,
     ) -> Result<()> {
-        let user_id;
         let project_id;
         {
             let mut state = self.store_mut().await;
-            user_id = state.user_id_for_connection(request.sender_id)?;
+            let user_id = state.user_id_for_connection(request.sender_id)?;
             project_id = state.register_project(request.sender_id, user_id);
         };
-        self.update_user_contacts(user_id).await?;
+
         response.send(proto::RegisterProjectResponse { project_id })?;
+
         Ok(())
     }
 
     async fn unregister_project(
         self: Arc<Server>,
         request: TypedEnvelope<proto::UnregisterProject>,
+        response: Response<proto::UnregisterProject>,
     ) -> Result<()> {
         let (user_id, project) = {
             let mut state = self.store_mut().await;
@@ -528,7 +528,13 @@ impl Server {
             }
         }
 
+        // Send out the `UpdateContacts` message before responding to the unregister
+        // request. This way, when the project's host can keep track of the project's
+        // remote id until after they've received the `UpdateContacts` message for
+        // themself.
         self.update_user_contacts(user_id).await?;
+        response.send(proto::Ack {})?;
+
         Ok(())
     }
 
@@ -568,6 +574,7 @@ impl Server {
         response: Response<proto::JoinProject>,
     ) -> Result<()> {
         let project_id = request.payload.project_id;
+
         let host_user_id;
         let guest_user_id;
         let host_connection_id;
@@ -768,63 +775,28 @@ impl Server {
         Ok(())
     }
 
-    async fn register_worktree(
+    async fn update_project(
         self: Arc<Server>,
-        request: TypedEnvelope<proto::RegisterWorktree>,
-        response: Response<proto::RegisterWorktree>,
+        request: TypedEnvelope<proto::UpdateProject>,
     ) -> Result<()> {
-        let host_user_id;
+        let user_id;
         {
             let mut state = self.store_mut().await;
-            host_user_id = state.user_id_for_connection(request.sender_id)?;
-
+            user_id = state.user_id_for_connection(request.sender_id)?;
             let guest_connection_ids = state
                 .read_project(request.payload.project_id, request.sender_id)?
                 .guest_connection_ids();
-            state.register_worktree(
+            state.update_project(
                 request.payload.project_id,
-                request.payload.worktree_id,
+                &request.payload.worktrees,
                 request.sender_id,
-                Worktree {
-                    root_name: request.payload.root_name.clone(),
-                    visible: request.payload.visible,
-                    ..Default::default()
-                },
             )?;
-
             broadcast(request.sender_id, guest_connection_ids, |connection_id| {
                 self.peer
                     .forward_send(request.sender_id, connection_id, request.payload.clone())
             });
-        }
-        self.update_user_contacts(host_user_id).await?;
-        response.send(proto::Ack {})?;
-        Ok(())
-    }
-
-    async fn unregister_worktree(
-        self: Arc<Server>,
-        request: TypedEnvelope<proto::UnregisterWorktree>,
-    ) -> Result<()> {
-        let host_user_id;
-        let project_id = request.payload.project_id;
-        let worktree_id = request.payload.worktree_id;
-        {
-            let mut state = self.store_mut().await;
-            let (_, guest_connection_ids) =
-                state.unregister_worktree(project_id, worktree_id, request.sender_id)?;
-            host_user_id = state.user_id_for_connection(request.sender_id)?;
-            broadcast(request.sender_id, guest_connection_ids, |conn_id| {
-                self.peer.send(
-                    conn_id,
-                    proto::UnregisterWorktree {
-                        project_id,
-                        worktree_id,
-                    },
-                )
-            });
-        }
-        self.update_user_contacts(host_user_id).await?;
+        };
+        self.update_user_contacts(user_id).await?;
         Ok(())
     }
 
@@ -833,10 +805,11 @@ impl Server {
         request: TypedEnvelope<proto::UpdateWorktree>,
         response: Response<proto::UpdateWorktree>,
     ) -> Result<()> {
-        let connection_ids = self.store_mut().await.update_worktree(
+        let (connection_ids, metadata_changed) = self.store_mut().await.update_worktree(
             request.sender_id,
             request.payload.project_id,
             request.payload.worktree_id,
+            &request.payload.root_name,
             &request.payload.removed_entries,
             &request.payload.updated_entries,
             request.payload.scan_id,
@@ -846,6 +819,13 @@ impl Server {
             self.peer
                 .forward_send(request.sender_id, connection_id, request.payload.clone())
         });
+        if metadata_changed {
+            let user_id = self
+                .store()
+                .await
+                .user_id_for_connection(request.sender_id)?;
+            self.update_user_contacts(user_id).await?;
+        }
         response.send(proto::Ack {})?;
         Ok(())
     }

crates/collab/src/rpc/store.rs 🔗

@@ -32,7 +32,7 @@ pub struct Project {
     #[serde(skip)]
     pub join_requests: HashMap<UserId, Vec<Receipt<proto::JoinProject>>>,
     pub active_replica_ids: HashSet<ReplicaId>,
-    pub worktrees: HashMap<u64, Worktree>,
+    pub worktrees: BTreeMap<u64, Worktree>,
     pub language_servers: Vec<proto::LanguageServer>,
 }
 
@@ -312,19 +312,32 @@ impl Store {
         project_id
     }
 
-    pub fn register_worktree(
+    pub fn update_project(
         &mut self,
         project_id: u64,
-        worktree_id: u64,
+        worktrees: &[proto::WorktreeMetadata],
         connection_id: ConnectionId,
-        worktree: Worktree,
     ) -> Result<()> {
         let project = self
             .projects
             .get_mut(&project_id)
             .ok_or_else(|| anyhow!("no such project"))?;
         if project.host_connection_id == connection_id {
-            project.worktrees.insert(worktree_id, worktree);
+            let mut old_worktrees = mem::take(&mut project.worktrees);
+            for worktree in worktrees {
+                if let Some(old_worktree) = old_worktrees.remove(&worktree.id) {
+                    project.worktrees.insert(worktree.id, old_worktree);
+                } else {
+                    project.worktrees.insert(
+                        worktree.id,
+                        Worktree {
+                            root_name: worktree.root_name.clone(),
+                            visible: worktree.visible,
+                            ..Default::default()
+                        },
+                    );
+                }
+            }
             Ok(())
         } else {
             Err(anyhow!("no such project"))?
@@ -374,27 +387,6 @@ impl Store {
         }
     }
 
-    pub fn unregister_worktree(
-        &mut self,
-        project_id: u64,
-        worktree_id: u64,
-        acting_connection_id: ConnectionId,
-    ) -> Result<(Worktree, Vec<ConnectionId>)> {
-        let project = self
-            .projects
-            .get_mut(&project_id)
-            .ok_or_else(|| anyhow!("no such project"))?;
-        if project.host_connection_id != acting_connection_id {
-            Err(anyhow!("not your worktree"))?;
-        }
-
-        let worktree = project
-            .worktrees
-            .remove(&worktree_id)
-            .ok_or_else(|| anyhow!("no such worktree"))?;
-        Ok((worktree, project.guest_connection_ids()))
-    }
-
     pub fn update_diagnostic_summary(
         &mut self,
         project_id: u64,
@@ -573,15 +565,15 @@ impl Store {
         connection_id: ConnectionId,
         project_id: u64,
         worktree_id: u64,
+        worktree_root_name: &str,
         removed_entries: &[u64],
         updated_entries: &[proto::Entry],
         scan_id: u64,
-    ) -> Result<Vec<ConnectionId>> {
+    ) -> Result<(Vec<ConnectionId>, bool)> {
         let project = self.write_project(project_id, connection_id)?;
-        let worktree = project
-            .worktrees
-            .get_mut(&worktree_id)
-            .ok_or_else(|| anyhow!("no such worktree"))?;
+        let mut worktree = project.worktrees.entry(worktree_id).or_default();
+        let metadata_changed = worktree_root_name != worktree.root_name;
+        worktree.root_name = worktree_root_name.to_string();
         for entry_id in removed_entries {
             worktree.entries.remove(&entry_id);
         }
@@ -590,7 +582,7 @@ impl Store {
         }
         worktree.scan_id = scan_id;
         let connection_ids = project.connection_ids();
-        Ok(connection_ids)
+        Ok((connection_ids, metadata_changed))
     }
 
     pub fn project_connection_ids(

crates/command_palette/Cargo.toml 🔗

@@ -25,4 +25,4 @@ project = { path = "../project", features = ["test-support"] }
 serde_json = { version = "1.0", features = ["preserve_order"] }
 workspace = { path = "../workspace", features = ["test-support"] }
 ctor = "0.1"
-env_logger = "0.8"
+env_logger = "0.9"

crates/contacts_panel/Cargo.toml 🔗

@@ -9,6 +9,7 @@ doctest = false
 
 [dependencies]
 client = { path = "../client" }
+collections = { path = "../collections" }
 editor = { path = "../editor" }
 fuzzy = { path = "../fuzzy" }
 gpui = { path = "../gpui" }

crates/contacts_panel/src/contacts_panel.rs 🔗

@@ -13,15 +13,16 @@ use gpui::{
     impl_actions, impl_internal_actions,
     platform::CursorStyle,
     AppContext, ClipboardItem, Element, ElementBox, Entity, ModelHandle, MutableAppContext,
-    RenderContext, Subscription, View, ViewContext, ViewHandle, WeakViewHandle,
+    RenderContext, Subscription, View, ViewContext, ViewHandle, WeakModelHandle, WeakViewHandle,
 };
 use join_project_notification::JoinProjectNotification;
 use menu::{Confirm, SelectNext, SelectPrev};
+use project::{Project, ProjectStore};
 use serde::Deserialize;
 use settings::Settings;
-use std::sync::Arc;
+use std::{ops::DerefMut, sync::Arc};
 use theme::IconButton;
-use workspace::{sidebar::SidebarItem, JoinProject, Workspace};
+use workspace::{sidebar::SidebarItem, JoinProject, ToggleProjectOnline, Workspace};
 
 impl_actions!(
     contacts_panel,
@@ -37,13 +38,14 @@ enum Section {
     Offline,
 }
 
-#[derive(Clone, Debug)]
+#[derive(Clone)]
 enum ContactEntry {
     Header(Section),
     IncomingRequest(Arc<User>),
     OutgoingRequest(Arc<User>),
     Contact(Arc<Contact>),
-    ContactProject(Arc<Contact>, usize),
+    ContactProject(Arc<Contact>, usize, Option<WeakModelHandle<Project>>),
+    OfflineProject(WeakModelHandle<Project>),
 }
 
 #[derive(Clone)]
@@ -54,6 +56,7 @@ pub struct ContactsPanel {
     match_candidates: Vec<StringMatchCandidate>,
     list_state: ListState,
     user_store: ModelHandle<UserStore>,
+    project_store: ModelHandle<ProjectStore>,
     filter_editor: ViewHandle<Editor>,
     collapsed_sections: Vec<Section>,
     selection: Option<usize>,
@@ -89,6 +92,7 @@ pub fn init(cx: &mut MutableAppContext) {
 impl ContactsPanel {
     pub fn new(
         user_store: ModelHandle<UserStore>,
+        project_store: ModelHandle<ProjectStore>,
         workspace: WeakViewHandle<Workspace>,
         cx: &mut ViewContext<Self>,
     ) -> Self {
@@ -148,93 +152,99 @@ impl ContactsPanel {
             }
         });
 
-        cx.subscribe(&user_store, {
-            let user_store = user_store.downgrade();
-            move |_, _, event, cx| {
-                if let Some((workspace, user_store)) =
-                    workspace.upgrade(cx).zip(user_store.upgrade(cx))
-                {
-                    workspace.update(cx, |workspace, cx| match event {
-                        client::Event::Contact { user, kind } => match kind {
-                            ContactEventKind::Requested | ContactEventKind::Accepted => workspace
-                                .show_notification(user.id as usize, cx, |cx| {
-                                    cx.add_view(|cx| {
-                                        ContactNotification::new(
-                                            user.clone(),
-                                            *kind,
-                                            user_store,
-                                            cx,
-                                        )
-                                    })
-                                }),
-                            _ => {}
-                        },
+        cx.observe(&project_store, |this, _, cx| this.update_entries(cx))
+            .detach();
+
+        cx.subscribe(&user_store, move |_, user_store, event, cx| {
+            if let Some(workspace) = workspace.upgrade(cx) {
+                workspace.update(cx, |workspace, cx| match event {
+                    client::Event::Contact { user, kind } => match kind {
+                        ContactEventKind::Requested | ContactEventKind::Accepted => workspace
+                            .show_notification(user.id as usize, cx, |cx| {
+                                cx.add_view(|cx| {
+                                    ContactNotification::new(user.clone(), *kind, user_store, cx)
+                                })
+                            }),
                         _ => {}
-                    });
-                }
+                    },
+                    _ => {}
+                });
+            }
 
-                if let client::Event::ShowContacts = event {
-                    cx.emit(Event::Activate);
-                }
+            if let client::Event::ShowContacts = event {
+                cx.emit(Event::Activate);
             }
         })
         .detach();
 
-        let mut this = Self {
-            list_state: ListState::new(0, Orientation::Top, 1000., cx, {
-                move |this, ix, cx| {
-                    let theme = cx.global::<Settings>().theme.clone();
-                    let theme = &theme.contacts_panel;
-                    let current_user_id =
-                        this.user_store.read(cx).current_user().map(|user| user.id);
-                    let is_selected = this.selection == Some(ix);
-
-                    match &this.entries[ix] {
-                        ContactEntry::Header(section) => {
-                            let is_collapsed = this.collapsed_sections.contains(&section);
-                            Self::render_header(*section, theme, is_selected, is_collapsed, cx)
-                        }
-                        ContactEntry::IncomingRequest(user) => Self::render_contact_request(
-                            user.clone(),
-                            this.user_store.clone(),
-                            theme,
-                            true,
-                            is_selected,
-                            cx,
-                        ),
-                        ContactEntry::OutgoingRequest(user) => Self::render_contact_request(
-                            user.clone(),
-                            this.user_store.clone(),
-                            theme,
-                            false,
-                            is_selected,
-                            cx,
-                        ),
-                        ContactEntry::Contact(contact) => {
-                            Self::render_contact(contact.clone(), theme, is_selected)
-                        }
-                        ContactEntry::ContactProject(contact, project_ix) => {
-                            let is_last_project_for_contact =
-                                this.entries.get(ix + 1).map_or(true, |next| {
-                                    if let ContactEntry::ContactProject(next_contact, _) = next {
-                                        next_contact.user.id != contact.user.id
-                                    } else {
-                                        true
-                                    }
-                                });
-                            Self::render_contact_project(
-                                contact.clone(),
-                                current_user_id,
-                                *project_ix,
-                                theme,
-                                is_last_project_for_contact,
-                                is_selected,
-                                cx,
-                            )
-                        }
-                    }
+        let list_state = ListState::new(0, Orientation::Top, 1000., cx, move |this, ix, cx| {
+            let theme = cx.global::<Settings>().theme.clone();
+            let current_user_id = this.user_store.read(cx).current_user().map(|user| user.id);
+            let is_selected = this.selection == Some(ix);
+
+            match &this.entries[ix] {
+                ContactEntry::Header(section) => {
+                    let is_collapsed = this.collapsed_sections.contains(&section);
+                    Self::render_header(
+                        *section,
+                        &theme.contacts_panel,
+                        is_selected,
+                        is_collapsed,
+                        cx,
+                    )
                 }
-            }),
+                ContactEntry::IncomingRequest(user) => Self::render_contact_request(
+                    user.clone(),
+                    this.user_store.clone(),
+                    &theme.contacts_panel,
+                    true,
+                    is_selected,
+                    cx,
+                ),
+                ContactEntry::OutgoingRequest(user) => Self::render_contact_request(
+                    user.clone(),
+                    this.user_store.clone(),
+                    &theme.contacts_panel,
+                    false,
+                    is_selected,
+                    cx,
+                ),
+                ContactEntry::Contact(contact) => {
+                    Self::render_contact(&contact.user, &theme.contacts_panel, is_selected)
+                }
+                ContactEntry::ContactProject(contact, project_ix, open_project) => {
+                    let is_last_project_for_contact =
+                        this.entries.get(ix + 1).map_or(true, |next| {
+                            if let ContactEntry::ContactProject(next_contact, _, _) = next {
+                                next_contact.user.id != contact.user.id
+                            } else {
+                                true
+                            }
+                        });
+                    Self::render_project(
+                        contact.clone(),
+                        current_user_id,
+                        *project_ix,
+                        open_project.clone(),
+                        &theme.contacts_panel,
+                        &theme.tooltip,
+                        is_last_project_for_contact,
+                        is_selected,
+                        cx,
+                    )
+                }
+                ContactEntry::OfflineProject(project) => Self::render_offline_project(
+                    project.clone(),
+                    &theme.contacts_panel,
+                    &theme.tooltip,
+                    is_selected,
+                    cx,
+                ),
+            }
+        });
+
+        let mut this = Self {
+            list_state,
             selection: None,
             collapsed_sections: Default::default(),
             entries: Default::default(),
@@ -242,6 +252,7 @@ impl ContactsPanel {
             filter_editor,
             _maintain_contacts: cx.observe(&user_store, |this, _, cx| this.update_entries(cx)),
             user_store,
+            project_store,
         };
         this.update_entries(cx);
         this
@@ -300,13 +311,9 @@ impl ContactsPanel {
         .boxed()
     }
 
-    fn render_contact(
-        contact: Arc<Contact>,
-        theme: &theme::ContactsPanel,
-        is_selected: bool,
-    ) -> ElementBox {
+    fn render_contact(user: &User, theme: &theme::ContactsPanel, is_selected: bool) -> ElementBox {
         Flex::row()
-            .with_children(contact.user.avatar.clone().map(|avatar| {
+            .with_children(user.avatar.clone().map(|avatar| {
                 Image::new(avatar)
                     .with_style(theme.contact_avatar)
                     .aligned()
@@ -315,7 +322,7 @@ impl ContactsPanel {
             }))
             .with_child(
                 Label::new(
-                    contact.user.github_login.clone(),
+                    user.github_login.clone(),
                     theme.contact_username.text.clone(),
                 )
                 .contained()
@@ -332,11 +339,13 @@ impl ContactsPanel {
             .boxed()
     }
 
-    fn render_contact_project(
+    fn render_project(
         contact: Arc<Contact>,
         current_user_id: Option<u64>,
         project_index: usize,
+        open_project: Option<WeakModelHandle<Project>>,
         theme: &theme::ContactsPanel,
+        tooltip_style: &TooltipStyle,
         is_last_project: bool,
         is_selected: bool,
         cx: &mut RenderContext<Self>,
@@ -344,6 +353,7 @@ impl ContactsPanel {
         let project = &contact.projects[project_index];
         let project_id = project.id;
         let is_host = Some(contact.user.id) == current_user_id;
+        let open_project = open_project.and_then(|p| p.upgrade(cx.deref_mut()));
 
         let font_cache = cx.font_cache();
         let host_avatar_height = theme
@@ -358,48 +368,97 @@ impl ContactsPanel {
         let baseline_offset =
             row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
 
-        MouseEventHandler::new::<JoinProject, _, _>(project_id as usize, cx, |mouse_state, _| {
+        MouseEventHandler::new::<JoinProject, _, _>(project_id as usize, cx, |mouse_state, cx| {
             let tree_branch = *tree_branch.style_for(mouse_state, is_selected);
             let row = theme.project_row.style_for(mouse_state, is_selected);
 
             Flex::row()
                 .with_child(
-                    Canvas::new(move |bounds, _, cx| {
-                        let start_x =
-                            bounds.min_x() + (bounds.width() / 2.) - (tree_branch.width / 2.);
-                        let end_x = bounds.max_x();
-                        let start_y = bounds.min_y();
-                        let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
-
-                        cx.scene.push_quad(gpui::Quad {
-                            bounds: RectF::from_points(
-                                vec2f(start_x, start_y),
-                                vec2f(
-                                    start_x + tree_branch.width,
-                                    if is_last_project {
-                                        end_y
-                                    } else {
-                                        bounds.max_y()
-                                    },
-                                ),
-                            ),
-                            background: Some(tree_branch.color),
-                            border: gpui::Border::default(),
-                            corner_radius: 0.,
-                        });
-                        cx.scene.push_quad(gpui::Quad {
-                            bounds: RectF::from_points(
-                                vec2f(start_x, end_y),
-                                vec2f(end_x, end_y + tree_branch.width),
-                            ),
-                            background: Some(tree_branch.color),
-                            border: gpui::Border::default(),
-                            corner_radius: 0.,
-                        });
-                    })
-                    .constrained()
-                    .with_width(host_avatar_height)
-                    .boxed(),
+                    Stack::new()
+                        .with_child(
+                            Canvas::new(move |bounds, _, cx| {
+                                let start_x = bounds.min_x() + (bounds.width() / 2.)
+                                    - (tree_branch.width / 2.);
+                                let end_x = bounds.max_x();
+                                let start_y = bounds.min_y();
+                                let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
+
+                                cx.scene.push_quad(gpui::Quad {
+                                    bounds: RectF::from_points(
+                                        vec2f(start_x, start_y),
+                                        vec2f(
+                                            start_x + tree_branch.width,
+                                            if is_last_project {
+                                                end_y
+                                            } else {
+                                                bounds.max_y()
+                                            },
+                                        ),
+                                    ),
+                                    background: Some(tree_branch.color),
+                                    border: gpui::Border::default(),
+                                    corner_radius: 0.,
+                                });
+                                cx.scene.push_quad(gpui::Quad {
+                                    bounds: RectF::from_points(
+                                        vec2f(start_x, end_y),
+                                        vec2f(end_x, end_y + tree_branch.width),
+                                    ),
+                                    background: Some(tree_branch.color),
+                                    border: gpui::Border::default(),
+                                    corner_radius: 0.,
+                                });
+                            })
+                            .boxed(),
+                        )
+                        .with_children(open_project.and_then(|open_project| {
+                            let is_going_offline = !open_project.read(cx).is_online();
+                            if !mouse_state.hovered && !is_going_offline {
+                                return None;
+                            }
+
+                            let button = MouseEventHandler::new::<ToggleProjectOnline, _, _>(
+                                project_id as usize,
+                                cx,
+                                |state, _| {
+                                    let mut icon_style =
+                                        *theme.private_button.style_for(state, false);
+                                    icon_style.container.background_color =
+                                        row.container.background_color;
+                                    if is_going_offline {
+                                        icon_style.color = theme.disabled_button.color;
+                                    }
+                                    render_icon_button(&icon_style, "icons/lock-8.svg")
+                                        .aligned()
+                                        .boxed()
+                                },
+                            );
+
+                            if is_going_offline {
+                                Some(button.boxed())
+                            } else {
+                                Some(
+                                    button
+                                        .with_cursor_style(CursorStyle::PointingHand)
+                                        .on_click(move |_, _, cx| {
+                                            cx.dispatch_action(ToggleProjectOnline {
+                                                project: Some(open_project.clone()),
+                                            })
+                                        })
+                                        .with_tooltip(
+                                            project_id as usize,
+                                            "Take project offline".to_string(),
+                                            None,
+                                            tooltip_style.clone(),
+                                            cx,
+                                        )
+                                        .boxed(),
+                                )
+                            }
+                        }))
+                        .constrained()
+                        .with_width(host_avatar_height)
+                        .boxed(),
                 )
                 .with_child(
                     Label::new(
@@ -446,6 +505,94 @@ impl ContactsPanel {
         .boxed()
     }
 
+    fn render_offline_project(
+        project: WeakModelHandle<Project>,
+        theme: &theme::ContactsPanel,
+        tooltip_style: &TooltipStyle,
+        is_selected: bool,
+        cx: &mut RenderContext<Self>,
+    ) -> ElementBox {
+        let project = if let Some(project) = project.upgrade(cx.deref_mut()) {
+            project
+        } else {
+            return Empty::new().boxed();
+        };
+
+        let host_avatar_height = theme
+            .contact_avatar
+            .width
+            .or(theme.contact_avatar.height)
+            .unwrap_or(0.);
+
+        enum LocalProject {}
+        enum ToggleOnline {}
+
+        let project_id = project.id();
+        MouseEventHandler::new::<LocalProject, _, _>(project_id, cx, |state, cx| {
+            let row = theme.project_row.style_for(state, is_selected);
+            let mut worktree_root_names = String::new();
+            let project_ = project.read(cx);
+            let is_going_online = project_.is_online();
+            for tree in project_.visible_worktrees(cx) {
+                if !worktree_root_names.is_empty() {
+                    worktree_root_names.push_str(", ");
+                }
+                worktree_root_names.push_str(tree.read(cx).root_name());
+            }
+
+            Flex::row()
+                .with_child({
+                    let button =
+                        MouseEventHandler::new::<ToggleOnline, _, _>(project_id, cx, |state, _| {
+                            let mut style = *theme.private_button.style_for(state, false);
+                            if is_going_online {
+                                style.color = theme.disabled_button.color;
+                            }
+                            render_icon_button(&style, "icons/lock-8.svg")
+                                .aligned()
+                                .constrained()
+                                .with_width(host_avatar_height)
+                                .boxed()
+                        });
+
+                    if is_going_online {
+                        button.boxed()
+                    } else {
+                        button
+                            .with_cursor_style(CursorStyle::PointingHand)
+                            .on_click(move |_, _, cx| {
+                                cx.dispatch_action(ToggleProjectOnline {
+                                    project: Some(project.clone()),
+                                })
+                            })
+                            .with_tooltip(
+                                project_id,
+                                "Take project online".to_string(),
+                                None,
+                                tooltip_style.clone(),
+                                cx,
+                            )
+                            .boxed()
+                    }
+                })
+                .with_child(
+                    Label::new(worktree_root_names, row.name.text.clone())
+                        .aligned()
+                        .left()
+                        .contained()
+                        .with_style(row.name.container)
+                        .flex(1., false)
+                        .boxed(),
+                )
+                .constrained()
+                .with_height(theme.row_height)
+                .contained()
+                .with_style(row.container)
+                .boxed()
+        })
+        .boxed()
+    }
+
     fn render_contact_request(
         user: Arc<User>,
         user_store: ModelHandle<UserStore>,
@@ -487,7 +634,7 @@ impl ContactsPanel {
             row.add_children([
                 MouseEventHandler::new::<Decline, _, _>(user.id as usize, cx, |mouse_state, _| {
                     let button_style = if is_contact_request_pending {
-                        &theme.disabled_contact_button
+                        &theme.disabled_button
                     } else {
                         &theme.contact_button.style_for(mouse_state, false)
                     };
@@ -509,7 +656,7 @@ impl ContactsPanel {
                 .boxed(),
                 MouseEventHandler::new::<Accept, _, _>(user.id as usize, cx, |mouse_state, _| {
                     let button_style = if is_contact_request_pending {
-                        &theme.disabled_contact_button
+                        &theme.disabled_button
                     } else {
                         &theme.contact_button.style_for(mouse_state, false)
                     };
@@ -531,7 +678,7 @@ impl ContactsPanel {
             row.add_child(
                 MouseEventHandler::new::<Cancel, _, _>(user.id as usize, cx, |mouse_state, _| {
                     let button_style = if is_contact_request_pending {
-                        &theme.disabled_contact_button
+                        &theme.disabled_button
                     } else {
                         &theme.contact_button.style_for(mouse_state, false)
                     };
@@ -557,6 +704,7 @@ impl ContactsPanel {
 
     fn update_entries(&mut self, cx: &mut ViewContext<Self>) {
         let user_store = self.user_store.read(cx);
+        let project_store = self.project_store.read(cx);
         let query = self.filter_editor.read(cx).text(cx);
         let executor = cx.background().clone();
 
@@ -629,20 +777,37 @@ impl ContactsPanel {
             }
         }
 
+        let current_user = user_store.current_user();
+
         let contacts = user_store.contacts();
         if !contacts.is_empty() {
+            // Always put the current user first.
             self.match_candidates.clear();
-            self.match_candidates
-                .extend(
-                    contacts
-                        .iter()
-                        .enumerate()
-                        .map(|(ix, contact)| StringMatchCandidate {
-                            id: ix,
-                            string: contact.user.github_login.clone(),
-                            char_bag: contact.user.github_login.chars().collect(),
-                        }),
-                );
+            self.match_candidates.reserve(contacts.len());
+            self.match_candidates.push(StringMatchCandidate {
+                id: 0,
+                string: Default::default(),
+                char_bag: Default::default(),
+            });
+            for (ix, contact) in contacts.iter().enumerate() {
+                let candidate = StringMatchCandidate {
+                    id: ix,
+                    string: contact.user.github_login.clone(),
+                    char_bag: contact.user.github_login.chars().collect(),
+                };
+                if current_user
+                    .as_ref()
+                    .map_or(false, |current_user| current_user.id == contact.user.id)
+                {
+                    self.match_candidates[0] = candidate;
+                } else {
+                    self.match_candidates.push(candidate);
+                }
+            }
+            if self.match_candidates[0].string.is_empty() {
+                self.match_candidates.remove(0);
+            }
+
             let matches = executor.block(match_strings(
                 &self.match_candidates,
                 &query,
@@ -666,16 +831,60 @@ impl ContactsPanel {
                         for mat in matches {
                             let contact = &contacts[mat.candidate_id];
                             self.entries.push(ContactEntry::Contact(contact.clone()));
-                            self.entries
-                                .extend(contact.projects.iter().enumerate().filter_map(
-                                    |(ix, project)| {
-                                        if project.worktree_root_names.is_empty() {
+
+                            let is_current_user = current_user
+                                .as_ref()
+                                .map_or(false, |user| user.id == contact.user.id);
+                            if is_current_user {
+                                let mut open_projects =
+                                    project_store.projects(cx).collect::<Vec<_>>();
+                                self.entries.extend(
+                                    contact.projects.iter().enumerate().filter_map(
+                                        |(ix, project)| {
+                                            let open_project = open_projects
+                                                .iter()
+                                                .position(|p| {
+                                                    p.read(cx).remote_id() == Some(project.id)
+                                                })
+                                                .map(|ix| open_projects.remove(ix).downgrade());
+                                            if project.worktree_root_names.is_empty() {
+                                                None
+                                            } else {
+                                                Some(ContactEntry::ContactProject(
+                                                    contact.clone(),
+                                                    ix,
+                                                    open_project,
+                                                ))
+                                            }
+                                        },
+                                    ),
+                                );
+                                self.entries.extend(open_projects.into_iter().filter_map(
+                                    |project| {
+                                        if project.read(cx).visible_worktrees(cx).next().is_none() {
                                             None
                                         } else {
-                                            Some(ContactEntry::ContactProject(contact.clone(), ix))
+                                            Some(ContactEntry::OfflineProject(project.downgrade()))
                                         }
                                     },
                                 ));
+                            } else {
+                                self.entries.extend(
+                                    contact.projects.iter().enumerate().filter_map(
+                                        |(ix, project)| {
+                                            if project.worktree_root_names.is_empty() {
+                                                None
+                                            } else {
+                                                Some(ContactEntry::ContactProject(
+                                                    contact.clone(),
+                                                    ix,
+                                                    None,
+                                                ))
+                                            }
+                                        },
+                                    ),
+                                );
+                            }
                         }
                     }
                 }
@@ -757,11 +966,18 @@ impl ContactsPanel {
                         let section = *section;
                         self.toggle_expanded(&ToggleExpanded(section), cx);
                     }
-                    ContactEntry::ContactProject(contact, project_index) => cx
-                        .dispatch_global_action(JoinProject {
-                            contact: contact.clone(),
-                            project_index: *project_index,
-                        }),
+                    ContactEntry::ContactProject(contact, project_index, open_project) => {
+                        if let Some(open_project) = open_project {
+                            workspace::activate_workspace_for_project(cx, |_, cx| {
+                                cx.model_id() == open_project.id()
+                            });
+                        } else {
+                            cx.dispatch_global_action(JoinProject {
+                                contact: contact.clone(),
+                                project_index: *project_index,
+                            })
+                        }
+                    }
                     _ => {}
                 }
             }
@@ -952,11 +1168,16 @@ impl PartialEq for ContactEntry {
                     return contact_1.user.id == contact_2.user.id;
                 }
             }
-            ContactEntry::ContactProject(contact_1, ix_1) => {
-                if let ContactEntry::ContactProject(contact_2, ix_2) = other {
+            ContactEntry::ContactProject(contact_1, ix_1, _) => {
+                if let ContactEntry::ContactProject(contact_2, ix_2, _) = other {
                     return contact_1.user.id == contact_2.user.id && ix_1 == ix_2;
                 }
             }
+            ContactEntry::OfflineProject(project_1) => {
+                if let ContactEntry::OfflineProject(project_2) = other {
+                    return project_1.id() == project_2.id();
+                }
+            }
         }
         false
     }
@@ -965,20 +1186,70 @@ impl PartialEq for ContactEntry {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use client::{proto, test::FakeServer, Client};
-    use gpui::TestAppContext;
+    use client::{
+        proto,
+        test::{FakeHttpClient, FakeServer},
+        Client,
+    };
+    use collections::HashSet;
+    use gpui::{serde_json::json, TestAppContext};
     use language::LanguageRegistry;
-    use project::Project;
-    use theme::ThemeRegistry;
-    use workspace::AppState;
+    use project::{FakeFs, Project};
 
     #[gpui::test]
     async fn test_contact_panel(cx: &mut TestAppContext) {
-        let (app_state, server) = init(cx).await;
-        let project = Project::test(app_state.fs.clone(), [], cx).await;
-        let workspace = cx.add_view(0, |cx| Workspace::new(project, cx));
+        Settings::test_async(cx);
+        let current_user_id = 100;
+
+        let languages = Arc::new(LanguageRegistry::test());
+        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::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": "" }))
+            .await;
+        let project = cx.update(|cx| {
+            Project::local(
+                false,
+                client.clone(),
+                user_store.clone(),
+                project_store.clone(),
+                languages,
+                fs,
+                cx,
+            )
+        });
+        let worktree_id = project
+            .update(cx, |project, cx| {
+                project.find_or_create_local_worktree("/private_dir", true, cx)
+            })
+            .await
+            .unwrap()
+            .0
+            .read_with(cx, |worktree, _| worktree.id().to_proto());
+
+        let workspace = cx.add_view(0, |cx| Workspace::new(project.clone(), cx));
         let panel = cx.add_view(0, |cx| {
-            ContactsPanel::new(app_state.user_store.clone(), workspace.downgrade(), cx)
+            ContactsPanel::new(
+                user_store.clone(),
+                project_store.clone(),
+                workspace.downgrade(),
+                cx,
+            )
+        });
+
+        workspace.update(cx, |_, cx| {
+            cx.observe(&panel, |_, panel, cx| {
+                let entries = render_to_strings(&panel, cx);
+                assert!(
+                    entries.iter().collect::<HashSet<_>>().len() == entries.len(),
+                    "Duplicate contact panel entries {:?}",
+                    entries
+                )
+            })
+            .detach();
         });
 
         let get_users_request = server.receive::<proto::GetUsers>().await.unwrap();
@@ -1001,6 +1272,11 @@ mod tests {
                         github_login: name.to_string(),
                         ..Default::default()
                     })
+                    .chain([proto::User {
+                        id: current_user_id,
+                        github_login: "the_current_user".to_string(),
+                        ..Default::default()
+                    }])
                     .collect(),
                 },
             )
@@ -1039,19 +1315,219 @@ mod tests {
                     should_notify: false,
                     projects: vec![],
                 },
+                proto::Contact {
+                    user_id: current_user_id,
+                    online: true,
+                    should_notify: false,
+                    projects: vec![proto::ProjectMetadata {
+                        id: 103,
+                        worktree_root_names: vec!["dir3".to_string()],
+                        guests: vec![3],
+                    }],
+                },
             ],
             ..Default::default()
         });
 
         cx.foreground().run_until_parked();
         assert_eq!(
-            render_to_strings(&panel, cx),
+            cx.read(|cx| render_to_strings(&panel, cx)),
+            &[
+                "v Requests",
+                "  incoming user_one",
+                "  outgoing user_two",
+                "v Online",
+                "  the_current_user",
+                "    dir3",
+                "    🔒 private_dir",
+                "  user_four",
+                "    dir2",
+                "  user_three",
+                "    dir1",
+                "v Offline",
+                "  user_five",
+            ]
+        );
+
+        // Take a project online. It appears as loading, since the project
+        // isn't yet visible to other contacts.
+        project.update(cx, |project, cx| project.set_online(true, cx));
+        cx.foreground().run_until_parked();
+        assert_eq!(
+            cx.read(|cx| render_to_strings(&panel, cx)),
+            &[
+                "v Requests",
+                "  incoming user_one",
+                "  outgoing user_two",
+                "v Online",
+                "  the_current_user",
+                "    dir3",
+                "    🔒 private_dir (going online...)",
+                "  user_four",
+                "    dir2",
+                "  user_three",
+                "    dir1",
+                "v Offline",
+                "  user_five",
+            ]
+        );
+
+        // The server responds, assigning the project a remote id. It still appears
+        // as loading, because the server hasn't yet sent out the updated contact
+        // state for the current user.
+        let request = server.receive::<proto::RegisterProject>().await.unwrap();
+        server
+            .respond(
+                request.receipt(),
+                proto::RegisterProjectResponse { project_id: 200 },
+            )
+            .await;
+        cx.foreground().run_until_parked();
+        assert_eq!(
+            cx.read(|cx| render_to_strings(&panel, cx)),
+            &[
+                "v Requests",
+                "  incoming user_one",
+                "  outgoing user_two",
+                "v Online",
+                "  the_current_user",
+                "    dir3",
+                "    🔒 private_dir (going online...)",
+                "  user_four",
+                "    dir2",
+                "  user_three",
+                "    dir1",
+                "v Offline",
+                "  user_five",
+            ]
+        );
+
+        // The server receives the project's metadata and updates the contact metadata
+        // for the current user. Now the project appears as online.
+        assert_eq!(
+            server
+                .receive::<proto::UpdateProject>()
+                .await
+                .unwrap()
+                .payload
+                .worktrees,
+            &[proto::WorktreeMetadata {
+                id: worktree_id,
+                root_name: "private_dir".to_string(),
+                visible: true,
+            }],
+        );
+        server.send(proto::UpdateContacts {
+            contacts: vec![proto::Contact {
+                user_id: current_user_id,
+                online: true,
+                should_notify: false,
+                projects: vec![
+                    proto::ProjectMetadata {
+                        id: 103,
+                        worktree_root_names: vec!["dir3".to_string()],
+                        guests: vec![3],
+                    },
+                    proto::ProjectMetadata {
+                        id: 200,
+                        worktree_root_names: vec!["private_dir".to_string()],
+                        guests: vec![3],
+                    },
+                ],
+            }],
+            ..Default::default()
+        });
+        cx.foreground().run_until_parked();
+        assert_eq!(
+            cx.read(|cx| render_to_strings(&panel, cx)),
             &[
-                "+",
                 "v Requests",
                 "  incoming user_one",
                 "  outgoing user_two",
                 "v Online",
+                "  the_current_user",
+                "    dir3",
+                "    private_dir",
+                "  user_four",
+                "    dir2",
+                "  user_three",
+                "    dir1",
+                "v Offline",
+                "  user_five",
+            ]
+        );
+
+        // Take the project offline. It appears as loading.
+        project.update(cx, |project, cx| project.set_online(false, cx));
+        cx.foreground().run_until_parked();
+        assert_eq!(
+            cx.read(|cx| render_to_strings(&panel, cx)),
+            &[
+                "v Requests",
+                "  incoming user_one",
+                "  outgoing user_two",
+                "v Online",
+                "  the_current_user",
+                "    dir3",
+                "    private_dir (going offline...)",
+                "  user_four",
+                "    dir2",
+                "  user_three",
+                "    dir1",
+                "v Offline",
+                "  user_five",
+            ]
+        );
+
+        // The server receives the unregister request and updates the contact
+        // metadata for the current user. The project is now offline.
+        let request = server.receive::<proto::UnregisterProject>().await.unwrap();
+        server.send(proto::UpdateContacts {
+            contacts: vec![proto::Contact {
+                user_id: current_user_id,
+                online: true,
+                should_notify: false,
+                projects: vec![proto::ProjectMetadata {
+                    id: 103,
+                    worktree_root_names: vec!["dir3".to_string()],
+                    guests: vec![3],
+                }],
+            }],
+            ..Default::default()
+        });
+        cx.foreground().run_until_parked();
+        assert_eq!(
+            cx.read(|cx| render_to_strings(&panel, cx)),
+            &[
+                "v Requests",
+                "  incoming user_one",
+                "  outgoing user_two",
+                "v Online",
+                "  the_current_user",
+                "    dir3",
+                "    🔒 private_dir",
+                "  user_four",
+                "    dir2",
+                "  user_three",
+                "    dir1",
+                "v Offline",
+                "  user_five",
+            ]
+        );
+
+        // The server responds to the unregister request.
+        server.respond(request.receipt(), proto::Ack {}).await;
+        cx.foreground().run_until_parked();
+        assert_eq!(
+            cx.read(|cx| render_to_strings(&panel, cx)),
+            &[
+                "v Requests",
+                "  incoming user_one",
+                "  outgoing user_two",
+                "v Online",
+                "  the_current_user",
+                "    dir3",
+                "    🔒 private_dir",
                 "  user_four",
                 "    dir2",
                 "  user_three",

crates/editor/Cargo.toml 🔗

@@ -59,7 +59,7 @@ project = { path = "../project", features = ["test-support"] }
 settings = { path = "../settings", features = ["test-support"] }
 workspace = { path = "../workspace", features = ["test-support"] }
 ctor = "0.1"
-env_logger = "0.8"
+env_logger = "0.9"
 rand = "0.8"
 unindent = "0.1.7"
 tree-sitter = "0.20"

crates/file_finder/Cargo.toml 🔗

@@ -25,4 +25,4 @@ gpui = { path = "../gpui", features = ["test-support"] }
 serde_json = { version = "1.0", features = ["preserve_order"] }
 workspace = { path = "../workspace", features = ["test-support"] }
 ctor = "0.1"
-env_logger = "0.8"
+env_logger = "0.9"

crates/gpui/Cargo.toml 🔗

@@ -20,7 +20,7 @@ async-task = "4.0.3"
 backtrace = { version = "0.3", optional = true }
 ctor = "0.1"
 dhat = { version = "0.3", optional = true }
-env_logger = { version = "0.8", optional = true }
+env_logger = { version = "0.9", optional = true }
 etagere = "0.2"
 futures = "0.3"
 image = "0.23"
@@ -47,14 +47,14 @@ usvg = "0.14"
 waker-fn = "1.1.0"
 
 [build-dependencies]
-bindgen = "0.58.1"
+bindgen = "0.59.2"
 cc = "1.0.67"
 
 [dev-dependencies]
 backtrace = "0.3"
 collections = { path = "../collections", features = ["test-support"] }
 dhat = "0.3"
-env_logger = "0.8"
+env_logger = "0.9"
 png = "0.16"
 simplelog = "0.9"
 

crates/gpui/src/app.rs 🔗

@@ -499,7 +499,14 @@ impl TestAppContext {
         Fut: 'static + Future<Output = T>,
         T: 'static,
     {
-        self.cx.borrow_mut().spawn(f)
+        let foreground = self.foreground();
+        let future = f(self.to_async());
+        let cx = self.to_async();
+        foreground.spawn(async move {
+            let result = future.await;
+            cx.0.borrow_mut().flush_effects();
+            result
+        })
     }
 
     pub fn simulate_new_path_selection(&self, result: impl FnOnce(PathBuf) -> Option<PathBuf>) {
@@ -4604,6 +4611,10 @@ impl<T: View> WeakViewHandle<T> {
         self.view_id
     }
 
+    pub fn window_id(&self) -> usize {
+        self.window_id
+    }
+
     pub fn upgrade(&self, cx: &impl UpgradeViewHandle) -> Option<ViewHandle<T>> {
         cx.upgrade_view_handle(self)
     }

crates/gpui/src/platform.rs 🔗

@@ -147,6 +147,12 @@ pub struct AppVersion {
     patch: usize,
 }
 
+impl Default for CursorStyle {
+    fn default() -> Self {
+        Self::Arrow
+    }
+}
+
 impl FromStr for AppVersion {
     type Err = anyhow::Error;
 

crates/language/Cargo.toml 🔗

@@ -57,7 +57,7 @@ lsp = { path = "../lsp", features = ["test-support"] }
 text = { path = "../text", features = ["test-support"] }
 util = { path = "../util", features = ["test-support"] }
 ctor = "0.1"
-env_logger = "0.8"
+env_logger = "0.9"
 rand = "0.8.3"
 tree-sitter-json = "*"
 tree-sitter-rust = "*"

crates/lsp/Cargo.toml 🔗

@@ -30,5 +30,5 @@ gpui = { path = "../gpui", features = ["test-support"] }
 util = { path = "../util", features = ["test-support"] }
 async-pipe = { git = "https://github.com/zed-industries/async-pipe-rs", rev = "82d00a04211cf4e1236029aa03e6b6ce2a74c553" }
 ctor = "0.1"
-env_logger = "0.8"
+env_logger = "0.9"
 unindent = "0.1.7"

crates/picker/Cargo.toml 🔗

@@ -21,4 +21,4 @@ gpui = { path = "../gpui", features = ["test-support"] }
 serde_json = { version = "1.0", features = ["preserve_order"] }
 workspace = { path = "../workspace", features = ["test-support"] }
 ctor = "0.1"
-env_logger = "0.8"
+env_logger = "0.9"

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;
@@ -25,6 +26,7 @@ use language::{
 use lsp::{DiagnosticSeverity, DiagnosticTag, DocumentHighlightKind, LanguageServer};
 use lsp_command::*;
 use parking_lot::Mutex;
+use postage::stream::Stream;
 use postage::watch;
 use rand::prelude::*;
 use search::SearchQuery;
@@ -52,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::*;
 
@@ -59,6 +62,11 @@ pub trait Item: Entity {
     fn entry_id(&self, cx: &AppContext) -> Option<ProjectEntryId>;
 }
 
+pub struct ProjectStore {
+    db: Arc<Db>,
+    projects: Vec<WeakModelHandle<Project>>,
+}
+
 pub struct Project {
     worktrees: Vec<WorktreeHandle>,
     active_entry: Option<ProjectEntryId>,
@@ -75,6 +83,7 @@ pub struct Project {
     next_entry_id: Arc<AtomicUsize>,
     next_diagnostic_group_id: usize,
     user_store: ModelHandle<UserStore>,
+    project_store: ModelHandle<ProjectStore>,
     fs: Arc<dyn Fs>,
     client_state: ProjectClientState,
     collaborators: HashMap<PeerId, Collaborator>,
@@ -90,6 +99,7 @@ pub struct Project {
     opened_buffers: HashMap<u64, OpenBuffer>,
     buffer_snapshots: HashMap<u64, Vec<(i32, TextBufferSnapshot)>>,
     nonce: u128,
+    initialized_persistent_state: bool,
 }
 
 #[derive(Error, Debug)]
@@ -120,6 +130,8 @@ enum ProjectClientState {
         is_shared: bool,
         remote_id_tx: watch::Sender<Option<u64>>,
         remote_id_rx: watch::Receiver<Option<u64>>,
+        online_tx: watch::Sender<bool>,
+        online_rx: watch::Receiver<bool>,
         _maintain_remote_id_task: Task<Option<()>>,
     },
     Remote {
@@ -273,8 +285,7 @@ impl Project {
         client.add_model_message_handler(Self::handle_update_language_server);
         client.add_model_message_handler(Self::handle_remove_collaborator);
         client.add_model_message_handler(Self::handle_join_project_request_cancelled);
-        client.add_model_message_handler(Self::handle_register_worktree);
-        client.add_model_message_handler(Self::handle_unregister_worktree);
+        client.add_model_message_handler(Self::handle_update_project);
         client.add_model_message_handler(Self::handle_unregister_project);
         client.add_model_message_handler(Self::handle_project_unshared);
         client.add_model_message_handler(Self::handle_update_buffer_file);
@@ -305,34 +316,42 @@ impl Project {
     }
 
     pub fn local(
+        online: bool,
         client: Arc<Client>,
         user_store: ModelHandle<UserStore>,
+        project_store: ModelHandle<ProjectStore>,
         languages: Arc<LanguageRegistry>,
         fs: Arc<dyn Fs>,
         cx: &mut MutableAppContext,
     ) -> ModelHandle<Self> {
         cx.add_model(|cx: &mut ModelContext<Self>| {
+            let (online_tx, online_rx) = watch::channel_with(online);
             let (remote_id_tx, remote_id_rx) = watch::channel();
             let _maintain_remote_id_task = cx.spawn_weak({
-                let rpc = client.clone();
-                move |this, mut cx| {
-                    async move {
-                        let mut status = rpc.status();
-                        while let Some(status) = status.next().await {
-                            if let Some(this) = this.upgrade(&cx) {
-                                if status.is_connected() {
-                                    this.update(&mut cx, |this, cx| this.register(cx)).await?;
-                                } else {
-                                    this.update(&mut cx, |this, cx| this.unregister(cx));
-                                }
-                            }
+                let status_rx = client.clone().status();
+                let online_rx = online_rx.clone();
+                move |this, mut cx| async move {
+                    let mut stream = Stream::map(status_rx.clone(), drop)
+                        .merge(Stream::map(online_rx.clone(), drop));
+                    while stream.recv().await.is_some() {
+                        let this = this.upgrade(&cx)?;
+                        if status_rx.borrow().is_connected() && *online_rx.borrow() {
+                            this.update(&mut cx, |this, cx| this.register(cx))
+                                .await
+                                .log_err()?;
+                        } else {
+                            this.update(&mut cx, |this, cx| this.unregister(cx))
+                                .await
+                                .log_err();
                         }
-                        Ok(())
                     }
-                    .log_err()
+                    None
                 }
             });
 
+            let handle = cx.weak_handle();
+            project_store.update(cx, |store, cx| store.add_project(handle, cx));
+
             let (opened_buffer_tx, opened_buffer_rx) = watch::channel();
             Self {
                 worktrees: Default::default(),
@@ -346,6 +365,8 @@ impl Project {
                     is_shared: false,
                     remote_id_tx,
                     remote_id_rx,
+                    online_tx,
+                    online_rx,
                     _maintain_remote_id_task,
                 },
                 opened_buffer: (Rc::new(RefCell::new(opened_buffer_tx)), opened_buffer_rx),
@@ -354,6 +375,7 @@ impl Project {
                 languages,
                 client,
                 user_store,
+                project_store,
                 fs,
                 next_entry_id: Default::default(),
                 next_diagnostic_group_id: Default::default(),
@@ -364,6 +386,7 @@ impl Project {
                 language_server_settings: Default::default(),
                 next_language_server_id: 0,
                 nonce: StdRng::from_entropy().gen(),
+                initialized_persistent_state: false,
             }
         })
     }
@@ -372,9 +395,10 @@ impl Project {
         remote_id: u64,
         client: Arc<Client>,
         user_store: ModelHandle<UserStore>,
+        project_store: ModelHandle<ProjectStore>,
         languages: Arc<LanguageRegistry>,
         fs: Arc<dyn Fs>,
-        cx: &mut AsyncAppContext,
+        mut cx: AsyncAppContext,
     ) -> Result<ModelHandle<Self>, JoinProjectError> {
         client.authenticate_and_connect(true, &cx).await?;
 
@@ -414,6 +438,9 @@ impl Project {
 
         let (opened_buffer_tx, opened_buffer_rx) = watch::channel();
         let this = cx.add_model(|cx: &mut ModelContext<Self>| {
+            let handle = cx.weak_handle();
+            project_store.update(cx, |store, cx| store.add_project(handle, cx));
+
             let mut this = Self {
                 worktrees: Vec::new(),
                 loading_buffers: Default::default(),
@@ -424,6 +451,7 @@ impl Project {
                 collaborators: Default::default(),
                 languages,
                 user_store: user_store.clone(),
+                project_store,
                 fs,
                 next_entry_id: Default::default(),
                 next_diagnostic_group_id: Default::default(),
@@ -471,6 +499,7 @@ impl Project {
                 opened_buffers: Default::default(),
                 buffer_snapshots: Default::default(),
                 nonce: StdRng::from_entropy().gen(),
+                initialized_persistent_state: false,
             };
             for worktree in worktrees {
                 this.add_worktree(&worktree, cx);
@@ -484,15 +513,15 @@ impl Project {
             .map(|peer| peer.user_id)
             .collect();
         user_store
-            .update(cx, |user_store, cx| user_store.get_users(user_ids, cx))
+            .update(&mut cx, |user_store, cx| user_store.get_users(user_ids, cx))
             .await?;
         let mut collaborators = HashMap::default();
         for message in response.collaborators {
-            let collaborator = Collaborator::from_proto(message, &user_store, cx).await?;
+            let collaborator = Collaborator::from_proto(message, &user_store, &mut cx).await?;
             collaborators.insert(collaborator.peer_id, collaborator);
         }
 
-        this.update(cx, |this, _| {
+        this.update(&mut cx, |this, _| {
             this.collaborators = collaborators;
         });
 
@@ -509,7 +538,10 @@ 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 = cx.update(|cx| Project::local(client, user_store, languages, fs, cx));
+        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)
+        });
         for path in root_paths {
             let (tree, _) = project
                 .update(cx, |project, cx| {
@@ -523,6 +555,53 @@ impl Project {
         project
     }
 
+    pub fn restore_state(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
+        if self.is_remote() {
+            return Task::ready(Ok(()));
+        }
+
+        let db = self.project_store.read(cx).db.clone();
+        let keys = self.db_keys_for_online_state(cx);
+        let online_by_default = cx.global::<Settings>().projects_online_by_default;
+        let read_online = cx.background().spawn(async move {
+            let values = db.read(keys)?;
+            anyhow::Ok(
+                values
+                    .into_iter()
+                    .all(|e| e.map_or(online_by_default, |e| e == [true as u8])),
+            )
+        });
+        cx.spawn(|this, mut cx| async move {
+            let online = read_online.await.log_err().unwrap_or(false);
+            this.update(&mut cx, |this, cx| {
+                this.initialized_persistent_state = true;
+                if let ProjectClientState::Local { online_tx, .. } = &mut this.client_state {
+                    let mut online_tx = online_tx.borrow_mut();
+                    if *online_tx != online {
+                        *online_tx = online;
+                        drop(online_tx);
+                        this.metadata_changed(false, cx);
+                    }
+                }
+            });
+            Ok(())
+        })
+    }
+
+    fn persist_state(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
+        if self.is_remote() || !self.initialized_persistent_state {
+            return Task::ready(Ok(()));
+        }
+
+        let db = self.project_store.read(cx).db.clone();
+        let keys = self.db_keys_for_online_state(cx);
+        let is_online = self.is_online();
+        cx.background().spawn(async move {
+            let value = &[is_online as u8];
+            db.write(keys.into_iter().map(|key| (key, value)))
+        })
+    }
+
     pub fn buffer_for_id(&self, remote_id: u64, cx: &AppContext) -> Option<ModelHandle<Buffer>> {
         self.opened_buffers
             .get(&remote_id)
@@ -541,6 +620,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() {
@@ -598,53 +681,83 @@ impl Project {
         &self.fs
     }
 
-    fn unregister(&mut self, cx: &mut ModelContext<Self>) {
-        self.unshared(cx);
-        for worktree in &self.worktrees {
-            if let Some(worktree) = worktree.upgrade(cx) {
-                worktree.update(cx, |worktree, _| {
-                    worktree.as_local_mut().unwrap().unregister();
-                });
+    pub fn set_online(&mut self, online: bool, cx: &mut ModelContext<Self>) {
+        if let ProjectClientState::Local { online_tx, .. } = &mut self.client_state {
+            let mut online_tx = online_tx.borrow_mut();
+            if *online_tx != online {
+                *online_tx = online;
+                drop(online_tx);
+                self.metadata_changed(true, cx);
             }
         }
+    }
 
-        if let ProjectClientState::Local { remote_id_tx, .. } = &mut self.client_state {
-            *remote_id_tx.borrow_mut() = None;
+    pub fn is_online(&self) -> bool {
+        match &self.client_state {
+            ProjectClientState::Local { online_rx, .. } => *online_rx.borrow(),
+            ProjectClientState::Remote { .. } => true,
         }
+    }
 
-        self.subscriptions.clear();
+    fn unregister(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
+        self.unshared(cx);
+        if let ProjectClientState::Local { remote_id_rx, .. } = &mut self.client_state {
+            if let Some(remote_id) = *remote_id_rx.borrow() {
+                let request = self.client.request(proto::UnregisterProject {
+                    project_id: remote_id,
+                });
+                return cx.spawn(|this, mut cx| async move {
+                    let response = request.await;
+
+                    // Unregistering the project causes the server to send out a
+                    // contact update removing this project from the host's list
+                    // of online projects. Wait until this contact update has been
+                    // processed before clearing out this project's remote id, so
+                    // that there is no moment where this project appears in the
+                    // contact metadata and *also* has no remote id.
+                    this.update(&mut cx, |this, cx| {
+                        this.user_store()
+                            .update(cx, |store, _| store.contact_updates_done())
+                    })
+                    .await;
+
+                    this.update(&mut cx, |this, cx| {
+                        if let ProjectClientState::Local { remote_id_tx, .. } =
+                            &mut this.client_state
+                        {
+                            *remote_id_tx.borrow_mut() = None;
+                        }
+                        this.subscriptions.clear();
+                        this.metadata_changed(false, cx);
+                    });
+                    response.map(drop)
+                });
+            }
+        }
+        Task::ready(Ok(()))
     }
 
     fn register(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
-        self.unregister(cx);
+        if let ProjectClientState::Local { remote_id_rx, .. } = &self.client_state {
+            if remote_id_rx.borrow().is_some() {
+                return Task::ready(Ok(()));
+            }
+        }
 
         let response = self.client.request(proto::RegisterProject {});
         cx.spawn(|this, mut cx| async move {
             let remote_id = response.await?.project_id;
-
-            let mut registrations = Vec::new();
             this.update(&mut cx, |this, cx| {
                 if let ProjectClientState::Local { remote_id_tx, .. } = &mut this.client_state {
                     *remote_id_tx.borrow_mut() = Some(remote_id);
                 }
 
+                this.metadata_changed(false, cx);
                 cx.emit(Event::RemoteIdChanged(Some(remote_id)));
-
                 this.subscriptions
                     .push(this.client.add_model_for_remote_entity(remote_id, cx));
-
-                for worktree in &this.worktrees {
-                    if let Some(worktree) = worktree.upgrade(cx) {
-                        registrations.push(worktree.update(cx, |worktree, cx| {
-                            let worktree = worktree.as_local_mut().unwrap();
-                            worktree.register(remote_id, cx)
-                        }));
-                    }
-                }
-            });
-
-            futures::future::try_join_all(registrations).await?;
-            Ok(())
+                Ok(())
+            })
         })
     }
 
@@ -702,6 +815,38 @@ impl Project {
         }
     }
 
+    fn metadata_changed(&mut self, persist: bool, cx: &mut ModelContext<Self>) {
+        if let ProjectClientState::Local {
+            remote_id_rx,
+            online_rx,
+            ..
+        } = &self.client_state
+        {
+            if let (Some(project_id), true) = (*remote_id_rx.borrow(), *online_rx.borrow()) {
+                self.client
+                    .send(proto::UpdateProject {
+                        project_id,
+                        worktrees: self
+                            .worktrees
+                            .iter()
+                            .filter_map(|worktree| {
+                                worktree.upgrade(&cx).map(|worktree| {
+                                    worktree.read(cx).as_local().unwrap().metadata_proto()
+                                })
+                            })
+                            .collect(),
+                    })
+                    .log_err();
+            }
+
+            self.project_store.update(cx, |_, cx| cx.notify());
+            if persist {
+                self.persist_state(cx).detach_and_log_err(cx);
+            }
+            cx.notify();
+        }
+    }
+
     pub fn collaborators(&self) -> &HashMap<PeerId, Collaborator> {
         &self.collaborators
     }
@@ -730,6 +875,28 @@ impl Project {
         })
     }
 
+    pub fn worktree_root_names<'a>(&'a self, cx: &'a AppContext) -> impl Iterator<Item = &'a str> {
+        self.visible_worktrees(cx)
+            .map(|tree| tree.read(cx).root_name())
+    }
+
+    fn db_keys_for_online_state(&self, cx: &AppContext) -> Vec<String> {
+        self.worktrees
+            .iter()
+            .filter_map(|worktree| {
+                let worktree = worktree.upgrade(&cx)?.read(cx);
+                if worktree.is_visible() {
+                    Some(format!(
+                        "project-path-online:{}",
+                        worktree.as_local().unwrap().abs_path().to_string_lossy()
+                    ))
+                } else {
+                    None
+                }
+            })
+            .collect::<Vec<_>>()
+    }
+
     pub fn worktree_for_id(
         &self,
         id: WorktreeId,
@@ -757,6 +924,20 @@ impl Project {
             .map(|worktree| worktree.read(cx).id())
     }
 
+    pub fn contains_paths(&self, paths: &[PathBuf], cx: &AppContext) -> bool {
+        paths.iter().all(|path| self.contains_path(&path, cx))
+    }
+
+    pub fn contains_path(&self, path: &Path, cx: &AppContext) -> bool {
+        for worktree in self.worktrees(cx) {
+            let worktree = worktree.read(cx).as_local();
+            if worktree.map_or(false, |w| w.contains_abs_path(path)) {
+                return true;
+            }
+        }
+        false
+    }
+
     pub fn create_entry(
         &mut self,
         project_path: impl Into<ProjectPath>,
@@ -3619,37 +3800,18 @@ impl Project {
                         });
                         let worktree = worktree?;
 
-                        let remote_project_id = project.update(&mut cx, |project, cx| {
+                        let project_id = project.update(&mut cx, |project, cx| {
                             project.add_worktree(&worktree, cx);
-                            project.remote_id()
+                            project.shared_remote_id()
                         });
 
-                        if let Some(project_id) = remote_project_id {
-                            // Because sharing is async, we may have *unshared* the project by the time it completes,
-                            // in which case we need to register the worktree instead.
-                            loop {
-                                if project.read_with(&cx, |project, _| project.is_shared()) {
-                                    if worktree
-                                        .update(&mut cx, |worktree, cx| {
-                                            worktree.as_local_mut().unwrap().share(project_id, cx)
-                                        })
-                                        .await
-                                        .is_ok()
-                                    {
-                                        break;
-                                    }
-                                } else {
-                                    worktree
-                                        .update(&mut cx, |worktree, cx| {
-                                            worktree
-                                                .as_local_mut()
-                                                .unwrap()
-                                                .register(project_id, cx)
-                                        })
-                                        .await?;
-                                    break;
-                                }
-                            }
+                        if let Some(project_id) = project_id {
+                            worktree
+                                .update(&mut cx, |worktree, cx| {
+                                    worktree.as_local_mut().unwrap().share(project_id, cx)
+                                })
+                                .await
+                                .log_err();
                         }
 
                         Ok(worktree)
@@ -3681,6 +3843,7 @@ impl Project {
                 false
             }
         });
+        self.metadata_changed(true, cx);
         cx.notify();
     }
 
@@ -3710,6 +3873,7 @@ impl Project {
             self.worktrees
                 .push(WorktreeHandle::Weak(worktree.downgrade()));
         }
+        self.metadata_changed(true, cx);
         cx.emit(Event::WorktreeAdded);
         cx.notify();
     }
@@ -3992,40 +4156,51 @@ impl Project {
         Ok(())
     }
 
-    async fn handle_register_worktree(
+    async fn handle_update_project(
         this: ModelHandle<Self>,
-        envelope: TypedEnvelope<proto::RegisterWorktree>,
+        envelope: TypedEnvelope<proto::UpdateProject>,
         client: Arc<Client>,
         mut cx: AsyncAppContext,
     ) -> Result<()> {
         this.update(&mut cx, |this, cx| {
-            let remote_id = this.remote_id().ok_or_else(|| anyhow!("invalid project"))?;
             let replica_id = this.replica_id();
-            let worktree = proto::Worktree {
-                id: envelope.payload.worktree_id,
-                root_name: envelope.payload.root_name,
-                entries: Default::default(),
-                diagnostic_summaries: Default::default(),
-                visible: envelope.payload.visible,
-                scan_id: 0,
-            };
-            let (worktree, load_task) =
-                Worktree::remote(remote_id, replica_id, worktree, client, cx);
-            this.add_worktree(&worktree, cx);
-            load_task.detach();
-            Ok(())
-        })
-    }
+            let remote_id = this.remote_id().ok_or_else(|| anyhow!("invalid project"))?;
+
+            let mut old_worktrees_by_id = this
+                .worktrees
+                .drain(..)
+                .filter_map(|worktree| {
+                    let worktree = worktree.upgrade(cx)?;
+                    Some((worktree.read(cx).id(), worktree))
+                })
+                .collect::<HashMap<_, _>>();
+
+            for worktree in envelope.payload.worktrees {
+                if let Some(old_worktree) =
+                    old_worktrees_by_id.remove(&WorktreeId::from_proto(worktree.id))
+                {
+                    this.worktrees.push(WorktreeHandle::Strong(old_worktree));
+                } else {
+                    let worktree = proto::Worktree {
+                        id: worktree.id,
+                        root_name: worktree.root_name,
+                        entries: Default::default(),
+                        diagnostic_summaries: Default::default(),
+                        visible: worktree.visible,
+                        scan_id: 0,
+                    };
+                    let (worktree, load_task) =
+                        Worktree::remote(remote_id, replica_id, worktree, client.clone(), cx);
+                    this.add_worktree(&worktree, cx);
+                    load_task.detach();
+                }
+            }
+
+            this.metadata_changed(true, cx);
+            for (id, _) in old_worktrees_by_id {
+                cx.emit(Event::WorktreeRemoved(id));
+            }
 
-    async fn handle_unregister_worktree(
-        this: ModelHandle<Self>,
-        envelope: TypedEnvelope<proto::UnregisterWorktree>,
-        _: Arc<Client>,
-        mut cx: AsyncAppContext,
-    ) -> Result<()> {
-        this.update(&mut cx, |this, cx| {
-            let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id);
-            this.remove_worktree(worktree_id, cx);
             Ok(())
         })
     }
@@ -5132,6 +5307,49 @@ 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,
+    ) -> impl 'a + Iterator<Item = ModelHandle<Project>> {
+        self.projects
+            .iter()
+            .filter_map(|project| project.upgrade(cx))
+    }
+
+    fn add_project(&mut self, project: WeakModelHandle<Project>, cx: &mut ModelContext<Self>) {
+        if let Err(ix) = self
+            .projects
+            .binary_search_by_key(&project.id(), WeakModelHandle::id)
+        {
+            self.projects.insert(ix, project);
+        }
+        cx.notify();
+    }
+
+    fn prune_projects(&mut self, cx: &mut ModelContext<Self>) {
+        let mut did_change = false;
+        self.projects.retain(|project| {
+            if project.is_upgradable(cx) {
+                true
+            } else {
+                did_change = true;
+                false
+            }
+        });
+        if did_change {
+            cx.notify();
+        }
+    }
+}
+
 impl WorktreeHandle {
     pub fn upgrade(&self, cx: &AppContext) -> Option<ModelHandle<Worktree>> {
         match self {
@@ -5210,10 +5428,16 @@ impl<'a> Iterator for CandidateSetIter<'a> {
     }
 }
 
+impl Entity for ProjectStore {
+    type Event = ();
+}
+
 impl Entity for Project {
     type Event = Event;
 
-    fn release(&mut self, _: &mut gpui::MutableAppContext) {
+    fn release(&mut self, cx: &mut gpui::MutableAppContext) {
+        self.project_store.update(cx, ProjectStore::prune_projects);
+
         match &self.client_state {
             ProjectClientState::Local { remote_id_rx, .. } => {
                 if let Some(project_id) = *remote_id_rx.borrow() {

crates/project/src/worktree.rs 🔗

@@ -68,7 +68,6 @@ pub struct LocalWorktree {
     last_scan_state_rx: watch::Receiver<ScanState>,
     _background_scanner_task: Option<Task<()>>,
     poll_task: Option<Task<()>>,
-    registration: Registration,
     share: Option<ShareState>,
     diagnostics: HashMap<Arc<Path>, Vec<DiagnosticEntry<PointUtf16>>>,
     diagnostic_summaries: TreeMap<PathKey, DiagnosticSummary>,
@@ -129,13 +128,6 @@ enum ScanState {
     Err(Arc<anyhow::Error>),
 }
 
-#[derive(Debug, Eq, PartialEq)]
-enum Registration {
-    None,
-    Pending,
-    Done { project_id: u64 },
-}
-
 struct ShareState {
     project_id: u64,
     snapshots_tx: Sender<LocalSnapshot>,
@@ -148,19 +140,6 @@ pub enum Event {
 
 impl Entity for Worktree {
     type Event = Event;
-
-    fn release(&mut self, _: &mut MutableAppContext) {
-        if let Some(worktree) = self.as_local_mut() {
-            if let Registration::Done { project_id } = worktree.registration {
-                let client = worktree.client.clone();
-                let unregister_message = proto::UnregisterWorktree {
-                    project_id,
-                    worktree_id: worktree.id().to_proto(),
-                };
-                client.send(unregister_message).log_err();
-            }
-        }
-    }
 }
 
 impl Worktree {
@@ -486,7 +465,6 @@ impl LocalWorktree {
                 background_snapshot: Arc::new(Mutex::new(snapshot)),
                 last_scan_state_rx,
                 _background_scanner_task: None,
-                registration: Registration::None,
                 share: None,
                 poll_task: None,
                 diagnostics: Default::default(),
@@ -608,6 +586,14 @@ impl LocalWorktree {
         self.snapshot.clone()
     }
 
+    pub fn metadata_proto(&self) -> proto::WorktreeMetadata {
+        proto::WorktreeMetadata {
+            id: self.id().to_proto(),
+            root_name: self.root_name().to_string(),
+            visible: self.visible,
+        }
+    }
+
     fn load(&self, path: &Path, cx: &mut ModelContext<Worktree>) -> Task<Result<(File, String)>> {
         let handle = cx.handle();
         let path = Arc::from(path);
@@ -904,46 +890,7 @@ impl LocalWorktree {
         })
     }
 
-    pub fn register(
-        &mut self,
-        project_id: u64,
-        cx: &mut ModelContext<Worktree>,
-    ) -> Task<anyhow::Result<()>> {
-        if self.registration != Registration::None {
-            return Task::ready(Ok(()));
-        }
-
-        self.registration = Registration::Pending;
-        let client = self.client.clone();
-        let register_message = proto::RegisterWorktree {
-            project_id,
-            worktree_id: self.id().to_proto(),
-            root_name: self.root_name().to_string(),
-            visible: self.visible,
-        };
-        let request = client.request(register_message);
-        cx.spawn(|this, mut cx| async move {
-            let response = request.await;
-            this.update(&mut cx, |this, _| {
-                let worktree = this.as_local_mut().unwrap();
-                match response {
-                    Ok(_) => {
-                        if worktree.registration == Registration::Pending {
-                            worktree.registration = Registration::Done { project_id };
-                        }
-                        Ok(())
-                    }
-                    Err(error) => {
-                        worktree.registration = Registration::None;
-                        Err(error)
-                    }
-                }
-            })
-        })
-    }
-
     pub fn share(&mut self, project_id: u64, cx: &mut ModelContext<Worktree>) -> Task<Result<()>> {
-        let register = self.register(project_id, cx);
         let (share_tx, share_rx) = oneshot::channel();
         let (snapshots_to_send_tx, snapshots_to_send_rx) =
             smol::channel::unbounded::<LocalSnapshot>();
@@ -1048,7 +995,6 @@ impl LocalWorktree {
         }
 
         cx.spawn_weak(|this, cx| async move {
-            register.await?;
             if let Some(this) = this.upgrade(&cx) {
                 this.read_with(&cx, |this, _| {
                     let this = this.as_local().unwrap();
@@ -1061,11 +1007,6 @@ impl LocalWorktree {
         })
     }
 
-    pub fn unregister(&mut self) {
-        self.unshare();
-        self.registration = Registration::None;
-    }
-
     pub fn unshare(&mut self) {
         self.share.take();
     }

crates/rpc/Cargo.toml 🔗

@@ -31,7 +31,7 @@ tracing = { version = "0.1.34", features = ["log"] }
 zstd = "0.9"
 
 [build-dependencies]
-prost-build = "0.8"
+prost-build = "0.9"
 
 [dev-dependencies]
 collections = { path = "../collections", features = ["test-support"] }

crates/rpc/proto/zed.proto 🔗

@@ -35,8 +35,7 @@ message Envelope {
         OpenBufferForSymbol open_buffer_for_symbol = 28;
         OpenBufferForSymbolResponse open_buffer_for_symbol_response = 29;
 
-        RegisterWorktree register_worktree = 30;
-        UnregisterWorktree unregister_worktree = 31;
+        UpdateProject update_project = 30;
         UpdateWorktree update_worktree = 32;
 
         CreateProjectEntry create_project_entry = 33;
@@ -129,6 +128,11 @@ message UnregisterProject {
     uint64 project_id = 1;
 }
 
+message UpdateProject {
+    uint64 project_id = 1;
+    repeated WorktreeMetadata worktrees = 2;
+}
+
 message RequestJoinProject {
     uint64 requester_id = 1;
     uint64 project_id = 2;
@@ -177,18 +181,6 @@ message LeaveProject {
     uint64 project_id = 1;
 }
 
-message RegisterWorktree {
-    uint64 project_id = 1;
-    uint64 worktree_id = 2;
-    string root_name = 3;
-    bool visible = 4;
-}
-
-message UnregisterWorktree {
-    uint64 project_id = 1;
-    uint64 worktree_id = 2;
-}
-
 message UpdateWorktree {
     uint64 project_id = 1;
     uint64 worktree_id = 2;
@@ -934,3 +926,9 @@ message ProjectMetadata {
     repeated string worktree_root_names = 3;
     repeated uint64 guests = 4;
 }
+
+message WorktreeMetadata {
+    uint64 id = 1;
+    string root_name = 2;
+    bool visible = 3;
+}

crates/rpc/src/proto.rs 🔗

@@ -132,7 +132,6 @@ messages!(
     (Ping, Foreground),
     (ProjectUnshared, Foreground),
     (RegisterProject, Foreground),
-    (RegisterWorktree, Foreground),
     (ReloadBuffers, Foreground),
     (ReloadBuffersResponse, Foreground),
     (RemoveProjectCollaborator, Foreground),
@@ -151,7 +150,6 @@ messages!(
     (Test, Foreground),
     (Unfollow, Foreground),
     (UnregisterProject, Foreground),
-    (UnregisterWorktree, Foreground),
     (UpdateBuffer, Foreground),
     (UpdateBufferFile, Foreground),
     (UpdateContacts, Foreground),
@@ -159,6 +157,7 @@ messages!(
     (UpdateFollowers, Foreground),
     (UpdateInviteInfo, Foreground),
     (UpdateLanguageServer, Foreground),
+    (UpdateProject, Foreground),
     (UpdateWorktree, Foreground),
 );
 
@@ -192,7 +191,6 @@ request_messages!(
     (PerformRename, PerformRenameResponse),
     (PrepareRename, PrepareRenameResponse),
     (RegisterProject, RegisterProjectResponse),
-    (RegisterWorktree, Ack),
     (ReloadBuffers, ReloadBuffersResponse),
     (RequestContact, Ack),
     (RemoveContact, Ack),
@@ -202,6 +200,7 @@ request_messages!(
     (SearchProject, SearchProjectResponse),
     (SendChannelMessage, SendChannelMessageResponse),
     (Test, Test),
+    (UnregisterProject, Ack),
     (UpdateBuffer, Ack),
     (UpdateWorktree, Ack),
 );
@@ -242,13 +241,12 @@ entity_messages!(
     StartLanguageServer,
     Unfollow,
     UnregisterProject,
-    UnregisterWorktree,
     UpdateBuffer,
     UpdateBufferFile,
     UpdateDiagnosticSummary,
     UpdateFollowers,
     UpdateLanguageServer,
-    RegisterWorktree,
+    UpdateProject,
     UpdateWorktree,
 );
 

crates/rpc/src/rpc.rs 🔗

@@ -6,4 +6,4 @@ pub use conn::Connection;
 pub use peer::*;
 mod macros;
 
-pub const PROTOCOL_VERSION: u32 = 21;
+pub const PROTOCOL_VERSION: u32 = 22;

crates/settings/src/settings.rs 🔗

@@ -19,6 +19,7 @@ pub use keymap_file::{keymap_file_json_schema, KeymapFileContent};
 
 #[derive(Clone)]
 pub struct Settings {
+    pub projects_online_by_default: bool,
     pub buffer_font_family: FamilyId,
     pub buffer_font_size: f32,
     pub default_buffer_font_size: f32,
@@ -49,6 +50,8 @@ pub enum SoftWrap {
 
 #[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
 pub struct SettingsFileContent {
+    #[serde(default)]
+    pub projects_online_by_default: Option<bool>,
     #[serde(default)]
     pub buffer_font_family: Option<String>,
     #[serde(default)]
@@ -81,6 +84,7 @@ impl Settings {
             preferred_line_length: 80,
             language_overrides: Default::default(),
             format_on_save: true,
+            projects_online_by_default: true,
             theme,
         })
     }
@@ -135,6 +139,7 @@ impl Settings {
             preferred_line_length: 80,
             format_on_save: true,
             language_overrides: Default::default(),
+            projects_online_by_default: true,
             theme: gpui::fonts::with_font_cache(cx.font_cache().clone(), || Default::default()),
         }
     }
@@ -164,6 +169,10 @@ impl Settings {
             }
         }
 
+        merge(
+            &mut self.projects_online_by_default,
+            data.projects_online_by_default,
+        );
         merge(&mut self.buffer_font_size, data.buffer_font_size);
         merge(&mut self.default_buffer_font_size, data.buffer_font_size);
         merge(&mut self.vim_mode, data.vim_mode);

crates/sum_tree/Cargo.toml 🔗

@@ -13,5 +13,5 @@ log = { version = "0.4.16", features = ["kv_unstable_serde"] }
 
 [dev-dependencies]
 ctor = "0.1"
-env_logger = "0.8"
+env_logger = "0.9"
 rand = "0.8.3"

crates/text/Cargo.toml 🔗

@@ -28,5 +28,5 @@ collections = { path = "../collections", features = ["test-support"] }
 gpui = { path = "../gpui", features = ["test-support"] }
 util = { path = "../util", features = ["test-support"] }
 ctor = "0.1"
-env_logger = "0.8"
+env_logger = "0.9"
 rand = "0.8.3"

crates/theme/src/theme.rs 🔗

@@ -279,8 +279,9 @@ pub struct ContactsPanel {
     pub contact_username: ContainedText,
     pub contact_button: Interactive<IconButton>,
     pub contact_button_spacing: f32,
-    pub disabled_contact_button: IconButton,
+    pub disabled_button: IconButton,
     pub tree_branch: Interactive<TreeBranch>,
+    pub private_button: Interactive<IconButton>,
     pub section_icon_size: f32,
     pub invite_row: Interactive<ContainedLabel>,
 }
@@ -318,7 +319,7 @@ pub struct Icon {
     pub path: String,
 }
 
-#[derive(Clone, Deserialize, Default)]
+#[derive(Deserialize, Clone, Copy, Default)]
 pub struct IconButton {
     #[serde(flatten)]
     pub container: ContainerStyle,

crates/workspace/src/waiting_room.rs 🔗

@@ -85,9 +85,10 @@ impl WaitingRoom {
                         project_id,
                         app_state.client.clone(),
                         app_state.user_store.clone(),
+                        app_state.project_store.clone(),
                         app_state.languages.clone(),
                         app_state.fs.clone(),
-                        &mut cx,
+                        cx.clone(),
                     )
                     .await;
 

crates/workspace/src/workspace.rs 🔗

@@ -17,19 +17,20 @@ use gpui::{
     color::Color,
     elements::*,
     geometry::{rect::RectF, vector::vec2f, PathBuilder},
-    impl_internal_actions,
+    impl_actions, impl_internal_actions,
     json::{self, ToJson},
     platform::{CursorStyle, WindowOptions},
     AnyModelHandle, AnyViewHandle, AppContext, AsyncAppContext, Border, Entity, ImageData,
-    ModelHandle, MutableAppContext, PathPromptOptions, PromptLevel, RenderContext, Task, View,
-    ViewContext, ViewHandle, WeakViewHandle,
+    ModelContext, ModelHandle, MutableAppContext, PathPromptOptions, PromptLevel, RenderContext,
+    Task, View, ViewContext, ViewHandle, WeakViewHandle,
 };
 use language::LanguageRegistry;
 use log::error;
 pub use pane::*;
 pub use pane_group::*;
 use postage::prelude::Stream;
-use project::{fs, Fs, Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId};
+use project::{fs, Fs, Project, ProjectEntryId, ProjectPath, ProjectStore, Worktree, WorktreeId};
+use serde::Deserialize;
 use settings::Settings;
 use sidebar::{Side, Sidebar, SidebarButtons, ToggleSidebarItem, ToggleSidebarItemFocus};
 use smallvec::SmallVec;
@@ -98,6 +99,12 @@ pub struct OpenPaths {
     pub paths: Vec<PathBuf>,
 }
 
+#[derive(Clone, Deserialize)]
+pub struct ToggleProjectOnline {
+    #[serde(skip_deserializing)]
+    pub project: Option<ModelHandle<Project>>,
+}
+
 #[derive(Clone)]
 pub struct ToggleFollow(pub PeerId);
 
@@ -116,6 +123,7 @@ impl_internal_actions!(
         RemoveFolderFromProject
     ]
 );
+impl_actions!(workspace, [ToggleProjectOnline]);
 
 pub fn init(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
     pane::init(cx);
@@ -160,6 +168,7 @@ pub fn init(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
     cx.add_async_action(Workspace::save_all);
     cx.add_action(Workspace::add_folder_to_project);
     cx.add_action(Workspace::remove_folder_from_project);
+    cx.add_action(Workspace::toggle_project_online);
     cx.add_action(
         |workspace: &mut Workspace, _: &Unfollow, cx: &mut ViewContext<Workspace>| {
             let pane = workspace.active_pane().clone();
@@ -222,6 +231,7 @@ pub struct AppState {
     pub themes: Arc<ThemeRegistry>,
     pub client: Arc<client::Client>,
     pub user_store: ModelHandle<client::UserStore>,
+    pub project_store: ModelHandle<ProjectStore>,
     pub fs: Arc<dyn fs::Fs>,
     pub build_window_options: fn() -> WindowOptions<'static>,
     pub initialize_workspace: fn(&mut Workspace, &Arc<AppState>, &mut ViewContext<Workspace>),
@@ -682,6 +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::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 {
@@ -690,6 +701,7 @@ impl AppState {
             fs,
             languages,
             user_store,
+            project_store,
             initialize_workspace: |_, _, _| {},
             build_window_options: || Default::default(),
         })
@@ -837,10 +849,7 @@ impl Workspace {
             _observe_current_user,
         };
         this.project_remote_id_changed(this.project.read(cx).remote_id(), cx);
-
-        cx.defer(|this, cx| {
-            this.update_window_title(cx);
-        });
+        cx.defer(|this, cx| this.update_window_title(cx));
 
         this
     }
@@ -876,20 +885,6 @@ impl Workspace {
         self.project.read(cx).worktrees(cx)
     }
 
-    pub fn contains_paths(&self, paths: &[PathBuf], cx: &AppContext) -> bool {
-        paths.iter().all(|path| self.contains_path(&path, cx))
-    }
-
-    pub fn contains_path(&self, path: &Path, cx: &AppContext) -> bool {
-        for worktree in self.worktrees(cx) {
-            let worktree = worktree.read(cx).as_local();
-            if worktree.map_or(false, |w| w.contains_abs_path(path)) {
-                return true;
-            }
-        }
-        false
-    }
-
     pub fn worktree_scans_complete(&self, cx: &AppContext) -> impl Future<Output = ()> + 'static {
         let futures = self
             .worktrees(cx)
@@ -1054,6 +1049,17 @@ impl Workspace {
             .update(cx, |project, cx| project.remove_worktree(*worktree_id, cx));
     }
 
+    fn toggle_project_online(&mut self, action: &ToggleProjectOnline, cx: &mut ViewContext<Self>) {
+        let project = action
+            .project
+            .clone()
+            .unwrap_or_else(|| self.project.clone());
+        project.update(cx, |project, cx| {
+            let public = !project.is_online();
+            project.set_online(public, cx);
+        });
+    }
+
     fn project_path_for_path(
         &self,
         abs_path: &Path,
@@ -1668,8 +1674,15 @@ impl Workspace {
     }
 
     fn render_titlebar(&self, theme: &Theme, cx: &mut RenderContext<Self>) -> ElementBox {
+        let project = &self.project.read(cx);
+        let replica_id = project.replica_id();
         let mut worktree_root_names = String::new();
-        self.worktree_root_names(&mut worktree_root_names, cx);
+        for (i, name) in project.worktree_root_names(cx).enumerate() {
+            if i > 0 {
+                worktree_root_names.push_str(", ");
+            }
+            worktree_root_names.push_str(name);
+        }
 
         ConstrainedBox::new(
             Container::new(
@@ -1686,7 +1699,7 @@ impl Workspace {
                                 .with_children(self.render_collaborators(theme, cx))
                                 .with_children(self.render_current_user(
                                     self.user_store.read(cx).current_user().as_ref(),
-                                    self.project.read(cx).replica_id(),
+                                    replica_id,
                                     theme,
                                     cx,
                                 ))
@@ -1714,6 +1727,7 @@ impl Workspace {
 
     fn update_window_title(&mut self, cx: &mut ViewContext<Self>) {
         let mut title = String::new();
+        let project = self.project().read(cx);
         if let Some(path) = self.active_item(cx).and_then(|item| item.project_path(cx)) {
             let filename = path
                 .path
@@ -1721,8 +1735,7 @@ impl Workspace {
                 .map(|s| s.to_string_lossy())
                 .or_else(|| {
                     Some(Cow::Borrowed(
-                        self.project()
-                            .read(cx)
+                        project
                             .worktree_for_id(path.worktree_id, cx)?
                             .read(cx)
                             .root_name(),
@@ -1733,22 +1746,18 @@ impl Workspace {
                 title.push_str(" — ");
             }
         }
-        self.worktree_root_names(&mut title, cx);
+        for (i, name) in project.worktree_root_names(cx).enumerate() {
+            if i > 0 {
+                title.push_str(", ");
+            }
+            title.push_str(name);
+        }
         if title.is_empty() {
             title = "empty project".to_string();
         }
         cx.set_window_title(&title);
     }
 
-    fn worktree_root_names(&self, string: &mut String, cx: &mut MutableAppContext) {
-        for (i, worktree) in self.project.read(cx).visible_worktrees(cx).enumerate() {
-            if i != 0 {
-                string.push_str(", ");
-            }
-            string.push_str(worktree.read(cx).root_name());
-        }
-    }
-
     fn render_collaborators(&self, theme: &Theme, cx: &mut RenderContext<Self>) -> Vec<ElementBox> {
         let mut collaborators = self
             .project
@@ -2365,6 +2374,22 @@ fn open(_: &Open, cx: &mut MutableAppContext) {
 
 pub struct WorkspaceCreated(WeakViewHandle<Workspace>);
 
+pub fn activate_workspace_for_project(
+    cx: &mut MutableAppContext,
+    predicate: impl Fn(&mut Project, &mut ModelContext<Project>) -> bool,
+) -> Option<ViewHandle<Workspace>> {
+    for window_id in cx.window_ids().collect::<Vec<_>>() {
+        if let Some(workspace_handle) = cx.root_view::<Workspace>(window_id) {
+            let project = workspace_handle.read(cx).project.clone();
+            if project.update(cx, &predicate) {
+                cx.activate_window(window_id);
+                return Some(workspace_handle);
+            }
+        }
+    }
+    None
+}
+
 pub fn open_paths(
     abs_paths: &[PathBuf],
     app_state: &Arc<AppState>,
@@ -2376,26 +2401,13 @@ pub fn open_paths(
     log::info!("open paths {:?}", abs_paths);
 
     // Open paths in existing workspace if possible
-    let mut existing = None;
-    for window_id in cx.window_ids().collect::<Vec<_>>() {
-        if let Some(workspace_handle) = cx.root_view::<Workspace>(window_id) {
-            if workspace_handle.update(cx, |workspace, cx| {
-                if workspace.contains_paths(abs_paths, cx.as_ref()) {
-                    cx.activate_window(window_id);
-                    existing = Some(workspace_handle.clone());
-                    true
-                } else {
-                    false
-                }
-            }) {
-                break;
-            }
-        }
-    }
+    let existing =
+        activate_workspace_for_project(cx, |project, cx| project.contains_paths(abs_paths, cx));
 
     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 {
@@ -2405,16 +2417,17 @@ pub fn open_paths(
                     .contains(&false);
 
             cx.add_window((app_state.build_window_options)(), |cx| {
-                let mut workspace = Workspace::new(
-                    Project::local(
-                        app_state.client.clone(),
-                        app_state.user_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(
@@ -2433,6 +2446,14 @@ 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 {
+            project
+                .update(&mut cx, |project, cx| project.restore_state(cx))
+                .await
+                .log_err();
+        }
+
         (workspace, items)
     })
 }
@@ -2463,8 +2484,10 @@ fn open_new(app_state: &Arc<AppState>, cx: &mut MutableAppContext) {
     let (window_id, workspace) = 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,

crates/zed/Cargo.toml 🔗

@@ -59,7 +59,7 @@ chrono = "0.4"
 ctor = "0.1.20"
 dirs = "3.0"
 easy-parallel = "3.1.0"
-env_logger = "0.8"
+env_logger = "0.9"
 futures = "0.3"
 http-auth-basic = "0.1.3"
 ignore = "0.4"
@@ -108,7 +108,7 @@ client = { path = "../client", features = ["test-support"] }
 settings = { path = "../settings", features = ["test-support"] }
 util = { path = "../util", features = ["test-support"] }
 workspace = { path = "../workspace", features = ["test-support"] }
-env_logger = "0.8"
+env_logger = "0.9"
 serde_json = { version = "1.0", features = ["preserve_order"] }
 unindent = "0.1.7"
 

crates/zed/src/main.rs 🔗

@@ -23,7 +23,7 @@ use gpui::{executor::Background, App, AssetSource, AsyncAppContext, Task};
 use isahc::{config::Configurable, AsyncBody, Request};
 use log::LevelFilter;
 use parking_lot::Mutex;
-use project::Fs;
+use project::{Fs, ProjectStore};
 use serde_json::json;
 use settings::{self, KeymapFileContent, Settings, SettingsFileContent};
 use smol::process::Command;
@@ -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);
 
@@ -169,6 +175,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,
@@ -204,11 +211,13 @@ fn main() {
         .detach();
         cx.set_global(settings);
 
+        let project_store = cx.add_model(|_| ProjectStore::new(db));
         let app_state = Arc::new(AppState {
             languages,
             themes,
             client: client.clone(),
             user_store,
+            project_store,
             fs,
             build_window_options,
             initialize_workspace,

crates/zed/src/zed.rs 🔗

@@ -181,7 +181,12 @@ pub fn initialize_workspace(
 
     let project_panel = ProjectPanel::new(workspace.project().clone(), cx);
     let contact_panel = cx.add_view(|cx| {
-        ContactsPanel::new(app_state.user_store.clone(), workspace.weak_handle(), cx)
+        ContactsPanel::new(
+            app_state.user_store.clone(),
+            app_state.project_store.clone(),
+            workspace.weak_handle(),
+            cx,
+        )
     });
 
     workspace.left_sidebar().update(cx, |sidebar, cx| {
@@ -295,8 +300,10 @@ fn open_config_file(
                     let (_, workspace) = 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,

styles/src/styleTree/contactsPanel.ts 🔗

@@ -68,6 +68,12 @@ export default function contactsPanel(theme: Theme) {
       buttonWidth: 8,
       iconWidth: 8,
     },
+    privateButton: {
+      iconWidth: 8,
+      color: iconColor(theme, "primary"),
+      cornerRadius: 5,
+      buttonWidth: 12,
+    },
     rowHeight: 28,
     sectionIconSize: 8,
     headerRow: {
@@ -118,7 +124,7 @@ export default function contactsPanel(theme: Theme) {
         background: backgroundColor(theme, 100, "hovered"),
       },
     },
-    disabledContactButton: {
+    disabledButton: {
       ...contactButton,
       background: backgroundColor(theme, 100),
       color: iconColor(theme, "muted"),