Style the contact panel while public/private operations are in-flight

Max Brunsfeld created

Change summary

Cargo.lock                                  |   1 
crates/contacts_panel/Cargo.toml            |   1 
crates/contacts_panel/src/contacts_panel.rs | 446 +++++++++++++++++-----
crates/project/src/project.rs               |  10 
crates/theme/src/theme.rs                   |   2 
styles/src/styleTree/contactsPanel.ts       |   2 
6 files changed, 342 insertions(+), 120 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -956,6 +956,7 @@ version = "0.1.0"
 dependencies = [
  "anyhow",
  "client",
+ "collections",
  "editor",
  "futures",
  "fuzzy",

crates/contacts_panel/Cargo.toml 🔗

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

crates/contacts_panel/src/contacts_panel.rs 🔗

@@ -400,32 +400,41 @@ impl ContactsPanel {
                             })
                             .boxed(),
                         )
-                        .with_children(if mouse_state.hovered && open_project.is_some() {
-                            Some(
-                                MouseEventHandler::new::<ToggleProjectPublic, _, _>(
-                                    project_id as usize,
-                                    cx,
-                                    |state, _| {
-                                        let mut icon_style =
-                                            *theme.private_button.style_for(state, false);
-                                        icon_style.container.background_color =
-                                            row.container.background_color;
-                                        render_icon_button(&icon_style, "icons/lock-8.svg")
-                                            .aligned()
-                                            .boxed()
-                                    },
-                                )
-                                .with_cursor_style(CursorStyle::PointingHand)
-                                .on_click(move |_, _, cx| {
-                                    cx.dispatch_action(ToggleProjectPublic {
-                                        project: open_project.clone(),
-                                    })
-                                })
-                                .boxed(),
-                            )
-                        } else {
-                            None
-                        })
+                        .with_children(open_project.and_then(|open_project| {
+                            let is_becoming_private = !open_project.read(cx).is_public();
+                            if !mouse_state.hovered && !is_becoming_private {
+                                return None;
+                            }
+
+                            let mut button = MouseEventHandler::new::<ToggleProjectPublic, _, _>(
+                                project_id as usize,
+                                cx,
+                                |state, _| {
+                                    let mut icon_style =
+                                        *theme.private_button.style_for(state, false);
+                                    icon_style.container.background_color =
+                                        row.container.background_color;
+                                    if is_becoming_private {
+                                        icon_style.color = theme.disabled_button.color;
+                                    }
+                                    render_icon_button(&icon_style, "icons/lock-8.svg")
+                                        .aligned()
+                                        .boxed()
+                                },
+                            );
+
+                            if !is_becoming_private {
+                                button = button
+                                    .with_cursor_style(CursorStyle::PointingHand)
+                                    .on_click(move |_, _, cx| {
+                                        cx.dispatch_action(ToggleProjectPublic {
+                                            project: Some(open_project.clone()),
+                                        })
+                                    });
+                            }
+
+                            Some(button.boxed())
+                        }))
                         .constrained()
                         .with_width(host_avatar_height)
                         .boxed(),
