Allow clicking on worktrees to share, unshare, join, and leave

Max Brunsfeld and Nathan Sobo created

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

Change summary

zed/assets/themes/_base.toml |  15 +--
zed/src/editor.rs            |   6 +
zed/src/editor/buffer.rs     |   5 +
zed/src/menus.rs             |  10 --
zed/src/people_panel.rs      | 135 ++++++++++++++++++++-----------------
zed/src/theme.rs             |   3 
zed/src/user.rs              |   1 
zed/src/workspace.rs         |  84 ++++++++++++++++++++---
zed/src/worktree.rs          |  35 +++++++++
9 files changed, 200 insertions(+), 94 deletions(-)

Detailed changes

zed/assets/themes/_base.toml 🔗

@@ -134,21 +134,18 @@ guest_avatar_spacing = 8
 extends = "$text.1"
 padding = { left = 5 }
 
-[people_panel.own_worktree]
-extends = "$people_panel.unshared_worktree"
-color = "$syntax.variant"
-
-[people_panel.joined_worktree]
-extends = "$people_panel.own_worktree"
+[people_panel.hovered_unshared_worktree]
+extends = "$people_panel.shared_worktree"
+background = "$state.hover"
+corner_radius = 6
 
 [people_panel.shared_worktree]
 extends = "$people_panel.unshared_worktree"
 color = "$text.0.color"
 
 [people_panel.hovered_shared_worktree]
-extends = "$people_panel.shared_worktree"
-background = "$state.hover"
-corner_radius = 6
+extends = "$people_panel.hovered_unshared_worktree"
+color = "$text.0.color"
 
 [people_panel.tree_branch]
 width = 1

zed/src/editor.rs 🔗

@@ -2320,6 +2320,7 @@ impl Editor {
             buffer::Event::Saved => cx.emit(Event::Saved),
             buffer::Event::FileHandleChanged => cx.emit(Event::FileHandleChanged),
             buffer::Event::Reloaded => cx.emit(Event::FileHandleChanged),
+            buffer::Event::Closed => cx.emit(Event::Closed),
             buffer::Event::Reparsed => {}
         }
     }
