remoting: Edit dev server (#11344)

Bennet Bo Fenner created

This PR allows configuring existing dev server, right now you can:
- Change the dev servers name
- Generate a new token (and invalidate the old one)

<img width="563" alt="image"
src="https://github.com/zed-industries/zed/assets/53836821/9bc95042-c969-4293-90fd-0848d021b664">


Release Notes:

- N/A

Change summary

crates/collab/src/db/queries/dev_servers.rs           |  66 +
crates/collab/src/rpc.rs                              |  92 ++
crates/collab/src/tests/dev_server_tests.rs           | 133 +++
crates/dev_server_projects/src/dev_server_projects.rs |  33 
crates/recent_projects/src/dev_servers.rs             | 557 ++++++++----
crates/rpc/proto/zed.proto                            |  20 
crates/rpc/src/proto.rs                               |   5 
7 files changed, 697 insertions(+), 209 deletions(-)

Detailed changes

crates/collab/src/db/queries/dev_servers.rs πŸ”—

@@ -77,10 +77,14 @@ impl Database {
         user_id: UserId,
     ) -> crate::Result<(dev_server::Model, proto::DevServerProjectsUpdate)> {
         self.transaction(|tx| async move {
+            if name.trim().is_empty() {
+                return Err(anyhow::anyhow!(proto::ErrorCode::Forbidden))?;
+            }
+
             let dev_server = dev_server::Entity::insert(dev_server::ActiveModel {
                 id: ActiveValue::NotSet,
                 hashed_token: ActiveValue::Set(hashed_access_token.to_string()),
-                name: ActiveValue::Set(name.to_string()),
+                name: ActiveValue::Set(name.trim().to_string()),
                 user_id: ActiveValue::Set(user_id),
             })
             .exec_with_returning(&*tx)
@@ -95,6 +99,66 @@ impl Database {
         .await
     }
 
+    pub async fn update_dev_server_token(
+        &self,
+        id: DevServerId,
+        hashed_token: &str,
+        user_id: UserId,
+    ) -> crate::Result<proto::DevServerProjectsUpdate> {
+        self.transaction(|tx| async move {
+            let Some(dev_server) = dev_server::Entity::find_by_id(id).one(&*tx).await? else {
+                return Err(anyhow::anyhow!("no dev server with id {}", id))?;
+            };
+            if dev_server.user_id != user_id {
+                return Err(anyhow::anyhow!(proto::ErrorCode::Forbidden))?;
+            }
+
+            dev_server::Entity::update(dev_server::ActiveModel {
+                hashed_token: ActiveValue::Set(hashed_token.to_string()),
+                ..dev_server.clone().into_active_model()
+            })
+            .exec(&*tx)
+            .await?;
+
+            let dev_server_projects = self
+                .dev_server_projects_update_internal(user_id, &tx)
+                .await?;
+
+            Ok(dev_server_projects)
+        })
+        .await
+    }
+
+    pub async fn rename_dev_server(
+        &self,
+        id: DevServerId,
+        name: &str,
+        user_id: UserId,
+    ) -> crate::Result<proto::DevServerProjectsUpdate> {
+        self.transaction(|tx| async move {
+            let Some(dev_server) = dev_server::Entity::find_by_id(id).one(&*tx).await? else {
+                return Err(anyhow::anyhow!("no dev server with id {}", id))?;
+            };
+            if dev_server.user_id != user_id || name.trim().is_empty() {
+                return Err(anyhow::anyhow!(proto::ErrorCode::Forbidden))?;
+            }
+
+            dev_server::Entity::update(dev_server::ActiveModel {
+                name: ActiveValue::Set(name.trim().to_string()),
+                ..dev_server.clone().into_active_model()
+            })
+            .exec(&*tx)
+            .await?;
+
+            let dev_server_projects = self
+                .dev_server_projects_update_internal(user_id, &tx)
+                .await?;
+
+            Ok(dev_server_projects)
+        })
+        .await
+    }
+
     pub async fn delete_dev_server(
         &self,
         id: DevServerId,

crates/collab/src/rpc.rs πŸ”—

@@ -433,6 +433,8 @@ impl Server {
             .add_request_handler(user_handler(create_dev_server_project))
             .add_request_handler(user_handler(delete_dev_server_project))
             .add_request_handler(user_handler(create_dev_server))
+            .add_request_handler(user_handler(regenerate_dev_server_token))
+            .add_request_handler(user_handler(rename_dev_server))
             .add_request_handler(user_handler(delete_dev_server))
             .add_request_handler(dev_server_handler(share_dev_server_project))
             .add_request_handler(dev_server_handler(shutdown_dev_server))
@@ -2343,6 +2345,12 @@ async fn create_dev_server(
     let access_token = auth::random_token();
     let hashed_access_token = auth::hash_access_token(&access_token);
 
+    if request.name.is_empty() {
+        return Err(proto::ErrorCode::Forbidden
+            .message("Dev server name cannot be empty".to_string())
+            .anyhow())?;
+    }
+
     let (dev_server, status) = session
         .db()
         .await
@@ -2359,6 +2367,71 @@ async fn create_dev_server(
     Ok(())
 }
 
+async fn regenerate_dev_server_token(
+    request: proto::RegenerateDevServerToken,
+    response: Response<proto::RegenerateDevServerToken>,
+    session: UserSession,
+) -> Result<()> {
+    let dev_server_id = DevServerId(request.dev_server_id as i32);
+    let access_token = auth::random_token();
+    let hashed_access_token = auth::hash_access_token(&access_token);
+
+    let connection_id = session
+        .connection_pool()
+        .await
+        .dev_server_connection_id(dev_server_id);
+    if let Some(connection_id) = connection_id {
+        shutdown_dev_server_internal(dev_server_id, connection_id, &session).await?;
+        session
+            .peer
+            .send(connection_id, proto::ShutdownDevServer {})?;
+        let _ = remove_dev_server_connection(dev_server_id, &session).await;
+    }
+
+    let status = session
+        .db()
+        .await
+        .update_dev_server_token(dev_server_id, &hashed_access_token, session.user_id())
+        .await?;
+
+    send_dev_server_projects_update(session.user_id(), status, &session).await;
+
+    response.send(proto::RegenerateDevServerTokenResponse {
+        dev_server_id: dev_server_id.to_proto(),
+        access_token: auth::generate_dev_server_token(dev_server_id.0 as usize, access_token),
+    })?;
+    Ok(())
+}
+
+async fn rename_dev_server(
+    request: proto::RenameDevServer,
+    response: Response<proto::RenameDevServer>,
+    session: UserSession,
+) -> Result<()> {
+    if request.name.trim().is_empty() {
+        return Err(proto::ErrorCode::Forbidden
+            .message("Dev server name cannot be empty".to_string())
+            .anyhow())?;
+    }
+
+    let dev_server_id = DevServerId(request.dev_server_id as i32);
+    let dev_server = session.db().await.get_dev_server(dev_server_id).await?;
+    if dev_server.user_id != session.user_id() {
+        return Err(anyhow!(ErrorCode::Forbidden))?;
+    }
+
+    let status = session
+        .db()
+        .await
+        .rename_dev_server(dev_server_id, &request.name, session.user_id())
+        .await?;
+
+    send_dev_server_projects_update(session.user_id(), status, &session).await;
+
+    response.send(proto::Ack {})?;
+    Ok(())
+}
+
 async fn delete_dev_server(
     request: proto::DeleteDevServer,
     response: Response<proto::DeleteDevServer>,
@@ -2379,6 +2452,7 @@ async fn delete_dev_server(
         session
             .peer
             .send(connection_id, proto::ShutdownDevServer {})?;
+        let _ = remove_dev_server_connection(dev_server_id, &session).await;
     }
 
     let status = session
@@ -2551,7 +2625,8 @@ async fn shutdown_dev_server(
     session: DevServerSession,
 ) -> Result<()> {
     response.send(proto::Ack {})?;
-    shutdown_dev_server_internal(session.dev_server_id(), session.connection_id, &session).await
+    shutdown_dev_server_internal(session.dev_server_id(), session.connection_id, &session).await?;
+    remove_dev_server_connection(session.dev_server_id(), &session).await
 }
 
 async fn shutdown_dev_server_internal(
@@ -2591,6 +2666,21 @@ async fn shutdown_dev_server_internal(
     Ok(())
 }
 
+async fn remove_dev_server_connection(dev_server_id: DevServerId, session: &Session) -> Result<()> {
+    let dev_server_connection = session
+        .connection_pool()
+        .await
+        .dev_server_connection_id(dev_server_id);
+
+    if let Some(dev_server_connection) = dev_server_connection {
+        session
+            .connection_pool()
+            .await
+            .remove_connection(dev_server_connection)?;
+    }
+    Ok(())
+}
+
 /// Updates other participants with changes to the project
 async fn update_project(
     request: proto::UpdateProject,

crates/collab/src/tests/dev_server_tests.rs πŸ”—

@@ -315,6 +315,139 @@ async fn test_dev_server_delete(
     })
 }
 
+#[gpui::test]
+async fn test_dev_server_rename(
+    cx1: &mut gpui::TestAppContext,
+    cx2: &mut gpui::TestAppContext,
+    cx3: &mut gpui::TestAppContext,
+) {
+    let (server, client1, client2, channel_id) = TestServer::start2(cx1, cx2).await;
+
+    let (_dev_server, remote_workspace) =
+        create_dev_server_project(&server, client1.app_state.clone(), cx1, cx3).await;
+
+    cx1.update(|cx| {
+        workspace::join_channel(
+            channel_id,
+            client1.app_state.clone(),
+            Some(remote_workspace),
+            cx,
+        )
+    })
+    .await
+    .unwrap();
+    cx1.executor().run_until_parked();
+
+    remote_workspace
+        .update(cx1, |ws, cx| {
+            assert!(ws.project().read(cx).is_shared());
+        })
+        .unwrap();
+
+    join_channel(channel_id, &client2, cx2).await.unwrap();
+    cx2.executor().run_until_parked();
+
+    cx1.update(|cx| {
+        dev_server_projects::Store::global(cx).update(cx, |store, cx| {
+            store.rename_dev_server(
+                store.dev_servers().first().unwrap().id,
+                "name-edited".to_string(),
+                cx,
+            )
+        })
+    })
+    .await
+    .unwrap();
+
+    cx1.executor().run_until_parked();
+
+    cx1.update(|cx| {
+        dev_server_projects::Store::global(cx).update(cx, |store, _| {
+            assert_eq!(store.dev_servers().first().unwrap().name, "name-edited");
+        })
+    })
+}
+
+#[gpui::test]
+async fn test_dev_server_refresh_access_token(
+    cx1: &mut gpui::TestAppContext,
+    cx2: &mut gpui::TestAppContext,
+    cx3: &mut gpui::TestAppContext,
+    cx4: &mut gpui::TestAppContext,
+) {
+    let (server, client1, client2, channel_id) = TestServer::start2(cx1, cx2).await;
+
+    let (_dev_server, remote_workspace) =
+        create_dev_server_project(&server, client1.app_state.clone(), cx1, cx3).await;
+
+    cx1.update(|cx| {
+        workspace::join_channel(
+            channel_id,
+            client1.app_state.clone(),
+            Some(remote_workspace),
+            cx,
+        )
+    })
+    .await
+    .unwrap();
+    cx1.executor().run_until_parked();
+
+    remote_workspace
+        .update(cx1, |ws, cx| {
+            assert!(ws.project().read(cx).is_shared());
+        })
+        .unwrap();
+
+    join_channel(channel_id, &client2, cx2).await.unwrap();
+    cx2.executor().run_until_parked();
+
+    // Regenerate the access token
+    let new_token_response = cx1
+        .update(|cx| {
+            dev_server_projects::Store::global(cx).update(cx, |store, cx| {
+                store.regenerate_dev_server_token(store.dev_servers().first().unwrap().id, cx)
+            })
+        })
+        .await
+        .unwrap();
+
+    cx1.executor().run_until_parked();
+
+    // Assert that the other client was disconnected
+    let (workspace, cx2) = client2.active_workspace(cx2);
+    cx2.update(|cx| assert!(workspace.read(cx).project().read(cx).is_disconnected()));
+
+    // Assert that the owner of the dev server does not see the dev server as online anymore
+    let (workspace, cx1) = client1.active_workspace(cx1);
+    cx1.update(|cx| {
+        assert!(workspace.read(cx).project().read(cx).is_disconnected());
+        dev_server_projects::Store::global(cx).update(cx, |store, _| {
+            assert_eq!(
+                store.dev_servers().first().unwrap().status,
+                DevServerStatus::Offline
+            );
+        })
+    });
+
+    // Reconnect the dev server with the new token
+    let _dev_server = server
+        .create_dev_server(new_token_response.access_token, cx4)
+        .await;
+
+    cx1.executor().run_until_parked();
+
+    // Assert that the dev server is online again
+    cx1.update(|cx| {
+        dev_server_projects::Store::global(cx).update(cx, |store, _| {
+            assert_eq!(store.dev_servers().len(), 1);
+            assert_eq!(
+                store.dev_servers().first().unwrap().status,
+                DevServerStatus::Online
+            );
+        })
+    });
+}
+
 #[gpui::test]
 async fn test_dev_server_reconnect(
     cx1: &mut gpui::TestAppContext,

crates/dev_server_projects/src/dev_server_projects.rs πŸ”—

@@ -173,6 +173,39 @@ impl Store {
         })
     }
 
+    pub fn rename_dev_server(
+        &mut self,
+        dev_server_id: DevServerId,
+        name: String,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<()>> {
+        let client = self.client.clone();
+        cx.background_executor().spawn(async move {
+            client
+                .request(proto::RenameDevServer {
+                    dev_server_id: dev_server_id.0,
+                    name,
+                })
+                .await?;
+            Ok(())
+        })
+    }
+
+    pub fn regenerate_dev_server_token(
+        &mut self,
+        dev_server_id: DevServerId,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<proto::RegenerateDevServerTokenResponse>> {
+        let client = self.client.clone();
+        cx.background_executor().spawn(async move {
+            client
+                .request(proto::RegenerateDevServerToken {
+                    dev_server_id: dev_server_id.0,
+                })
+                .await
+        })
+    }
+
     pub fn delete_dev_server(
         &mut self,
         id: DevServerId,

crates/recent_projects/src/dev_servers.rs πŸ”—

@@ -10,7 +10,7 @@ use gpui::{
     View, ViewContext,
 };
 use rpc::{
-    proto::{CreateDevServerResponse, DevServerStatus},
+    proto::{CreateDevServerResponse, DevServerStatus, RegenerateDevServerTokenResponse},
     ErrorCode, ErrorExt,
 };
 use settings::Settings;
@@ -29,15 +29,30 @@ pub struct DevServerProjects {
     dev_server_store: Model<dev_server_projects::Store>,
     project_path_input: View<Editor>,
     dev_server_name_input: View<TextField>,
+    rename_dev_server_input: View<TextField>,
     _subscription: gpui::Subscription,
 }
 
-#[derive(Default)]
+#[derive(Default, Clone)]
 struct CreateDevServer {
     creating: bool,
     dev_server: Option<CreateDevServerResponse>,
 }
 
+#[derive(Clone)]
+struct EditDevServer {
+    dev_server_id: DevServerId,
+    state: EditDevServerState,
+}
+
+#[derive(Clone, PartialEq)]
+enum EditDevServerState {
+    Default,
+    RenamingDevServer,
+    RegeneratingToken,
+    RegeneratedToken(RegenerateDevServerTokenResponse),
+}
+
 #[derive(Clone)]
 struct CreateDevServerProject {
     dev_server_id: DevServerId,
@@ -47,6 +62,7 @@ struct CreateDevServerProject {
 enum Mode {
     Default(Option<CreateDevServerProject>),
     CreateDevServer(CreateDevServer),
+    EditDevServer(EditDevServer),
 }
 
 impl DevServerProjects {
@@ -83,6 +99,8 @@ impl DevServerProjects {
         });
         let dev_server_name_input =
             cx.new_view(|cx| TextField::new(cx, "Name", "").with_label(FieldLabelLayout::Stacked));
+        let rename_dev_server_input =
+            cx.new_view(|cx| TextField::new(cx, "Name", "").with_label(FieldLabelLayout::Stacked));
 
         let focus_handle = cx.focus_handle();
         let dev_server_store = dev_server_projects::Store::global(cx);
@@ -98,6 +116,7 @@ impl DevServerProjects {
             dev_server_store,
             project_path_input,
             dev_server_name_input,
+            rename_dev_server_input,
             _subscription: subscription,
         }
     }
@@ -225,6 +244,88 @@ impl DevServerProjects {
         cx.notify()
     }
 
+    fn rename_dev_server(&mut self, id: DevServerId, cx: &mut ViewContext<Self>) {
+        let name = self
+            .rename_dev_server_input
+            .read(cx)
+            .editor()
+            .read(cx)
+            .text(cx)
+            .trim()
+            .to_string();
+
+        let Some(dev_server) = self.dev_server_store.read(cx).dev_server(id) else {
+            return;
+        };
+
+        if name.is_empty() || dev_server.name == name {
+            return;
+        }
+
+        let request = self
+            .dev_server_store
+            .update(cx, |store, cx| store.rename_dev_server(id, name, cx));
+
+        self.mode = Mode::EditDevServer(EditDevServer {
+            dev_server_id: id,
+            state: EditDevServerState::RenamingDevServer,
+        });
+
+        cx.spawn(|this, mut cx| async move {
+            request.await?;
+            this.update(&mut cx, move |this, cx| {
+                this.mode = Mode::EditDevServer(EditDevServer {
+                    dev_server_id: id,
+                    state: EditDevServerState::Default,
+                });
+                cx.notify();
+            })
+        })
+        .detach_and_prompt_err("Failed to rename dev server", cx, |_, _| None);
+    }
+
+    fn refresh_dev_server_token(&mut self, id: DevServerId, cx: &mut ViewContext<Self>) {
+        let answer = cx.prompt(
+            gpui::PromptLevel::Warning,
+            "Are you sure?",
+            Some("This will invalidate the existing dev server token."),
+            &["Generate", "Cancel"],
+        );
+        cx.spawn(|this, mut cx| async move {
+            let answer = answer.await?;
+
+            if answer != 0 {
+                return Ok(());
+            }
+
+            let response = this
+                .update(&mut cx, move |this, cx| {
+                    let request = this
+                        .dev_server_store
+                        .update(cx, |store, cx| store.regenerate_dev_server_token(id, cx));
+                    this.mode = Mode::EditDevServer(EditDevServer {
+                        dev_server_id: id,
+                        state: EditDevServerState::RegeneratingToken,
+                    });
+                    cx.notify();
+                    request
+                })?
+                .await?;
+
+            this.update(&mut cx, move |this, cx| {
+                this.mode = Mode::EditDevServer(EditDevServer {
+                    dev_server_id: id,
+                    state: EditDevServerState::RegeneratedToken(response),
+                });
+                cx.notify();
+            })
+            .log_err();
+
+            Ok(())
+        })
+        .detach_and_prompt_err("Failed to delete dev server", cx, |_, _| None);
+    }
+
     fn delete_dev_server(&mut self, id: DevServerId, cx: &mut ViewContext<Self>) {
         let answer = cx.prompt(
             gpui::PromptLevel::Destructive,
@@ -314,6 +415,17 @@ impl DevServerProjects {
                     self.create_dev_server(cx);
                 }
             }
+            Mode::EditDevServer(edit_dev_server) => {
+                if self
+                    .rename_dev_server_input
+                    .read(cx)
+                    .editor()
+                    .read(cx)
+                    .is_focused(cx)
+                {
+                    self.rename_dev_server(edit_dev_server.dev_server_id, cx);
+                }
+            }
         }
     }
 
@@ -336,6 +448,7 @@ impl DevServerProjects {
     ) -> impl IntoElement {
         let dev_server_id = dev_server.id;
         let status = dev_server.status;
+        let dev_server_name = dev_server.name.clone();
         if create_project
             .as_ref()
             .is_some_and(|cp| cp.dev_server_id != dev_server.id)
@@ -375,17 +488,32 @@ impl DevServerProjects {
                                         )
                                     }),
                             )
-                            .child(dev_server.name.clone())
+                            .child(dev_server_name.clone())
                             .child(
                                 h_flex()
                                     .visible_on_hover("dev-server")
                                     .gap_1()
                                     .child(
                                         IconButton::new("edit-dev-server", IconName::Pencil)
-                                            .disabled(true) //TODO implement this on the collab side
-                                            .tooltip(|cx| {
-                                                Tooltip::text("Coming Soon - Edit dev server", cx)
-                                            }),
+                                            .on_click(cx.listener(move |this, _, cx| {
+                                                this.mode = Mode::EditDevServer(EditDevServer {
+                                                    dev_server_id,
+                                                    state: EditDevServerState::Default,
+                                                });
+                                                let dev_server_name = dev_server_name.clone();
+                                                this.rename_dev_server_input.update(
+                                                    cx,
+                                                    move |input, cx| {
+                                                        input.editor().update(
+                                                            cx,
+                                                            move |editor, cx| {
+                                                                editor.set_text(dev_server_name, cx)
+                                                            },
+                                                        )
+                                                    },
+                                                )
+                                            }))
+                                            .tooltip(|cx| Tooltip::text("Edit dev server", cx)),
                                     )
                                     .child({
                                         let dev_server_id = dev_server.id;
@@ -507,17 +635,18 @@ impl DevServerProjects {
                 .tooltip(|cx| Tooltip::text("Delete remote project", cx)).into_any_element()))
     }
 
-    fn render_create_dev_server(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
-        let Mode::CreateDevServer(CreateDevServer {
+    fn render_create_dev_server(
+        &mut self,
+        state: CreateDevServer,
+        cx: &mut ViewContext<Self>,
+    ) -> impl IntoElement {
+        let CreateDevServer {
             creating,
             dev_server,
-        }) = &self.mode
-        else {
-            unreachable!()
-        };
+        } = state;
 
         self.dev_server_name_input.update(cx, |input, cx| {
-            input.set_disabled(*creating || dev_server.is_some(), cx);
+            input.set_disabled(creating || dev_server.is_some(), cx);
         });
 
         v_flex()
@@ -529,7 +658,7 @@ impl DevServerProjects {
             .pt_0p5()
             .gap_px()
             .child(
-                ModalHeader::new("remote-projects")
+                ModalHeader::new("create-dev-server")
                     .show_back_button(true)
                     .child(Headline::new("New dev server").size(HeadlineSize::Small)),
             )
@@ -555,14 +684,14 @@ impl DevServerProjects {
                                     div()
                                         .pl_1()
                                         .pb(px(3.))
-                                        .when(!*creating && dev_server.is_none(), |div| {
+                                        .when(!creating && dev_server.is_none(), |div| {
                                             div.child(Button::new("create-dev-server", "Create").on_click(
                                                 cx.listener(move |this, _, cx| {
                                                     this.create_dev_server(cx);
                                                 }),
                                             ))
                                         })
-                                        .when(*creating && dev_server.is_none(), |div| {
+                                        .when(creating && dev_server.is_none(), |div| {
                                             div.child(
                                                 Button::new("create-dev-server", "Creating...")
                                                     .disabled(true),
@@ -579,86 +708,212 @@ impl DevServerProjects {
                                 .read(cx)
                                 .dev_server_status(DevServerId(dev_server.dev_server_id));
 
-                            let instructions = SharedString::from(format!(
-                                "zed --dev-server-token {}",
-                                dev_server.access_token
-                            ));
                             div.child(
-                                v_flex()
-                                    .pl_2()
-                                    .pt_2()
-                                    .gap_2()
-                                    .child(
-                                        h_flex().justify_between().w_full()
-                                            .child(Label::new(format!(
-                                                    "Please log into `{}` and run:",
-                                                    dev_server.name
-                                            )))
-                                            .child(
-                                                Button::new("copy-access-token", "Copy Instructions")
-                                                    .icon(Some(IconName::Copy))
-                                                    .icon_size(IconSize::Small)
-                                                    .on_click({
-                                                        let instructions = instructions.clone();
-                                                        cx.listener(move |_, _, cx| {
-                                                        cx.write_to_clipboard(ClipboardItem::new(
-                                                            instructions.to_string(),
-                                                        ))
-                                                    })})
-                                            )
-                                    )
-                                    .child(
-                                        v_flex()
-                                        .w_full()
-                                        .bg(cx.theme().colors().title_bar_background) // todo: this should be distinct
-                                        .border()
-                                        .border_color(cx.theme().colors().border_variant)
-                                        .rounded_md()
-                                        .my_1()
-                                        .py_0p5()
-                                        .px_3()
-                                        .font_family(ThemeSettings::get_global(cx).buffer_font.family.clone())
-                                        .child(Label::new(instructions))
-                                    )
-                                    .when(status == DevServerStatus::Offline, |this| {
-                                        this.child(
-
-                                        h_flex()
-                                            .gap_2()
-                                            .child(
-                                                Icon::new(IconName::ArrowCircle)
-                                                    .size(IconSize::Medium)
-                                                    .with_animation(
-                                                        "arrow-circle",
-                                                        Animation::new(Duration::from_secs(2)).repeat(),
-                                                        |icon, delta| {
-                                                            icon.transform(Transformation::rotate(percentage(delta)))
-                                                        },
-                                                    ),
-                                            )
-                                            .child(
-                                                Label::new("Waiting for connection…"),
-                                            )
-                                        )
-                                    })
-                                    .when(status == DevServerStatus::Online, |this| {
-                                        this.child(Label::new("🎊 Connection established!"))
-                                            .child(
-                                                h_flex().justify_end().child(
-                                                    Button::new("done", "Done").on_click(cx.listener(
-                                                        |_, _, cx| {
-                                                            cx.dispatch_action(menu::Cancel.boxed_clone())
-                                                        },
-                                                    ))
-                                                ),
-                                            )
-                                    }),
+                                 Self::render_dev_server_token_instructions(&dev_server.access_token, &dev_server.name, status, cx)
                             )
                         }),
                 )
             )
     }
 
+    fn render_dev_server_token_instructions(
+        access_token: &str,
+        dev_server_name: &str,
+        status: DevServerStatus,
+        cx: &mut ViewContext<Self>,
+    ) -> Div {
+        let instructions = SharedString::from(format!("zed --dev-server-token {}", access_token));
+
+        v_flex()
+            .pl_2()
+            .pt_2()
+            .gap_2()
+            .child(
+                h_flex()
+                    .justify_between()
+                    .w_full()
+                    .child(Label::new(format!(
+                        "Please log into `{}` and run:",
+                        dev_server_name
+                    )))
+                    .child(
+                        Button::new("copy-access-token", "Copy Instructions")
+                            .icon(Some(IconName::Copy))
+                            .icon_size(IconSize::Small)
+                            .on_click({
+                                let instructions = instructions.clone();
+                                cx.listener(move |_, _, cx| {
+                                    cx.write_to_clipboard(ClipboardItem::new(
+                                        instructions.to_string(),
+                                    ))
+                                })
+                            }),
+                    ),
+            )
+            .child(
+                v_flex()
+                    .w_full()
+                    .bg(cx.theme().colors().title_bar_background) // todo: this should be distinct
+                    .border()
+                    .border_color(cx.theme().colors().border_variant)
+                    .rounded_md()
+                    .my_1()
+                    .py_0p5()
+                    .px_3()
+                    .font_family(ThemeSettings::get_global(cx).buffer_font.family.clone())
+                    .child(Label::new(instructions)),
+            )
+            .when(status == DevServerStatus::Offline, |this| {
+                this.child(Self::render_loading_spinner("Waiting for connection…"))
+            })
+            .when(status == DevServerStatus::Online, |this| {
+                this.child(Label::new("🎊 Connection established!")).child(
+                    h_flex()
+                        .justify_end()
+                        .child(Button::new("done", "Done").on_click(
+                            cx.listener(|_, _, cx| cx.dispatch_action(menu::Cancel.boxed_clone())),
+                        )),
+                )
+            })
+    }
+
+    fn render_loading_spinner(label: impl Into<SharedString>) -> Div {
+        h_flex()
+            .gap_2()
+            .child(
+                Icon::new(IconName::ArrowCircle)
+                    .size(IconSize::Medium)
+                    .with_animation(
+                        "arrow-circle",
+                        Animation::new(Duration::from_secs(2)).repeat(),
+                        |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
+                    ),
+            )
+            .child(Label::new(label))
+    }
+
+    fn render_edit_dev_server(
+        &mut self,
+        edit_dev_server: EditDevServer,
+        cx: &mut ViewContext<Self>,
+    ) -> impl IntoElement {
+        let dev_server_id = edit_dev_server.dev_server_id;
+        let dev_server = self
+            .dev_server_store
+            .read(cx)
+            .dev_server(dev_server_id)
+            .cloned();
+
+        let dev_server_name = dev_server
+            .as_ref()
+            .map(|dev_server| dev_server.name.clone())
+            .unwrap_or_default();
+
+        let dev_server_status = dev_server
+            .map(|dev_server| dev_server.status)
+            .unwrap_or(DevServerStatus::Offline);
+
+        let disabled = matches!(
+            edit_dev_server.state,
+            EditDevServerState::RenamingDevServer | EditDevServerState::RegeneratingToken
+        );
+        self.rename_dev_server_input.update(cx, |input, cx| {
+            input.set_disabled(disabled, cx);
+        });
+
+        let rename_dev_server_input_text = self
+            .rename_dev_server_input
+            .read(cx)
+            .editor()
+            .read(cx)
+            .text(cx);
+
+        let content = v_flex().w_full().gap_2().child(
+            h_flex()
+                .pb_2()
+                .border_b_1()
+                .border_color(cx.theme().colors().border)
+                .items_end()
+                .w_full()
+                .px_2()
+                .child(
+                    div()
+                        .pl_2()
+                        .max_w(rems(16.))
+                        .child(self.rename_dev_server_input.clone()),
+                )
+                .child(
+                    div()
+                        .pl_1()
+                        .pb(px(3.))
+                        .when(
+                            edit_dev_server.state != EditDevServerState::RenamingDevServer,
+                            |div| {
+                                div.child(
+                                    Button::new("rename-dev-server", "Rename")
+                                        .disabled(
+                                            rename_dev_server_input_text.trim().is_empty()
+                                                || rename_dev_server_input_text == dev_server_name,
+                                        )
+                                        .on_click(cx.listener(move |this, _, cx| {
+                                            this.rename_dev_server(dev_server_id, cx);
+                                            cx.notify();
+                                        })),
+                                )
+                            },
+                        )
+                        .when(
+                            edit_dev_server.state == EditDevServerState::RenamingDevServer,
+                            |div| {
+                                div.child(
+                                    Button::new("rename-dev-server", "Renaming...").disabled(true),
+                                )
+                            },
+                        ),
+                ),
+        );
+
+        let content = content.child(match edit_dev_server.state {
+            EditDevServerState::RegeneratingToken => {
+                Self::render_loading_spinner("Generating token...")
+            }
+            EditDevServerState::RegeneratedToken(response) => {
+                Self::render_dev_server_token_instructions(
+                    &response.access_token,
+                    &dev_server_name,
+                    dev_server_status,
+                    cx,
+                )
+            }
+            _ => h_flex().items_end().w_full().child(
+                Button::new("regenerate-dev-server-token", "Generate new access token")
+                    .icon(IconName::Update)
+                    .on_click(cx.listener(move |this, _, cx| {
+                        this.refresh_dev_server_token(dev_server_id, cx);
+                        cx.notify();
+                    })),
+            ),
+        });
+
+        v_flex()
+            .id("scroll-container")
+            .h_full()
+            .overflow_y_scroll()
+            .track_scroll(&self.scroll_handle)
+            .px_1()
+            .pt_0p5()
+            .gap_px()
+            .child(
+                ModalHeader::new("edit-dev-server")
+                    .show_back_button(true)
+                    .child(
+                        Headline::new(format!("Edit {}", &dev_server_name))
+                            .size(HeadlineSize::Small),
+                    ),
+            )
+            .child(ModalContent::new().child(v_flex().w_full().child(content)))
+    }
+
     fn render_default(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
         let dev_servers = self.dev_server_store.read(cx).dev_servers();
 
@@ -715,121 +970,6 @@ impl DevServerProjects {
                 ),
             )
     }
-
-    // fn render_create_dev_server_project(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
-    //     let Mode::CreateDevServerProject(CreateDevServerProject {
-    //         dev_server_id,
-    //         creating,
-    //         dev_server_project,
-    //     }) = &self.mode
-    //     else {
-    //         unreachable!()
-    //     };
-
-    //     let dev_server = self
-    //         .dev_server_store
-    //         .read(cx)
-    //         .dev_server(*dev_server_id)
-    //         .cloned();
-
-    //     let (dev_server_name, dev_server_status) = dev_server
-    //         .map(|server| (server.name, server.status))
-    //         .unwrap_or((SharedString::from(""), DevServerStatus::Offline));
-
-    //     v_flex()
-    //         .px_1()
-    //         .pt_0p5()
-    //         .gap_px()
-    //         .child(
-    //             v_flex().py_0p5().px_1().child(
-    //                 h_flex()
-    //                     .px_1()
-    //                     .py_0p5()
-    //                     .child(
-    //                         IconButton::new("back", IconName::ArrowLeft)
-    //                             .style(ButtonStyle::Transparent)
-    //                             .on_click(cx.listener(|_, _: &gpui::ClickEvent, cx| {
-    //                                 cx.dispatch_action(menu::Cancel.boxed_clone())
-    //                             })),
-    //                     )
-    //                     .child(Headline::new("Add remote project").size(HeadlineSize::Small)),
-    //             ),
-    //         )
-    //         .child(
-    //             h_flex()
-    //                 .ml_5()
-    //                 .gap_2()
-    //                 .child(
-    //                     div()
-    //                         .id(("status", dev_server_id.0))
-    //                         .relative()
-    //                         .child(Icon::new(IconName::Server))
-    //                         .child(div().absolute().bottom_0().left(rems_from_px(12.0)).child(
-    //                             Indicator::dot().color(match dev_server_status {
-    //                                 DevServerStatus::Online => Color::Created,
-    //                                 DevServerStatus::Offline => Color::Hidden,
-    //                             }),
-    //                         ))
-    //                         .tooltip(move |cx| {
-    //                             Tooltip::text(
-    //                                 match dev_server_status {
-    //                                     DevServerStatus::Online => "Online",
-    //                                     DevServerStatus::Offline => "Offline",
-    //                                 },
-    //                                 cx,
-    //                             )
-    //                         }),
-    //                 )
-    //                 .child(dev_server_name.clone()),
-    //         )
-    //         .child(
-    //             h_flex()
-    //                 .ml_5()
-    //                 .gap_2()
-    //                 .child(self.project_path_input.clone())
-    //                 .when(!*creating && dev_server_project.is_none(), |div| {
-    //                     div.child(Button::new("create-remote-server", "Create").on_click({
-    //                         let dev_server_id = *dev_server_id;
-    //                         cx.listener(move |this, _, cx| {
-    //                             this.create_dev_server_project(dev_server_id, cx)
-    //                         })
-    //                     }))
-    //                 })
-    //                 .when(*creating, |div| {
-    //                     div.child(Button::new("create-dev-server", "Creating...").disabled(true))
-    //                 }),
-    //         )
-    //         .when_some(dev_server_project.clone(), |div, dev_server_project| {
-    //             let status = self
-    //                 .dev_server_store
-    //                 .read(cx)
-    //                 .dev_server_project(DevServerProjectId(dev_server_project.id))
-    //                 .map(|project| {
-    //                     if project.project_id.is_some() {
-    //                         DevServerStatus::Online
-    //                     } else {
-    //                         DevServerStatus::Offline
-    //                     }
-    //                 })
-    //                 .unwrap_or(DevServerStatus::Offline);
-    //             div.child(
-    //                 v_flex()
-    //                     .ml_5()
-    //                     .ml_8()
-    //                     .gap_2()
-    //                     .when(status == DevServerStatus::Offline, |this| {
-    //                         this.child(Label::new("Waiting for project..."))
-    //                     })
-    //                     .when(status == DevServerStatus::Online, |this| {
-    //                         this.child(Label::new("Project online! 🎊")).child(
-    //                             Button::new("done", "Done").on_click(cx.listener(|_, _, cx| {
-    //                                 cx.dispatch_action(menu::Cancel.boxed_clone())
-    //                             })),
-    //                         )
-    //                     }),
-    //             )
-    //         })
-    // }
 }
 impl ModalView for DevServerProjects {}
 
@@ -860,7 +1000,12 @@ impl Render for DevServerProjects {
             .max_h(rems(40.))
             .child(match &self.mode {
                 Mode::Default(_) => self.render_default(cx).into_any_element(),
-                Mode::CreateDevServer(_) => self.render_create_dev_server(cx).into_any_element(),
+                Mode::CreateDevServer(state) => self
+                    .render_create_dev_server(state.clone(), cx)
+                    .into_any_element(),
+                Mode::EditDevServer(state) => self
+                    .render_edit_dev_server(state.clone(), cx)
+                    .into_any_element(),
             })
     }
 }

crates/rpc/proto/zed.proto πŸ”—

@@ -241,7 +241,11 @@ message Envelope {
         DeleteDevServerProject delete_dev_server_project = 197;
 
         GetSupermavenApiKey get_supermaven_api_key = 198;
-        GetSupermavenApiKeyResponse get_supermaven_api_key_response = 199; // current max
+        GetSupermavenApiKeyResponse get_supermaven_api_key_response = 199;
+
+        RegenerateDevServerToken regenerate_dev_server_token = 200;
+        RegenerateDevServerTokenResponse regenerate_dev_server_token_response = 201;
+        RenameDevServer rename_dev_server = 202; // Current max
     }
 
     reserved 158 to 161;
@@ -488,6 +492,15 @@ message CreateDevServer {
     string name = 2;
 }
 
+message RegenerateDevServerToken {
+    uint64 dev_server_id = 1;
+}
+
+message RegenerateDevServerTokenResponse {
+    uint64 dev_server_id = 1;
+    string access_token = 2;
+}
+
 message CreateDevServerResponse {
     uint64 dev_server_id = 1;
     reserved 2;
@@ -498,6 +511,11 @@ message CreateDevServerResponse {
 message ShutdownDevServer {
 }
 
+message RenameDevServer {
+    uint64 dev_server_id = 1;
+    string name = 2;
+}
+
 message DeleteDevServer {
     uint64 dev_server_id = 1;
 }

crates/rpc/src/proto.rs πŸ”—

@@ -323,6 +323,9 @@ messages!(
     (ValidateDevServerProjectRequest, Background),
     (DeleteDevServer, Foreground),
     (DeleteDevServerProject, Foreground),
+    (RegenerateDevServerToken, Foreground),
+    (RegenerateDevServerTokenResponse, Foreground),
+    (RenameDevServer, Foreground),
     (OpenNewBuffer, Foreground)
 );
 
@@ -430,6 +433,8 @@ request_messages!(
     (MultiLspQuery, MultiLspQueryResponse),
     (DeleteDevServer, Ack),
     (DeleteDevServerProject, Ack),
+    (RegenerateDevServerToken, RegenerateDevServerTokenResponse),
+    (RenameDevServer, Ack)
 );
 
 entity_messages!(