@@ -501,7 +510,7 @@ impl ContactsPanel {
             let row = theme.project_row.style_for(state, is_selected);
             let mut worktree_root_names = String::new();
             let project_ = project.read(cx);
-            let is_public = project_.is_public();
+            let is_becoming_public = project_.is_public();
             for tree in project_.visible_worktrees(cx) {
                 if !worktree_root_names.is_empty() {
                     worktree_root_names.push_str(", ");
@@ -510,33 +519,32 @@ impl ContactsPanel {
             }
 
             Flex::row()
-                .with_child(
-                    MouseEventHandler::new::<TogglePublic, _, _>(project_id, cx, |state, _| {
-                        if is_public {
-                            Empty::new().constrained()
-                        } else {
-                            render_icon_button(
-                                theme.private_button.style_for(state, false),
-                                "icons/lock-8.svg",
-                            )
-                            .aligned()
-                            .constrained()
-                        }
-                        .with_width(host_avatar_height)
-                        .boxed()
-                    })
-                    .with_cursor_style(if is_public {
-                        CursorStyle::default()
-                    } else {
-                        CursorStyle::PointingHand
-                    })
-                    .on_click(move |_, _, cx| {
-                        cx.dispatch_action(ToggleProjectPublic {
-                            project: Some(project.clone()),
-                        })
-                    })
-                    .boxed(),
-                )
+                .with_child({
+                    let mut button =
+                        MouseEventHandler::new::<TogglePublic, _, _>(project_id, cx, |state, _| {
+                            let mut style = *theme.private_button.style_for(state, false);
+                            if is_becoming_public {
+                                style.color = theme.disabled_button.color;
+                            }
+                            render_icon_button(&style, "icons/lock-8.svg")
+                                .aligned()
+                                .constrained()
+                                .with_width(host_avatar_height)
+                                .boxed()
+                        });
+
+                    if !is_becoming_public {
+                        button = button
+                            .with_cursor_style(CursorStyle::PointingHand)
+                            .on_click(move |_, _, cx| {
+                                cx.dispatch_action(ToggleProjectPublic {
+                                    project: Some(project.clone()),
+                                })
+                            });
+                    }
+
+                    button.boxed()
+                })
                 .with_child(
                     Label::new(worktree_root_names, row.name.text.clone())
                         .aligned()
@@ -596,7 +604,7 @@ impl ContactsPanel {
             row.add_children([
                 MouseEventHandler::new::<Decline, _, _>(user.id as usize, cx, |mouse_state, _| {
                     let button_style = if is_contact_request_pending {
-                        &theme.disabled_contact_button
+                        &theme.disabled_button
                     } else {
                         &theme.contact_button.style_for(mouse_state, false)
                     };
@@ -618,7 +626,7 @@ impl ContactsPanel {
                 .boxed(),
                 MouseEventHandler::new::<Accept, _, _>(user.id as usize, cx, |mouse_state, _| {
                     let button_style = if is_contact_request_pending {
-                        &theme.disabled_contact_button
+                        &theme.disabled_button
                     } else {
                         &theme.contact_button.style_for(mouse_state, false)
                     };
@@ -640,7 +648,7 @@ impl ContactsPanel {
             row.add_child(
                 MouseEventHandler::new::<Cancel, _, _>(user.id as usize, cx, |mouse_state, _| {
                     let button_style = if is_contact_request_pending {
-                        &theme.disabled_contact_button
+                        &theme.disabled_button
                     } else {
                         &theme.contact_button.style_for(mouse_state, false)
                     };
@@ -1153,6 +1161,7 @@ mod tests {
         test::{FakeHttpClient, FakeServer},
         Client,
     };
+    use collections::HashSet;
     use gpui::{serde_json::json, TestAppContext};
     use language::LanguageRegistry;
     use project::{FakeFs, Project};
@@ -1182,12 +1191,14 @@ mod tests {
                 cx,
             )
         });
-        project
+        let worktree_id = project
             .update(cx, |project, cx| {
                 project.find_or_create_local_worktree("/private_dir", true, cx)
             })
             .await
-            .unwrap();
+            .unwrap()
+            .0
+            .read_with(cx, |worktree, _| worktree.id().to_proto());
 
         let workspace = cx.add_view(0, |cx| Workspace::new(project.clone(), cx));
         let panel = cx.add_view(0, |cx| {
@@ -1199,6 +1210,18 @@ mod tests {
             )
         });
 
+        workspace.update(cx, |_, cx| {
+            cx.observe(&panel, |_, panel, cx| {
+                let entries = render_to_strings(&panel, cx);
+                assert!(
+                    entries.iter().collect::<HashSet<_>>().len() == entries.len(),
+                    "Duplicate contact panel entries {:?}",
+                    entries
+                )
+            })
+            .detach();
+        });
+
         let get_users_request = server.receive::<proto::GetUsers>().await.unwrap();
         server
             .respond(
@@ -1278,9 +1301,196 @@ mod tests {
 
         cx.foreground().run_until_parked();
         assert_eq!(
-            render_to_strings(&panel, cx),
+            cx.read(|cx| render_to_strings(&panel, cx)),
+            &[
+                "v Requests",
+                "  incoming user_one",
+                "  outgoing user_two",
+                "v Online",
+                "  the_current_user",
+                "    dir3",
+                "    🔒 private_dir",
+                "  user_four",
+                "    dir2",
+                "  user_three",
+                "    dir1",
+                "v Offline",
+                "  user_five",
+            ]
+        );
+
+        // Make a project public. It appears as loading, since the project
+        // isn't yet visible to other contacts.
+        project.update(cx, |project, cx| project.set_public(true, cx));
+        cx.foreground().run_until_parked();
+        assert_eq!(
+            cx.read(|cx| render_to_strings(&panel, cx)),
+            &[
+                "v Requests",
+                "  incoming user_one",
+                "  outgoing user_two",
+                "v Online",
+                "  the_current_user",
+                "    dir3",
+                "    🔒 private_dir (becoming public...)",
+                "  user_four",
+                "    dir2",
+                "  user_three",
+                "    dir1",
+                "v Offline",
+                "  user_five",
+            ]
+        );
+
+        // The server responds, assigning the project a remote id. It still appears
+        // as loading, because the server hasn't yet sent out the updated contact
+        // state for the current user.
+        let request = server.receive::<proto::RegisterProject>().await.unwrap();
+        server
+            .respond(
+                request.receipt(),
+                proto::RegisterProjectResponse { project_id: 200 },
+            )
+            .await;
+        cx.foreground().run_until_parked();
+        assert_eq!(
+            cx.read(|cx| render_to_strings(&panel, cx)),
+            &[
+                "v Requests",
+                "  incoming user_one",
+                "  outgoing user_two",
+                "v Online",
+                "  the_current_user",
+                "    dir3",
+                "    🔒 private_dir (becoming public...)",
+                "  user_four",
+                "    dir2",
+                "  user_three",
+                "    dir1",
+                "v Offline",
+                "  user_five",
+            ]
+        );
+
+        // The server receives the project's metadata and updates the contact metadata
+        // for the current user. Now the project appears as public.
+        assert_eq!(
+            server
+                .receive::<proto::UpdateProject>()
+                .await
+                .unwrap()
+                .payload
+                .worktrees,
+            &[proto::WorktreeMetadata {
+                id: worktree_id,
+                root_name: "private_dir".to_string(),
+                visible: true,
+            }],
+        );
+        server.send(proto::UpdateContacts {
+            contacts: vec![proto::Contact {
+                user_id: current_user_id,
+                online: true,
+                should_notify: false,
+                projects: vec![
+                    proto::ProjectMetadata {
+                        id: 103,
+                        worktree_root_names: vec!["dir3".to_string()],
+                        guests: vec![3],
+                    },
+                    proto::ProjectMetadata {
+                        id: 200,
+                        worktree_root_names: vec!["private_dir".to_string()],
+                        guests: vec![3],
+                    },
+                ],
+            }],
+            ..Default::default()
+        });
+        cx.foreground().run_until_parked();
+        assert_eq!(
+            cx.read(|cx| render_to_strings(&panel, cx)),
+            &[
+                "v Requests",
+                "  incoming user_one",
+                "  outgoing user_two",
+                "v Online",
+                "  the_current_user",
+                "    dir3",
+                "    private_dir",
+                "  user_four",
+                "    dir2",
+                "  user_three",
+                "    dir1",
+                "v Offline",
+                "  user_five",
+            ]
+        );
+
+        // Make the project private. It appears as loading.
+        project.update(cx, |project, cx| project.set_public(false, cx));
+        cx.foreground().run_until_parked();
+        assert_eq!(
+            cx.read(|cx| render_to_strings(&panel, cx)),
+            &[
+                "v Requests",
+                "  incoming user_one",
+                "  outgoing user_two",
+                "v Online",
+                "  the_current_user",
+                "    dir3",
+                "    private_dir (becoming private...)",
+                "  user_four",
+                "    dir2",
+                "  user_three",
+                "    dir1",
+                "v Offline",
+                "  user_five",
+            ]
+        );
+
+        // The server receives the unregister request and updates the contact
+        // metadata for the current user. The project is now private.
+        let request = server.receive::<proto::UnregisterProject>().await.unwrap();
+        server.send(proto::UpdateContacts {
+            contacts: vec![proto::Contact {
+                user_id: current_user_id,
+                online: true,
+                should_notify: false,
+                projects: vec![proto::ProjectMetadata {
+                    id: 103,
+                    worktree_root_names: vec!["dir3".to_string()],
+                    guests: vec![3],
+                }],
+            }],
+            ..Default::default()
+        });
+        cx.foreground().run_until_parked();
+        assert_eq!(
+            cx.read(|cx| render_to_strings(&panel, cx)),
+            &[
+                "v Requests",
+                "  incoming user_one",
+                "  outgoing user_two",
+                "v Online",
+                "  the_current_user",
+                "    dir3",
+                "    🔒 private_dir",
+                "  user_four",
+                "    dir2",
+                "  user_three",
+                "    dir1",
+                "v Offline",
+                "  user_five",
+            ]
+        );
+
+        // The server responds to the unregister request.
+        server.respond(request.receipt(), proto::Ack {}).await;
+        cx.foreground().run_until_parked();
+        assert_eq!(
+            cx.read(|cx| render_to_strings(&panel, cx)),
             &[
-                "+",
                 "v Requests",
                 "  incoming user_one",
                 "  outgoing user_two",
@@ -1304,9 +1514,8 @@ mod tests {
         });
         cx.foreground().run_until_parked();
         assert_eq!(
-            render_to_strings(&panel, cx),
+            cx.read(|cx| render_to_strings(&panel, cx)),
             &[
-                "+",
                 "v Online",
                 "  user_four  <=== selected",
                 "    dir2",
@@ -1319,9 +1528,8 @@ mod tests {
             panel.select_next(&Default::default(), cx);
         });
         assert_eq!(
-            render_to_strings(&panel, cx),
+            cx.read(|cx| render_to_strings(&panel, cx)),
             &[
-                "+",
                 "v Online",
                 "  user_four",
                 "    dir2  <=== selected",
@@ -1334,9 +1542,8 @@ mod tests {
             panel.select_next(&Default::default(), cx);
         });
         assert_eq!(
-            render_to_strings(&panel, cx),
+            cx.read(|cx| render_to_strings(&panel, cx)),
             &[
-                "+",
                 "v Online",
                 "  user_four",
                 "    dir2",
@@ -1346,56 +1553,65 @@ mod tests {
         );
     }
 
-    fn render_to_strings(panel: &ViewHandle<ContactsPanel>, cx: &TestAppContext) -> Vec<String> {
-        panel.read_with(cx, |panel, _| {
-            let mut entries = Vec::new();
-            entries.push("+".to_string());
-            entries.extend(panel.entries.iter().enumerate().map(|(ix, entry)| {
-                let mut string = match entry {
-                    ContactEntry::Header(name) => {
-                        let icon = if panel.collapsed_sections.contains(name) {
-                            ">"
+    fn render_to_strings(panel: &ViewHandle<ContactsPanel>, cx: &AppContext) -> Vec<String> {
+        let panel = panel.read(cx);
+        let mut entries = Vec::new();
+        entries.extend(panel.entries.iter().enumerate().map(|(ix, entry)| {
+            let mut string = match entry {
+                ContactEntry::Header(name) => {
+                    let icon = if panel.collapsed_sections.contains(name) {
+                        ">"
+                    } else {
+                        "v"
+                    };
+                    format!("{} {:?}", icon, name)
+                }
+                ContactEntry::IncomingRequest(user) => {
+                    format!("  incoming {}", user.github_login)
+                }
+                ContactEntry::OutgoingRequest(user) => {
+                    format!("  outgoing {}", user.github_login)
+                }
+                ContactEntry::Contact(contact) => {
+                    format!("  {}", contact.user.github_login)
+                }
+                ContactEntry::ContactProject(contact, project_ix, project) => {
+                    let project = project
+                        .and_then(|p| p.upgrade(cx))
+                        .map(|project| project.read(cx));
+                    format!(
+                        "    {}{}",
+                        contact.projects[*project_ix].worktree_root_names.join(", "),
+                        if project.map_or(true, |project| project.is_public()) {
+                            ""
                         } else {
-                            "v"
-                        };
-                        format!("{} {:?}", icon, name)
-                    }
-                    ContactEntry::IncomingRequest(user) => {
-                        format!("  incoming {}", user.github_login)
-                    }
-                    ContactEntry::OutgoingRequest(user) => {
-                        format!("  outgoing {}", user.github_login)
-                    }
-                    ContactEntry::Contact(contact) => {
-                        format!("  {}", contact.user.github_login)
-                    }
-                    ContactEntry::ContactProject(contact, project_ix, _) => {
-                        format!(
-                            "    {}",
-                            contact.projects[*project_ix].worktree_root_names.join(", ")
-                        )
-                    }
-                    ContactEntry::PrivateProject(project) => cx.read(|cx| {
-                        format!(
-                            "    🔒 {}",
-                            project
-                                .upgrade(cx)
-                                .unwrap()
-                                .read(cx)
-                                .worktree_root_names(cx)
-                                .collect::<Vec<_>>()
-                                .join(", ")
-                        )
-                    }),
-                };
-
-                if panel.selection == Some(ix) {
-                    string.push_str("  <=== selected");
+                            " (becoming private...)"
+                        },
+                    )
+                }
+                ContactEntry::PrivateProject(project) => {
+                    let project = project.upgrade(cx).unwrap().read(cx);
+                    format!(
+                        "    🔒 {}{}",
+                        project
+                            .worktree_root_names(cx)
+                            .collect::<Vec<_>>()
+                            .join(", "),
+                        if project.is_public() {
+                            " (becoming public...)"
+                        } else {
+                            ""
+                        },
+                    )
                 }
+            };
 
-                string
-            }));
-            entries
-        })
+            if panel.selection == Some(ix) {
+                string.push_str("  <=== selected");
+            }
+
+            string
+        }));
+        entries
     }
 }

crates/project/src/project.rs 🔗

@@ -746,8 +746,13 @@ impl Project {
         cx.notify();
         self.project_store.update(cx, |_, cx| cx.notify());
 
-        if let ProjectClientState::Local { remote_id_rx, .. } = &self.client_state {
-            if let Some(project_id) = *remote_id_rx.borrow() {
+        if let ProjectClientState::Local {
+            remote_id_rx,
+            public_rx,
+            ..
+        } = &self.client_state
+        {
+            if let (Some(project_id), true) = (*remote_id_rx.borrow(), *public_rx.borrow()) {
                 self.client
                     .send(proto::UpdateProject {
                         project_id,
@@ -3707,7 +3712,6 @@ impl Project {
                             project.shared_remote_id()
                         });
 
-                        // Because sharing is async, we may have *unshared* the project by the time it completes.
                         if let Some(project_id) = project_id {
                             worktree
                                 .update(&mut cx, |worktree, cx| {

crates/theme/src/theme.rs 🔗

@@ -279,7 +279,7 @@ pub struct ContactsPanel {
     pub contact_username: ContainedText,
     pub contact_button: Interactive<IconButton>,
     pub contact_button_spacing: f32,
-    pub disabled_contact_button: IconButton,
+    pub disabled_button: IconButton,
     pub tree_branch: Interactive<TreeBranch>,
     pub private_button: Interactive<IconButton>,
     pub section_icon_size: f32,

styles/src/styleTree/contactsPanel.ts 🔗

@@ -124,7 +124,7 @@ export default function contactsPanel(theme: Theme) {
         background: backgroundColor(theme, 100, "hovered"),
       },
     },
-    disabledContactButton: {
+    disabledButton: {
       ...contactButton,
       background: backgroundColor(theme, 100),
       color: iconColor(theme, "muted"),