@@ -2449,6 +2450,7 @@ pub enum Event {
     Dirtied,
     Saved,
     FileHandleChanged,
+    Closed,
 }
 
 impl Entity for Editor {
@@ -2556,6 +2558,10 @@ impl workspace::ItemView for Editor {
         matches!(event, Event::Activate)
     }
 
+    fn should_close_item_on_event(event: &Self::Event) -> bool {
+        matches!(event, Event::Closed)
+    }
+
     fn should_update_tab_on_event(event: &Self::Event) -> bool {
         matches!(
             event,

zed/src/editor/buffer.rs 🔗

@@ -801,6 +801,10 @@ impl Buffer {
         cx.emit(Event::FileHandleChanged);
     }
 
+    pub fn close(&mut self, cx: &mut ModelContext<Self>) {
+        cx.emit(Event::Closed);
+    }
+
     pub fn language(&self) -> Option<&Arc<Language>> {
         self.language.as_ref()
     }
@@ -2264,6 +2268,7 @@ pub enum Event {
     FileHandleChanged,
     Reloaded,
     Reparsed,
+    Closed,
 }
 
 impl Entity for Buffer {

zed/src/menus.rs 🔗

@@ -16,16 +16,6 @@ pub fn menus(state: &Arc<AppState>) -> Vec<Menu<'static>> {
                     action: Box::new(super::About),
                 },
                 MenuItem::Separator,
-                MenuItem::Action {
-                    name: "Sign In",
-                    keystroke: None,
-                    action: Box::new(super::Authenticate),
-                },
-                MenuItem::Action {
-                    name: "Share",
-                    keystroke: None,
-                    action: Box::new(workspace::ShareWorktree),
-                },
                 MenuItem::Action {
                     name: "Quit",
                     keystroke: Some("cmd-q"),

zed/src/people_panel.rs 🔗

@@ -14,6 +14,9 @@ use gpui::{
 use postage::watch;
 
 action!(JoinWorktree, u64);
+action!(LeaveWorktree, u64);
+action!(ShareWorktree, u64);
+action!(UnshareWorktree, u64);
 
 pub struct PeoplePanel {
     collaborators: ListState,
@@ -106,7 +109,7 @@ impl PeoplePanel {
                         .left()
                         .constrained()
                         .with_height(host_avatar_height)
-                        .boxed()
+                        .boxed(),
                     )
                     .boxed(),
             )
@@ -161,70 +164,78 @@ impl PeoplePanel {
                                 .boxed(),
                             )
                             .with_child({
-                                let mut worktree_row =
-                                    MouseEventHandler::new::<PeoplePanel, _, _, _>(
-                                        worktree_id as usize,
-                                        cx,
-                                        |mouse_state, _| {
-                                            let style =
-                                                if Some(collaborator.user.id) == current_user_id {
-                                                    &theme.own_worktree
-                                                } else if worktree.is_shared {
-                                                    if worktree.guests.iter().any(|guest| {
-                                                        Some(guest.id) == current_user_id
-                                                    }) {
-                                                        &theme.joined_worktree
-                                                    } else if mouse_state.hovered {
-                                                        &theme.hovered_shared_worktree
-                                                    } else {
-                                                        &theme.shared_worktree
-                                                    }
-                                                } else {
-                                                    &theme.unshared_worktree
-                                                };
+                                let is_host = Some(collaborator.user.id) == current_user_id;
+                                let is_guest = !is_host
+                                    && worktree
+                                        .guests
+                                        .iter()
+                                        .any(|guest| Some(guest.id) == current_user_id);
+                                let is_shared = worktree.is_shared;
 
-                                                Container::new(
-                                                    Flex::row()
-                                                        .with_child(
-                                                            Label::new(
-                                                                worktree.root_name.clone(),
-                                                                style.text.clone(),
-                                                            )
-                                                            .aligned()
-                                                            .left()
-                                                            .constrained()
-                                                            .with_height(guest_avatar_height)
-                                                            .boxed()
-                                                        )
-                                                        .with_children(worktree.guests.iter().filter_map(
-                                                            |participant| {
-                                                                participant.avatar.clone().map(|avatar| {
-                                                                    Image::new(avatar)
-                                                                        .with_style(theme.guest_avatar)
-                                                                        .contained()
-                                                                        .with_margin_left(
-                                                                            theme.guest_avatar_spacing,
-                                                                        )
-                                                                        .boxed()
-                                                                })
-                                                            },
-                                                        ))
-                                                        .boxed()
-                                                )
-                                                .with_style(style.container)
-                                                .boxed()
-                                        },
-                                    );
+                                MouseEventHandler::new::<PeoplePanel, _, _, _>(
+                                    worktree_id as usize,
+                                    cx,
+                                    |mouse_state, _| {
+                                        let style = match (worktree.is_shared, mouse_state.hovered)
+                                        {
+                                            (false, false) => &theme.unshared_worktree,
+                                            (false, true) => &theme.hovered_unshared_worktree,
+                                            (true, false) => &theme.shared_worktree,
+                                            (true, true) => &theme.hovered_shared_worktree,
+                                        };
 
-                                if worktree.is_shared {
-                                    worktree_row = worktree_row
-                                        .with_cursor_style(CursorStyle::PointingHand)
-                                        .on_click(move |cx| {
+                                        Container::new(
+                                            Flex::row()
+                                                .with_child(
+                                                    Label::new(
+                                                        worktree.root_name.clone(),
+                                                        style.text.clone(),
+                                                    )
+                                                    .aligned()
+                                                    .left()
+                                                    .constrained()
+                                                    .with_height(guest_avatar_height)
+                                                    .boxed(),
+                                                )
+                                                .with_children(worktree.guests.iter().filter_map(
+                                                    |participant| {
+                                                        participant.avatar.clone().map(|avatar| {
+                                                            Image::new(avatar)
+                                                                .with_style(theme.guest_avatar)
+                                                                .contained()
+                                                                .with_margin_left(
+                                                                    theme.guest_avatar_spacing,
+                                                                )
+                                                                .boxed()
+                                                        })
+                                                    },
+                                                ))
+                                                .boxed(),
+                                        )
+                                        .with_style(style.container)
+                                        .boxed()
+                                    },
+                                )
+                                .with_cursor_style(if is_host || is_shared {
+                                    CursorStyle::PointingHand
+                                } else {
+                                    CursorStyle::Arrow
+                                })
+                                .on_click(move |cx| {
+                                    if is_shared {
+                                        if is_host {
+                                            cx.dispatch_action(UnshareWorktree(worktree_id));
+                                        } else if is_guest {
+                                            cx.dispatch_action(LeaveWorktree(worktree_id));
+                                        } else {
                                             cx.dispatch_action(JoinWorktree(worktree_id))
-                                        });
-                                }
-
-                                worktree_row.expanded(1.0).boxed()
+                                        }
+                                    } else if is_host {
+                                        cx.dispatch_action(ShareWorktree(worktree_id));
+                                    }
+                                })
+                                .expanded(1.0)
+                                .boxed()
                             })
                             .boxed()
                     }),

zed/src/theme.rs 🔗

@@ -111,11 +111,10 @@ pub struct PeoplePanel {
     pub container: ContainerStyle,
     pub host_avatar: ImageStyle,
     pub host_username: ContainedText,
-    pub own_worktree: ContainedText,
-    pub joined_worktree: ContainedText,
     pub shared_worktree: ContainedText,
     pub hovered_shared_worktree: ContainedText,
     pub unshared_worktree: ContainedText,
+    pub hovered_unshared_worktree: ContainedText,
     pub guest_avatar: ImageStyle,
     pub guest_avatar_spacing: f32,
     pub tree_branch: TreeBranch,

zed/src/user.rs 🔗

@@ -128,6 +128,7 @@ impl UserStore {
             }
 
             this.update(&mut cx, |this, cx| {
+                collaborators.sort_by(|a, b| a.user.github_login.cmp(&b.user.github_login));
                 this.collaborators = collaborators.into();
                 cx.notify();
             });

zed/src/workspace.rs 🔗

@@ -7,7 +7,7 @@ use crate::{
     editor::Buffer,
     fs::Fs,
     language::LanguageRegistry,
-    people_panel::{JoinWorktree, PeoplePanel},
+    people_panel::{JoinWorktree, LeaveWorktree, PeoplePanel, ShareWorktree, UnshareWorktree},
     project_browser::ProjectBrowser,
     rpc,
     settings::Settings,
@@ -43,7 +43,6 @@ use std::{
 action!(Open, Arc<AppState>);
 action!(OpenPaths, OpenParams);
 action!(OpenNew, Arc<AppState>);
-action!(ShareWorktree);
 action!(Save);
 action!(DebugElements);
 
@@ -56,9 +55,11 @@ pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(Workspace::save_active_item);
     cx.add_action(Workspace::debug_elements);
     cx.add_action(Workspace::open_new_file);
-    cx.add_action(Workspace::share_worktree);
     cx.add_action(Workspace::toggle_sidebar_item);
+    cx.add_action(Workspace::share_worktree);
+    cx.add_action(Workspace::unshare_worktree);
     cx.add_action(Workspace::join_worktree);
+    cx.add_action(Workspace::leave_worktree);
     cx.add_bindings(vec![
         Binding::new("cmd-s", Save, None),
         Binding::new("cmd-alt-i", DebugElements, None),
@@ -175,6 +176,9 @@ pub trait ItemView: View {
     fn should_activate_item_on_event(_: &Self::Event) -> bool {
         false
     }
+    fn should_close_item_on_event(_: &Self::Event) -> bool {
+        false
+    }
     fn should_update_tab_on_event(_: &Self::Event) -> bool {
         false
     }
@@ -273,6 +277,10 @@ impl<T: ItemView> ItemViewHandle for ViewHandle<T> {
     fn set_parent_pane(&self, pane: &ViewHandle<Pane>, cx: &mut MutableAppContext) {
         pane.update(cx, |_, cx| {
             cx.subscribe(self, |pane, item, event, cx| {
+                if T::should_close_item_on_event(event) {
+                    pane.close_item(item.id(), cx);
+                    return;
+                }
                 if T::should_activate_item_on_event(event) {
                     if let Some(ix) = pane.item_index(&item) {
                         pane.activate_item(ix, cx);
@@ -814,21 +822,33 @@ impl Workspace {
         };
     }
 
-    fn share_worktree(&mut self, _: &ShareWorktree, cx: &mut ViewContext<Self>) {
+    fn share_worktree(&mut self, action: &ShareWorktree, cx: &mut ViewContext<Self>) {
         let rpc = self.rpc.clone();
+        let remote_id = action.0;
         cx.spawn(|this, mut cx| {
             async move {
                 rpc.authenticate_and_connect(&cx).await?;
 
-                let share_task = this.update(&mut cx, |this, cx| {
-                    let worktree = this.worktrees.iter().next()?;
-                    worktree.update(cx, |worktree, cx| {
-                        let worktree = worktree.as_local_mut()?;
-                        Some(worktree.share(cx))
-                    })
+                let task = this.update(&mut cx, |this, cx| {
+                    for worktree in &this.worktrees {
+                        let task = worktree.update(cx, |worktree, cx| {
+                            worktree.as_local_mut().and_then(|worktree| {
+                                if worktree.remote_id() == Some(remote_id) {
+                                    Some(worktree.share(cx))
+                                } else {
+                                    None
+                                }
+                            })
+                        });
+
+                        if task.is_some() {
+                            return task;
+                        }
+                    }
+                    None
                 });
 
-                if let Some(share_task) = share_task {
+                if let Some(share_task) = task {
                     share_task.await?;
                 }
 
@@ -839,6 +859,23 @@ impl Workspace {
         .detach();
     }
 
+    fn unshare_worktree(&mut self, action: &UnshareWorktree, cx: &mut ViewContext<Self>) {
+        let remote_id = action.0;
+        for worktree in &self.worktrees {
+            if worktree.update(cx, |worktree, cx| {
+                if let Some(worktree) = worktree.as_local_mut() {
+                    if worktree.remote_id() == Some(remote_id) {
+                        worktree.unshare(cx);
+                        return true;
+                    }
+                }
+                false
+            }) {
+                break;
+            }
+        }
+    }
+
     fn join_worktree(&mut self, action: &JoinWorktree, cx: &mut ViewContext<Self>) {
         let rpc = self.rpc.clone();
         let languages = self.languages.clone();
@@ -862,6 +899,31 @@ impl Workspace {
         .detach();
     }
 
+    fn leave_worktree(&mut self, action: &LeaveWorktree, cx: &mut ViewContext<Self>) {
+        let remote_id = action.0;
+        cx.spawn(|this, mut cx| {
+            async move {
+                this.update(&mut cx, |this, cx| {
+                    this.worktrees.retain(|worktree| {
+                        worktree.update(cx, |worktree, cx| {
+                            if let Some(worktree) = worktree.as_remote_mut() {
+                                if worktree.remote_id() == remote_id {
+                                    worktree.close_all_buffers(cx);
+                                    return false;
+                                }
+                            }
+                            true
+                        })
+                    })
+                });
+
+                Ok(())
+            }
+            .log_err()
+        })
+        .detach();
+    }
+
     fn add_pane(&mut self, cx: &mut ViewContext<Self>) -> ViewHandle<Pane> {
         let pane = cx.add_view(|_| Pane::new(self.settings.clone()));
         let pane_id = pane.id();

zed/src/worktree.rs 🔗

@@ -943,6 +943,10 @@ impl LocalWorktree {
         }
     }
 
+    pub fn remote_id(&self) -> Option<u64> {
+        *self.remote_id.borrow()
+    }
+
     pub fn next_remote_id(&self) -> impl Future<Output = Option<u64>> {
         let mut remote_id = self.remote_id.clone();
         async move {
@@ -1095,6 +1099,23 @@ impl LocalWorktree {
         })
     }
 
+    pub fn unshare(&mut self, cx: &mut ModelContext<Worktree>) {
+        self.share.take();
+        let rpc = self.rpc.clone();
+        let remote_id = self.remote_id();
+        cx.foreground()
+            .spawn(
+                async move {
+                    if let Some(worktree_id) = remote_id {
+                        rpc.send(proto::UnshareWorktree { worktree_id }).await?;
+                    }
+                    Ok(())
+                }
+                .log_err(),
+            )
+            .detach()
+    }
+
     fn share_request(&self, cx: &mut ModelContext<Worktree>) -> Task<Option<proto::ShareWorktree>> {
         let remote_id = self.next_remote_id();
         let snapshot = self.snapshot();
@@ -1229,6 +1250,20 @@ impl RemoteWorktree {
         })
     }
 
+    pub fn remote_id(&self) -> u64 {
+        self.remote_id
+    }
+
+    pub fn close_all_buffers(&mut self, cx: &mut MutableAppContext) {
+        for (_, buffer) in self.open_buffers.drain() {
+            if let RemoteBuffer::Loaded(buffer) = buffer {
+                if let Some(buffer) = buffer.upgrade(cx) {
+                    buffer.update(cx, |buffer, cx| buffer.close(cx))
+                }
+            }
+        }
+    }
+
     fn snapshot(&self) -> Snapshot {
         self.snapshot.clone()
     }