Render overlay after remote project becomes read-only

Antonio Scandurra and Nathan Sobo created

Co-Authored-By: Nathan Sobo <nathan@zed.dev>

Change summary

crates/client/src/client.rs         |  6 +++
crates/project/src/project.rs       | 49 ++++++++++++++++++++----------
crates/theme/src/theme.rs           |  1 
crates/workspace/src/workspace.rs   | 19 ++++++++++++
crates/zed/assets/themes/black.toml |  5 +++
crates/zed/assets/themes/dark.toml  |  5 +++
crates/zed/assets/themes/light.toml |  5 +++
7 files changed, 73 insertions(+), 17 deletions(-)

Detailed changes

crates/client/src/client.rs 🔗

@@ -127,6 +127,12 @@ pub enum Status {
     ReconnectionError { next_reconnection: Instant },
 }
 
+impl Status {
+    pub fn is_connected(&self) -> bool {
+        matches!(self, Self::Connected { .. })
+    }
+}
+
 struct ClientState {
     credentials: Option<Credentials>,
     status: (watch::Sender<Status>, watch::Receiver<Status>),

crates/project/src/project.rs 🔗

@@ -92,6 +92,7 @@ enum ProjectClientState {
         sharing_has_stopped: bool,
         remote_id: u64,
         replica_id: ReplicaId,
+        _detect_unshare_task: Task<Option<()>>,
     },
 }
 
@@ -244,7 +245,7 @@ impl Project {
                         let mut status = rpc.status();
                         while let Some(status) = status.next().await {
                             if let Some(this) = this.upgrade(&cx) {
-                                let remote_id = if let client::Status::Connected { .. } = status {
+                                let remote_id = if status.is_connected() {
                                     let response = rpc.request(proto::RegisterProject {}).await?;
                                     Some(response.project_id)
                                 } else {
@@ -333,7 +334,7 @@ impl Project {
         }
 
         let (opened_buffer_tx, opened_buffer_rx) = watch::channel();
-        let this = cx.add_model(|cx| {
+        let this = cx.add_model(|cx: &mut ModelContext<Self>| {
             let mut this = Self {
                 worktrees: Vec::new(),
                 loading_buffers: Default::default(),
@@ -346,11 +347,26 @@ impl Project {
                 user_store: user_store.clone(),
                 fs,
                 subscriptions: vec![client.add_model_for_remote_entity(remote_id, cx)],
-                client,
+                client: client.clone(),
                 client_state: ProjectClientState::Remote {
                     sharing_has_stopped: false,
                     remote_id,
                     replica_id,
+                    _detect_unshare_task: cx.spawn_weak(move |this, mut cx| {
+                        async move {
+                            let mut status = client.status();
+                            let is_connected =
+                                status.next().await.map_or(false, |s| s.is_connected());
+                            // Even if we're initially connected, any future change of the status means we momentarily disconnected.
+                            if !is_connected || status.next().await.is_some() {
+                                if let Some(this) = this.upgrade(&cx) {
+                                    this.update(&mut cx, |this, cx| this.project_unshared(cx))
+                                }
+                            }
+                            Ok(())
+                        }
+                        .log_err()
+                    }),
                 },
                 language_servers_with_diagnostics_running: 0,
                 language_servers: Default::default(),
@@ -666,6 +682,18 @@ impl Project {
         })
     }
 
+    fn project_unshared(&mut self, cx: &mut ModelContext<Self>) {
+        if let ProjectClientState::Remote {
+            sharing_has_stopped,
+            ..
+        } = &mut self.client_state
+        {
+            *sharing_has_stopped = true;
+            self.collaborators.clear();
+            cx.notify();
+        }
+    }
+
     pub fn is_read_only(&self) -> bool {
         match &self.client_state {
             ProjectClientState::Local { .. } => false,
@@ -2628,20 +2656,7 @@ impl Project {
         _: Arc<Client>,
         mut cx: AsyncAppContext,
     ) -> Result<()> {
-        this.update(&mut cx, |this, cx| {
-            if let ProjectClientState::Remote {
-                sharing_has_stopped,
-                ..
-            } = &mut this.client_state
-            {
-                *sharing_has_stopped = true;
-                this.collaborators.clear();
-                cx.notify();
-            } else {
-                unreachable!()
-            }
-        });
-
+        this.update(&mut cx, |this, cx| this.project_unshared(cx));
         Ok(())
     }
 

crates/theme/src/theme.rs 🔗

@@ -39,6 +39,7 @@ pub struct Workspace {
     pub right_sidebar: Sidebar,
     pub status_bar: StatusBar,
     pub toolbar: Toolbar,
+    pub disconnected_overlay: ContainedText,
 }
 
 #[derive(Clone, Deserialize, Default)]

crates/workspace/src/workspace.rs 🔗

@@ -1297,6 +1297,24 @@ impl Workspace {
             None
         }
     }
+
+    fn render_disconnected_overlay(&self, cx: &AppContext) -> Option<ElementBox> {
+        if self.project.read(cx).is_read_only() {
+            let theme = &self.settings.borrow().theme;
+            Some(
+                Label::new(
+                    "Your connection to the remote project has been lost.".to_string(),
+                    theme.workspace.disconnected_overlay.text.clone(),
+                )
+                .aligned()
+                .contained()
+                .with_style(theme.workspace.disconnected_overlay.container)
+                .boxed(),
+            )
+        } else {
+            None
+        }
+    }
 }
 
 impl Entity for Workspace {
@@ -1339,6 +1357,7 @@ impl View for Workspace {
                         content.boxed()
                     })
                     .with_children(self.modal.as_ref().map(|m| ChildView::new(m).boxed()))
+                    .with_children(self.render_disconnected_overlay(cx))
                     .flexible(1.0, true)
                     .boxed(),
             )

crates/zed/assets/themes/black.toml 🔗

@@ -60,3 +60,8 @@ emphasis = "#4ec9b0"
 link_uri = { color = "#6a9955", underline = true }
 link_text = { color = "#cb8f77", italic = true }
 list_marker = "#4e94ce"
+
+[workspace.disconnected_overlay]
+extends = "$text.base"
+color = "#ffffff"
+background = "#000000aa"

crates/zed/assets/themes/dark.toml 🔗

@@ -60,3 +60,8 @@ emphasis = "#4ec9b0"
 link_uri = { color = "#6a9955", underline = true }
 link_text = { color = "#cb8f77", italic = true }
 list_marker = "#4e94ce"
+
+[workspace.disconnected_overlay]
+extends = "$text.base"
+color = "#ffffff"
+background = "#000000aa"

crates/zed/assets/themes/light.toml 🔗

@@ -60,3 +60,8 @@ emphasis = "#267f29"
 link_uri = { color = "#6a9955", underline = true }
 link_text = { color = "#a82121", italic = true }
 list_marker = "#4e94ce"
+
+[workspace.disconnected_overlay]
+extends = "$text.base"
+color = "#ffffff"
+background = "#000000cc"