Reconnect button for remote projects (#12669)

Conrad Irwin and Max created

Release Notes:

- N/A

---------

Co-authored-by: Max <max@zed.dev>

Change summary

Cargo.lock                                         |   1 
crates/collab/Cargo.toml                           |   1 
crates/collab/src/tests/dev_server_tests.rs        |   3 
crates/collab/src/tests/editor_tests.rs            |   7 
crates/collab_ui/src/collab_titlebar_item.rs       |  11 +
crates/project/src/project.rs                      |  11 +
crates/project/src/terminals.rs                    |  19 -
crates/project_panel/src/project_panel.rs          |   1 
crates/recent_projects/Cargo.toml                  |   1 
crates/recent_projects/src/dev_servers.rs          |  72 ++++++
crates/recent_projects/src/disconnected_overlay.rs | 155 ++++++++++++++++
crates/recent_projects/src/recent_projects.rs      |  38 +--
crates/workspace/src/modal_layer.rs                |  46 +++-
crates/workspace/src/persistence.rs                |  93 +++++++++
crates/workspace/src/workspace.rs                  | 114 +++--------
15 files changed, 437 insertions(+), 136 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -2363,6 +2363,7 @@ dependencies = [
  "prometheus",
  "prost",
  "rand 0.8.5",
+ "recent_projects",
  "release_channel",
  "reqwest",
  "rpc",

crates/collab/Cargo.toml 🔗

@@ -96,6 +96,7 @@ node_runtime.workspace = true
 notifications = { workspace = true, features = ["test-support"] }
 pretty_assertions.workspace = true
 project = { workspace = true, features = ["test-support"] }
+recent_projects = { workspace = true }
 release_channel.workspace = true
 dev_server_projects.workspace = true
 rpc = { workspace = true, features = ["test-support"] }

crates/collab/src/tests/dev_server_tests.rs 🔗

@@ -68,6 +68,7 @@ async fn test_dev_server(cx: &mut gpui::TestAppContext, cx2: &mut gpui::TestAppC
             assert_eq!(projects.len(), 1);
             assert_eq!(projects[0].path, "/remote");
             workspace::join_dev_server_project(
+                projects[0].id,
                 projects[0].project_id.unwrap(),
                 client.app_state.clone(),
                 None,
@@ -207,6 +208,7 @@ async fn create_dev_server_project(
             assert_eq!(projects.len(), 1);
             assert_eq!(projects[0].path, "/remote");
             workspace::join_dev_server_project(
+                projects[0].id,
                 projects[0].project_id.unwrap(),
                 client_app_state,
                 None,
@@ -491,6 +493,7 @@ async fn test_dev_server_reconnect(
         .update(cx2, |store, cx| {
             let projects = store.dev_server_projects();
             workspace::join_dev_server_project(
+                projects[0].id,
                 projects[0].project_id.unwrap(),
                 client2.app_state.clone(),
                 None,

crates/collab/src/tests/editor_tests.rs 🔗

@@ -30,6 +30,7 @@ use project::{
     project_settings::{InlineBlameSettings, ProjectSettings},
     SERVER_PROGRESS_DEBOUNCE_TIMEOUT,
 };
+use recent_projects::disconnected_overlay::DisconnectedOverlay;
 use rpc::RECEIVE_TIMEOUT;
 use serde_json::json;
 use settings::SettingsStore;
@@ -59,6 +60,7 @@ async fn test_host_disconnect(
         .await;
 
     cx_b.update(editor::init);
+    cx_b.update(recent_projects::init);
 
     client_a
         .fs()
@@ -123,11 +125,10 @@ async fn test_host_disconnect(
     assert!(worktree_a.read_with(cx_a, |tree, _| !tree.has_update_observer()));
 
     // Ensure client B's edited state is reset and that the whole window is blurred.
-
     workspace_b
         .update(cx_b, |workspace, cx| {
-            assert_eq!(cx.focused(), None);
-            assert!(!workspace.is_edited())
+            assert!(workspace.active_modal::<DisconnectedOverlay>(cx).is_some());
+            assert!(!workspace.is_edited());
         })
         .unwrap();
 

crates/collab_ui/src/collab_titlebar_item.rs 🔗

@@ -413,6 +413,17 @@ impl CollabTitlebarItem {
             );
         }
 
+        if self.project.read(cx).is_disconnected() {
+            return Some(
+                Button::new("disconnected", "Disconnected")
+                    .disabled(true)
+                    .color(Color::Disabled)
+                    .style(ButtonStyle::Subtle)
+                    .label_size(LabelSize::Small)
+                    .into_any_element(),
+            );
+        }
+
         let host = self.project.read(cx).host()?;
         let host_user = self.user_store.read(cx).get_cached_user(host.user_id)?;
         let participant_index = self

crates/project/src/project.rs 🔗

@@ -1816,6 +1816,9 @@ impl Project {
     }
 
     pub fn disconnected_from_host(&mut self, cx: &mut ModelContext<Self>) {
+        if self.is_disconnected() {
+            return;
+        }
         self.disconnected_from_host_internal(cx);
         cx.emit(Event::DisconnectedFromHost);
         cx.notify();
@@ -1863,7 +1866,10 @@ impl Project {
             for open_buffer in self.opened_buffers.values_mut() {
                 // Wake up any tasks waiting for peers' edits to this buffer.
                 if let Some(buffer) = open_buffer.upgrade() {
-                    buffer.update(cx, |buffer, _| buffer.give_up_waiting());
+                    buffer.update(cx, |buffer, cx| {
+                        buffer.give_up_waiting();
+                        buffer.set_capability(Capability::ReadOnly, cx)
+                    });
                 }
 
                 if let OpenBuffer::Strong(buffer) = open_buffer {
@@ -2127,6 +2133,9 @@ impl Project {
         let remote_worktree_id = worktree.read(cx).id();
         let path = path.clone();
         let path_string = path.to_string_lossy().to_string();
+        if self.is_disconnected() {
+            return Task::ready(Err(anyhow!(ErrorCode::Disconnected)));
+        }
         cx.spawn(move |this, mut cx| async move {
             let response = rpc
                 .request(proto::OpenBufferByPath {

crates/project/src/terminals.rs 🔗

@@ -337,22 +337,10 @@ fn prepare_ssh_shell(
         "exec $SHELL -l".to_string()
     };
 
-    let (port_forward, local_dev_env) =
-        if env::var("ZED_RPC_URL").as_deref() == Ok("http://localhost:8080/rpc") {
-            (
-                "-R 8080:localhost:8080",
-                "export ZED_RPC_URL=http://localhost:8080/rpc;",
-            )
-        } else {
-            ("", "")
-        };
-
     let commands = if let Some(path) = path {
-        // I've found that `ssh -t dev sh -c 'cd; cd /tmp; pwd'` gives /tmp
-        // but `ssh -t dev sh -c 'cd /tmp; pwd'` gives /root
-        format!("cd {path}; {local_dev_env} {to_run}")
+        format!("cd {path}; {to_run}")
     } else {
-        format!("cd; {local_dev_env} {to_run}")
+        format!("cd; {to_run}")
     };
     let shell_invocation = &format!("sh -c {}", shlex::try_quote(&commands)?);
 
@@ -361,10 +349,9 @@ fn prepare_ssh_shell(
     // be run instead.
     write!(
         &mut ssh_file,
-        "#!/bin/sh\nexec {} \"$@\" {} {} {}",
+        "#!/bin/sh\nexec {} \"$@\" {} {}",
         real_ssh.to_string_lossy(),
         if spawn_task.is_none() { "-t" } else { "" },
-        port_forward,
         shlex::try_quote(shell_invocation)?,
     )?;
 

crates/project_panel/src/project_panel.rs 🔗

@@ -317,6 +317,7 @@ impl ProjectPanel {
                                 )
                                 .detach_and_prompt_err("Failed to open file", cx, move |e, _| {
                                     match e.error_code() {
+                                        ErrorCode::Disconnected => Some("Disconnected from remote project".to_string()),
                                         ErrorCode::UnsharedItem => Some(format!(
                                             "{} is not shared by the host. This could be because it has been marked as `private`",
                                             file_path.display()

crates/recent_projects/Cargo.toml 🔗

@@ -23,6 +23,7 @@ markdown.workspace = true
 menu.workspace = true
 ordered-float.workspace = true
 picker.workspace = true
+project.workspace = true
 dev_server_projects.workspace = true
 rpc.workspace = true
 serde.workspace = true

crates/recent_projects/src/dev_servers.rs 🔗

@@ -35,6 +35,7 @@ use ui_text_field::{FieldLabelLayout, TextField};
 use util::ResultExt;
 use workspace::{notifications::DetachAndPromptErr, AppState, ModalView, Workspace, WORKSPACE_DB};
 
+use crate::open_dev_server_project;
 use crate::OpenRemote;
 
 pub struct DevServerProjects {
@@ -211,7 +212,11 @@ impl DevServerProjects {
                                     this.mode = Mode::Default(None);
                                     if let Some(app_state) = AppState::global(cx).upgrade() {
                                         workspace::join_dev_server_project(
-                                            project_id, app_state, None, cx,
+                                            DevServerProjectId(dev_server_project_id),
+                                            project_id,
+                                            app_state,
+                                            None,
+                                            cx,
                                         )
                                         .detach_and_prompt_err(
                                             "Could not join project",
@@ -558,7 +563,27 @@ impl DevServerProjects {
                             h_flex()
                                 .visible_on_hover("dev-server")
                                 .gap_1()
-                                .child(
+                                .child(if dev_server.ssh_connection_string.is_some() {
+                                    let dev_server = dev_server.clone();
+                                    IconButton::new("reconnect-dev-server", IconName::ArrowCircle)
+                                        .on_click(cx.listener(move |this, _, cx| {
+                                            let Some(workspace) = this.workspace.upgrade() else {
+                                                return;
+                                            };
+
+                                            reconnect_to_dev_server(
+                                                workspace,
+                                                dev_server.clone(),
+                                                cx,
+                                            )
+                                            .detach_and_prompt_err(
+                                                "Failed to reconnect",
+                                                cx,
+                                                |_, _| None,
+                                            );
+                                        }))
+                                        .tooltip(|cx| Tooltip::text("Reconnect", cx))
+                                } else {
                                     IconButton::new("edit-dev-server", IconName::Pencil)
                                         .on_click(cx.listener(move |this, _, cx| {
                                             this.mode = Mode::CreateDevServer(CreateDevServer {
@@ -577,8 +602,8 @@ impl DevServerProjects {
                                                 },
                                             )
                                         }))
-                                        .tooltip(|cx| Tooltip::text("Edit dev server", cx)),
-                                )
+                                        .tooltip(|cx| Tooltip::text("Edit dev server", cx))
+                                })
                                 .child({
                                     let dev_server_id = dev_server.id;
                                     IconButton::new("remove-dev-server", IconName::Trash)
@@ -681,7 +706,7 @@ impl DevServerProjects {
             .on_click(cx.listener(move |_, _, cx| {
                 if let Some(project_id) = project_id {
                     if let Some(app_state) = AppState::global(cx).upgrade() {
-                        workspace::join_dev_server_project(project_id, app_state, None, cx)
+                        workspace::join_dev_server_project(dev_server_project_id, project_id, app_state, None, cx)
                             .detach_and_prompt_err("Could not join project", cx, |_, _| None)
                     }
                 } else {
@@ -1044,6 +1069,43 @@ impl Render for DevServerProjects {
     }
 }
 
+pub fn reconnect_to_dev_server_project(
+    workspace: View<Workspace>,
+    dev_server: DevServer,
+    dev_server_project_id: DevServerProjectId,
+    replace_current_window: bool,
+    cx: &mut WindowContext,
+) -> Task<anyhow::Result<()>> {
+    let store = dev_server_projects::Store::global(cx);
+    let reconnect = reconnect_to_dev_server(workspace.clone(), dev_server, cx);
+    cx.spawn(|mut cx| async move {
+        reconnect.await?;
+
+        cx.background_executor()
+            .timer(Duration::from_millis(1000))
+            .await;
+
+        if let Some(project_id) = store.update(&mut cx, |store, _| {
+            store
+                .dev_server_project(dev_server_project_id)
+                .and_then(|p| p.project_id)
+        })? {
+            workspace
+                .update(&mut cx, move |_, cx| {
+                    open_dev_server_project(
+                        replace_current_window,
+                        dev_server_project_id,
+                        project_id,
+                        cx,
+                    )
+                })?
+                .await?;
+        }
+
+        Ok(())
+    })
+}
+
 pub fn reconnect_to_dev_server(
     workspace: View<Workspace>,
     dev_server: DevServer,

crates/recent_projects/src/disconnected_overlay.rs 🔗

@@ -0,0 +1,155 @@
+use dev_server_projects::DevServer;
+use gpui::{ClickEvent, DismissEvent, EventEmitter, FocusHandle, FocusableView, Render, WeakView};
+use ui::{
+    div, h_flex, rems, Button, ButtonCommon, ButtonStyle, Clickable, ElevationIndex, FluentBuilder,
+    Headline, HeadlineSize, IconName, IconPosition, InteractiveElement, IntoElement, Label, Modal,
+    ModalFooter, ModalHeader, ParentElement, Section, Styled, StyledExt, ViewContext,
+};
+use workspace::{notifications::DetachAndPromptErr, ModalView, Workspace};
+
+use crate::{
+    dev_servers::reconnect_to_dev_server_project, open_dev_server_project, DevServerProjects,
+};
+
+pub struct DisconnectedOverlay {
+    workspace: WeakView<Workspace>,
+    dev_server: Option<DevServer>,
+    focus_handle: FocusHandle,
+}
+
+impl EventEmitter<DismissEvent> for DisconnectedOverlay {}
+impl FocusableView for DisconnectedOverlay {
+    fn focus_handle(&self, _cx: &gpui::AppContext) -> gpui::FocusHandle {
+        self.focus_handle.clone()
+    }
+}
+impl ModalView for DisconnectedOverlay {
+    fn fade_out_background(&self) -> bool {
+        true
+    }
+}
+
+impl DisconnectedOverlay {
+    pub fn register(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) {
+        cx.subscribe(workspace.project(), |workspace, project, event, cx| {
+            if !matches!(event, project::Event::DisconnectedFromHost) {
+                return;
+            }
+            let handle = cx.view().downgrade();
+            let dev_server = project
+                .read(cx)
+                .dev_server_project_id()
+                .and_then(|id| {
+                    dev_server_projects::Store::global(cx)
+                        .read(cx)
+                        .dev_server_for_project(id)
+                })
+                .cloned();
+            workspace.toggle_modal(cx, |cx| DisconnectedOverlay {
+                workspace: handle,
+                dev_server,
+                focus_handle: cx.focus_handle(),
+            });
+        })
+        .detach();
+    }
+
+    fn handle_reconnect(&mut self, _: &ClickEvent, cx: &mut ViewContext<Self>) {
+        cx.emit(DismissEvent);
+        let Some(workspace) = self.workspace.upgrade() else {
+            return;
+        };
+        let Some(dev_server) = self.dev_server.clone() else {
+            return;
+        };
+        let Some(dev_server_project_id) = workspace
+            .read(cx)
+            .project()
+            .read(cx)
+            .dev_server_project_id()
+        else {
+            return;
+        };
+
+        if let Some(project_id) = dev_server_projects::Store::global(cx)
+            .read(cx)
+            .dev_server_project(dev_server_project_id)
+            .and_then(|project| project.project_id)
+        {
+            return workspace.update(cx, move |_, cx| {
+                open_dev_server_project(true, dev_server_project_id, project_id, cx)
+                    .detach_and_prompt_err("Failed to reconnect", cx, |_, _| None)
+            });
+        }
+
+        if dev_server.ssh_connection_string.is_some() {
+            let task = workspace.update(cx, |_, cx| {
+                reconnect_to_dev_server_project(
+                    cx.view().clone(),
+                    dev_server,
+                    dev_server_project_id,
+                    true,
+                    cx,
+                )
+            });
+
+            task.detach_and_prompt_err("Failed to reconnect", cx, |_, _| None);
+        } else {
+            return workspace.update(cx, |workspace, cx| {
+                let handle = cx.view().downgrade();
+                workspace.toggle_modal(cx, |cx| DevServerProjects::new(cx, handle))
+            });
+        }
+    }
+
+    fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
+        cx.emit(DismissEvent)
+    }
+}
+
+impl Render for DisconnectedOverlay {
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
+        div()
+            .track_focus(&self.focus_handle)
+            .elevation_3(cx)
+            .on_action(cx.listener(Self::cancel))
+            .occlude()
+            .w(rems(24.))
+            .max_h(rems(40.))
+            .child(
+                Modal::new("disconnected", None)
+                    .header(
+                        ModalHeader::new()
+                            .show_dismiss_button(true)
+                            .child(Headline::new("Disconnected").size(HeadlineSize::Small)),
+                    )
+                    .section(Section::new().child(Label::new(
+                        "Your connection to the remote project has been lost.",
+                    )))
+                    .footer(
+                        ModalFooter::new().end_slot(
+                            h_flex()
+                                .gap_2()
+                                .child(
+                                    Button::new("close-window", "Close Window")
+                                        .style(ButtonStyle::Filled)
+                                        .layer(ElevationIndex::ModalSurface)
+                                        .on_click(cx.listener(move |_, _, cx| {
+                                            cx.remove_window();
+                                        })),
+                                )
+                                .when_some(self.dev_server.clone(), |el, _| {
+                                    el.child(
+                                        Button::new("reconnect", "Reconnect")
+                                            .style(ButtonStyle::Filled)
+                                            .layer(ElevationIndex::ModalSurface)
+                                            .icon(IconName::ArrowCircle)
+                                            .icon_position(IconPosition::Start)
+                                            .on_click(cx.listener(Self::handle_reconnect)),
+                                    )
+                                }),
+                        ),
+                    ),
+            )
+    }
+}

crates/recent_projects/src/recent_projects.rs 🔗

@@ -1,8 +1,10 @@
 mod dev_servers;
+pub mod disconnected_overlay;
 
-use client::ProjectId;
-use dev_servers::reconnect_to_dev_server;
+use client::{DevServerProjectId, ProjectId};
+use dev_servers::reconnect_to_dev_server_project;
 pub use dev_servers::DevServerProjects;
+use disconnected_overlay::DisconnectedOverlay;
 use feature_flags::FeatureFlagAppExt;
 use fuzzy::{StringMatch, StringMatchCandidate};
 use gpui::{
@@ -19,7 +21,6 @@ use serde::Deserialize;
 use std::{
     path::{Path, PathBuf},
     sync::Arc,
-    time::Duration,
 };
 use ui::{
     prelude::*, tooltip_container, ButtonLike, IconWithIndicator, Indicator, KeyBinding, ListItem,
@@ -46,6 +47,7 @@ gpui::actions!(projects, [OpenRemote]);
 pub fn init(cx: &mut AppContext) {
     cx.observe_new_views(RecentProjects::register).detach();
     cx.observe_new_views(DevServerProjects::register).detach();
+    cx.observe_new_views(DisconnectedOverlay::register).detach();
 }
 
 pub struct RecentProjects {
@@ -314,23 +316,7 @@ impl PickerDelegate for RecentProjectsDelegate {
                                 else {
                                     let server = store.read(cx).dev_server_for_project(dev_server_project.id);
                                     if server.is_some_and(|server| server.ssh_connection_string.is_some()) {
-                                        let reconnect =  reconnect_to_dev_server(cx.view().clone(), server.unwrap().clone(), cx);
-                                        let id = dev_server_project.id;
-                                        return cx.spawn(|workspace, mut cx| async move {
-                                            reconnect.await?;
-
-                                            cx.background_executor().timer(Duration::from_millis(1000)).await;
-
-                                            if let Some(project_id) = store.update(&mut cx, |store, _| {
-                                                store.dev_server_project(id)
-                                                    .and_then(|p| p.project_id)
-                                            })? {
-                                                    workspace.update(&mut cx, move |_, cx| {
-                                                    open_dev_server_project(replace_current_window, project_id, cx)
-                                                    })?.await?;
-                                                }
-                                            Ok(())
-                                        })
+                                        return reconnect_to_dev_server_project(cx.view().clone(), server.unwrap().clone(), dev_server_project.id, replace_current_window, cx);
                                     } else {
                                         let dev_server_name = dev_server_project.dev_server_name.clone();
                                         return cx.spawn(|workspace, mut cx| async move {
@@ -354,7 +340,7 @@ impl PickerDelegate for RecentProjectsDelegate {
                                         })
                                     }
                                 };
-                                open_dev_server_project(replace_current_window, project_id, cx)
+                                open_dev_server_project(replace_current_window, dev_server_project.id, project_id, cx)
                         }
                     }
                 }
@@ -544,6 +530,7 @@ impl PickerDelegate for RecentProjectsDelegate {
 
 fn open_dev_server_project(
     replace_current_window: bool,
+    dev_server_project_id: DevServerProjectId,
     project_id: ProjectId,
     cx: &mut ViewContext<Workspace>,
 ) -> Task<anyhow::Result<()>> {
@@ -565,6 +552,7 @@ fn open_dev_server_project(
                     workspace
                         .update(&mut cx, |_workspace, cx| {
                             workspace::join_dev_server_project(
+                                dev_server_project_id,
                                 project_id,
                                 app_state,
                                 Some(handle),
@@ -576,7 +564,13 @@ fn open_dev_server_project(
                 Ok(())
             })
         } else {
-            let task = workspace::join_dev_server_project(project_id, app_state, None, cx);
+            let task = workspace::join_dev_server_project(
+                dev_server_project_id,
+                project_id,
+                app_state,
+                None,
+                cx,
+            );
             cx.spawn(|_, _| async move {
                 task.await?;
                 Ok(())

crates/workspace/src/modal_layer.rs 🔗

@@ -2,6 +2,7 @@ use gpui::{
     div, prelude::*, px, AnyView, DismissEvent, FocusHandle, ManagedView, Render, Subscription,
     View, ViewContext, WindowContext,
 };
+use theme::ActiveTheme as _;
 use ui::{h_flex, v_flex};
 
 pub enum DismissDecision {
@@ -13,11 +14,16 @@ pub trait ModalView: ManagedView {
     fn on_before_dismiss(&mut self, _: &mut ViewContext<Self>) -> DismissDecision {
         DismissDecision::Dismiss(true)
     }
+
+    fn fade_out_background(&self) -> bool {
+        false
+    }
 }
 
 trait ModalViewHandle {
     fn on_before_dismiss(&mut self, cx: &mut WindowContext) -> DismissDecision;
     fn view(&self) -> AnyView;
+    fn fade_out_background(&self, cx: &WindowContext) -> bool;
 }
 
 impl<V: ModalView> ModalViewHandle for View<V> {
@@ -28,6 +34,10 @@ impl<V: ModalView> ModalViewHandle for View<V> {
     fn view(&self) -> AnyView {
         self.clone().into()
     }
+
+    fn fade_out_background(&self, cx: &WindowContext) -> bool {
+        self.read(cx).fade_out_background()
+    }
 }
 
 pub struct ActiveModal {
@@ -134,20 +144,34 @@ impl ModalLayer {
 }
 
 impl Render for ModalLayer {
-    fn render(&mut self, _: &mut ViewContext<Self>) -> impl IntoElement {
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
         let Some(active_modal) = &self.active_modal else {
             return div();
         };
 
-        div().absolute().size_full().top_0().left_0().child(
-            v_flex()
-                .h(px(0.0))
-                .top_20()
-                .flex()
-                .flex_col()
-                .items_center()
-                .track_focus(&active_modal.focus_handle)
-                .child(h_flex().occlude().child(active_modal.modal.view())),
-        )
+        div()
+            .absolute()
+            .size_full()
+            .top_0()
+            .left_0()
+            .when(active_modal.modal.fade_out_background(cx), |el| {
+                let mut background = cx.theme().colors().elevated_surface_background;
+                background.fade_out(0.2);
+                el.bg(background)
+                    .occlude()
+                    .on_mouse_down_out(cx.listener(|this, _, cx| {
+                        this.hide_modal(cx);
+                    }))
+            })
+            .child(
+                v_flex()
+                    .h(px(0.0))
+                    .top_20()
+                    .flex()
+                    .flex_col()
+                    .items_center()
+                    .track_focus(&active_modal.focus_handle)
+                    .child(h_flex().occlude().child(active_modal.modal.view())),
+            )
     }
 }

crates/workspace/src/persistence.rs 🔗

@@ -468,6 +468,99 @@ impl WorkspaceDb {
         })
     }
 
+    pub(crate) fn workspace_for_dev_server_project(
+        &self,
+        dev_server_project_id: DevServerProjectId,
+    ) -> Option<SerializedWorkspace> {
+        // Note that we re-assign the workspace_id here in case it's empty
+        // and we've grabbed the most recent workspace
+        let (
+            workspace_id,
+            local_paths,
+            local_paths_order,
+            dev_server_project_id,
+            window_bounds,
+            display,
+            centered_layout,
+            docks,
+        ): (
+            WorkspaceId,
+            Option<LocalPaths>,
+            Option<LocalPathsOrder>,
+            Option<u64>,
+            Option<SerializedWindowBounds>,
+            Option<Uuid>,
+            Option<bool>,
+            DockStructure,
+        ) = self
+            .select_row_bound(sql! {
+                SELECT
+                    workspace_id,
+                    local_paths,
+                    local_paths_order,
+                    dev_server_project_id,
+                    window_state,
+                    window_x,
+                    window_y,
+                    window_width,
+                    window_height,
+                    display,
+                    centered_layout,
+                    left_dock_visible,
+                    left_dock_active_panel,
+                    left_dock_zoom,
+                    right_dock_visible,
+                    right_dock_active_panel,
+                    right_dock_zoom,
+                    bottom_dock_visible,
+                    bottom_dock_active_panel,
+                    bottom_dock_zoom
+                FROM workspaces
+                WHERE dev_server_project_id = ?
+            })
+            .and_then(|mut prepared_statement| (prepared_statement)(dev_server_project_id.0))
+            .context("No workspaces found")
+            .warn_on_err()
+            .flatten()?;
+
+        let location = if let Some(dev_server_project_id) = dev_server_project_id {
+            let dev_server_project: SerializedDevServerProject = self
+                .select_row_bound(sql! {
+                    SELECT id, path, dev_server_name
+                    FROM dev_server_projects
+                    WHERE id = ?
+                })
+                .and_then(|mut prepared_statement| (prepared_statement)(dev_server_project_id))
+                .context("No remote project found")
+                .warn_on_err()
+                .flatten()?;
+            SerializedWorkspaceLocation::DevServer(dev_server_project)
+        } else if let Some(local_paths) = local_paths {
+            match local_paths_order {
+                Some(order) => SerializedWorkspaceLocation::Local(local_paths, order),
+                None => {
+                    let order = LocalPathsOrder::default_for_paths(&local_paths);
+                    SerializedWorkspaceLocation::Local(local_paths, order)
+                }
+            }
+        } else {
+            return None;
+        };
+
+        Some(SerializedWorkspace {
+            id: workspace_id,
+            location,
+            center_group: self
+                .get_center_pane_group(workspace_id)
+                .context("Getting center group")
+                .log_err()?,
+            window_bounds,
+            centered_layout: centered_layout.unwrap_or(false),
+            display,
+            docks,
+        })
+    }
+
     /// Saves a workspace using the worktree roots. Will garbage collect any workspaces
     /// that used this workspace previously
     pub(crate) async fn save_workspace(&self, workspace: SerializedWorkspace) {

crates/workspace/src/workspace.rs 🔗

@@ -16,7 +16,7 @@ use anyhow::{anyhow, Context as _, Result};
 use call::{call_settings::CallSettings, ActiveCall};
 use client::{
     proto::{self, ErrorCode, PeerId},
-    ChannelId, Client, ErrorExt, ProjectId, Status, TypedEnvelope, UserStore,
+    ChannelId, Client, DevServerProjectId, ErrorExt, ProjectId, Status, TypedEnvelope, UserStore,
 };
 use collections::{hash_map, HashMap, HashSet};
 use derive_more::{Deref, DerefMut};
@@ -29,10 +29,9 @@ use futures::{
 use gpui::{
     actions, canvas, impl_actions, point, relative, size, Action, AnyElement, AnyView, AnyWeakView,
     AppContext, AsyncAppContext, AsyncWindowContext, Bounds, DevicePixels, DragMoveEvent,
-    ElementId, Entity as _, EntityId, EventEmitter, FocusHandle, FocusableView, Global,
-    GlobalElementId, KeyContext, Keystroke, LayoutId, ManagedView, Model, ModelContext,
-    PathPromptOptions, Point, PromptLevel, Render, Size, Subscription, Task, View, WeakView,
-    WindowBounds, WindowHandle, WindowOptions,
+    Entity as _, EntityId, EventEmitter, FocusHandle, FocusableView, Global, KeyContext, Keystroke,
+    ManagedView, Model, ModelContext, PathPromptOptions, Point, PromptLevel, Render, Size,
+    Subscription, Task, View, WeakView, WindowBounds, WindowHandle, WindowOptions,
 };
 use item::{
     FollowableItem, FollowableItemHandle, Item, ItemHandle, ItemSettings, PreviewTabsSettings,
@@ -80,8 +79,8 @@ use theme::{ActiveTheme, SystemAppearance, ThemeSettings};
 pub use toolbar::{Toolbar, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView};
 pub use ui;
 use ui::{
-    div, h_flex, Context as _, Div, Element, FluentBuilder, InteractiveElement as _, IntoElement,
-    Label, ParentElement as _, Pixels, SharedString, Styled as _, ViewContext, VisualContext as _,
+    div, h_flex, Context as _, Div, FluentBuilder, InteractiveElement as _, IntoElement,
+    ParentElement as _, Pixels, SharedString, Styled as _, ViewContext, VisualContext as _,
     WindowContext,
 };
 use util::{maybe, ResultExt};
@@ -600,6 +599,8 @@ pub struct Workspace {
     centered_layout: bool,
     bounds_save_task_queued: Option<Task<()>>,
     on_prompt_for_new_path: Option<PromptForNewPath>,
+    render_disconnected_overlay:
+        Option<Box<dyn Fn(&mut Self, &mut ViewContext<Self>) -> AnyElement>>,
 }
 
 impl EventEmitter<Event> for Workspace {}
@@ -650,7 +651,6 @@ impl Workspace {
                     for pane in panes_to_unfollow {
                         this.unfollow(&pane, cx);
                     }
-                    cx.disable_focus();
                 }
 
                 project::Event::Closed => {
@@ -879,10 +879,11 @@ impl Workspace {
             centered_layout: false,
             bounds_save_task_queued: None,
             on_prompt_for_new_path: None,
+            render_disconnected_overlay: None,
         }
     }
 
-    fn new_local(
+    pub fn new_local(
         abs_paths: Vec<PathBuf>,
         app_state: Arc<AppState>,
         requesting_window: Option<WindowHandle<Workspace>>,
@@ -1255,6 +1256,13 @@ impl Workspace {
         self.on_prompt_for_new_path = Some(prompt)
     }
 
+    pub fn set_render_disconnected_overlay(
+        &mut self,
+        render: impl Fn(&mut Self, &mut ViewContext<Self>) -> AnyElement + 'static,
+    ) {
+        self.render_disconnected_overlay = Some(Box::new(render))
+    }
+
     pub fn prompt_for_new_path(
         &mut self,
         cx: &mut ViewContext<Self>,
@@ -4285,7 +4293,13 @@ impl Render for Workspace {
             )
             .child(self.status_bar.clone())
             .children(if self.project.read(cx).is_disconnected() {
-                Some(DisconnectedOverlay)
+                if let Some(render) = self.render_disconnected_overlay.take() {
+                    let result = render(self, cx);
+                    self.render_disconnected_overlay = Some(render);
+                    Some(result)
+                } else {
+                    None
+                }
             } else {
                 None
             })
@@ -4935,6 +4949,7 @@ pub fn join_hosted_project(
 }
 
 pub fn join_dev_server_project(
+    dev_server_project_id: DevServerProjectId,
     project_id: ProjectId,
     app_state: Arc<AppState>,
     window_to_replace: Option<WindowHandle<Workspace>>,
@@ -4969,10 +4984,19 @@ pub fn join_dev_server_project(
             )
             .await?;
 
+            let serialized_workspace: Option<SerializedWorkspace> =
+                persistence::DB.workspace_for_dev_server_project(dev_server_project_id);
+
+            let workspace_id = if let Some(serialized_workspace) = serialized_workspace {
+                serialized_workspace.id
+            } else {
+                persistence::DB.next_id().await?
+            };
+
             if let Some(window_to_replace) = window_to_replace {
                 cx.update_window(window_to_replace.into(), |_, cx| {
                     cx.replace_root_view(|cx| {
-                        Workspace::new(Default::default(), project, app_state.clone(), cx)
+                        Workspace::new(Some(workspace_id), project, app_state.clone(), cx)
                     });
                 })?;
                 window_to_replace
@@ -4984,7 +5008,7 @@ pub fn join_dev_server_project(
                         window_bounds_override.map(|bounds| WindowBounds::Windowed(bounds));
                     cx.open_window(options, |cx| {
                         cx.new_view(|cx| {
-                            Workspace::new(Default::default(), project, app_state.clone(), cx)
+                            Workspace::new(Some(workspace_id), project, app_state.clone(), cx)
                         })
                     })
                 })?
@@ -5150,72 +5174,6 @@ fn parse_pixel_size_env_var(value: &str) -> Option<Size<DevicePixels>> {
     Some(size((width as i32).into(), (height as i32).into()))
 }
 
-struct DisconnectedOverlay;
-
-impl Element for DisconnectedOverlay {
-    type RequestLayoutState = AnyElement;
-    type PrepaintState = ();
-
-    fn id(&self) -> Option<ElementId> {
-        None
-    }
-
-    fn request_layout(
-        &mut self,
-        _id: Option<&GlobalElementId>,
-        cx: &mut WindowContext,
-    ) -> (LayoutId, Self::RequestLayoutState) {
-        let mut background = cx.theme().colors().elevated_surface_background;
-        background.fade_out(0.2);
-        let mut overlay = div()
-            .bg(background)
-            .absolute()
-            .left_0()
-            .top(ui::TitleBar::height(cx))
-            .size_full()
-            .flex()
-            .items_center()
-            .justify_center()
-            .capture_any_mouse_down(|_, cx| cx.stop_propagation())
-            .capture_any_mouse_up(|_, cx| cx.stop_propagation())
-            .child(Label::new(
-                "Your connection to the remote project has been lost.",
-            ))
-            .into_any();
-        (overlay.request_layout(cx), overlay)
-    }
-
-    fn prepaint(
-        &mut self,
-        _id: Option<&GlobalElementId>,
-        bounds: Bounds<Pixels>,
-        overlay: &mut Self::RequestLayoutState,
-        cx: &mut WindowContext,
-    ) {
-        cx.insert_hitbox(bounds, true);
-        overlay.prepaint(cx);
-    }
-
-    fn paint(
-        &mut self,
-        _id: Option<&GlobalElementId>,
-        _: Bounds<Pixels>,
-        overlay: &mut Self::RequestLayoutState,
-        _: &mut Self::PrepaintState,
-        cx: &mut WindowContext,
-    ) {
-        overlay.paint(cx)
-    }
-}
-
-impl IntoElement for DisconnectedOverlay {
-    type Element = Self;
-
-    fn into_element(self) -> Self::Element {
-        self
-    }
-}
-
 #[cfg(test)]
 mod tests {
     use std::{cell::RefCell, rc::Rc};