Merge pull request #988 from zed-industries/contact-panel-keyboard-nav

Max Brunsfeld created

Allow interacting with the contacts panel using the keyboard

Change summary

Cargo.lock                                  |   2 
assets/keymaps/default.json                 |  14 
assets/themes/cave-dark.json                | 145 +--
assets/themes/cave-light.json               | 145 +--
assets/themes/dark.json                     | 145 +--
assets/themes/light.json                    | 145 +--
assets/themes/solarized-dark.json           | 145 +--
assets/themes/solarized-light.json          | 145 +--
assets/themes/sulphurpool-dark.json         | 145 +--
assets/themes/sulphurpool-light.json        | 145 +--
crates/client/src/http.rs                   |  26 
crates/client/src/test.rs                   |  20 
crates/collab/src/rpc.rs                    |  74 -
crates/collab/src/rpc/store.rs              |  79 -
crates/contacts_panel/Cargo.toml            |   5 
crates/contacts_panel/src/contacts_panel.rs | 862 +++++++++++++++++-----
crates/theme/src/theme.rs                   |  23 
crates/workspace/src/sidebar.rs             |  14 
crates/workspace/src/workspace.rs           |   4 
styles/src/styleTree/app.ts                 |   2 
styles/src/styleTree/contactsPanel.ts       | 111 ++
21 files changed, 1,405 insertions(+), 991 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -936,9 +936,11 @@ dependencies = [
  "futures",
  "fuzzy",
  "gpui",
+ "language",
  "log",
  "picker",
  "postage",
+ "project",
  "serde",
  "settings",
  "theme",

assets/keymaps/default.json 🔗

@@ -324,6 +324,20 @@
                     "side": "Left",
                     "item_index": 0
                 }
+            ],
+            "cmd-9": [
+                "workspace::ToggleSidebarItemFocus",
+                {
+                    "side": "Right",
+                    "item_index": 0
+                }
+            ],
+            "cmd-shift-(": [
+                "workspace::ToggleSidebarItem",
+                {
+                    "side": "Right",
+                    "item_index": 0
+                }
             ]
         }
     },

assets/themes/cave-dark.json 🔗

@@ -1029,9 +1029,7 @@
   "chat_panel": {
     "padding": {
       "top": 12,
-      "left": 12,
-      "bottom": 12,
-      "right": 12
+      "bottom": 12
     },
     "channel_name": {
       "family": "Zed Sans",
@@ -1248,9 +1246,7 @@
   "contacts_panel": {
     "padding": {
       "top": 12,
-      "left": 12,
-      "bottom": 12,
-      "right": 12
+      "bottom": 12
     },
     "user_query_editor": {
       "background": "#19171c",
@@ -1278,33 +1274,61 @@
         "left": 8,
         "right": 8,
         "top": 4
+      },
+      "margin": {
+        "left": 12,
+        "right": 12
       }
     },
     "user_query_editor_height": 32,
     "add_contact_button": {
       "margin": {
-        "left": 6
+        "left": 6,
+        "right": 12
       },
       "color": "#e2dfe7",
       "button_width": 8,
       "icon_width": 8
     },
-    "row": {
-      "padding": {
-        "left": 8
-      }
-    },
     "row_height": 28,
-    "header": {
+    "section_icon_size": 8,
+    "header_row": {
       "family": "Zed Mono",
       "color": "#8b8792",
       "size": 14,
       "margin": {
-        "top": 8
+        "top": 14
+      },
+      "padding": {
+        "left": 12,
+        "right": 12
+      },
+      "active": {
+        "family": "Zed Mono",
+        "color": "#e2dfe7",
+        "size": 14,
+        "background": "#5852605c"
+      }
+    },
+    "contact_row": {
+      "padding": {
+        "left": 12,
+        "right": 12
+      },
+      "active": {
+        "background": "#5852605c"
+      }
+    },
+    "tree_branch": {
+      "color": "#655f6d",
+      "width": 1,
+      "hover": {
+        "color": "#655f6d"
+      },
+      "active": {
+        "color": "#655f6d"
       }
     },
-    "tree_branch_color": "#655f6d",
-    "tree_branch_width": 1,
     "contact_avatar": {
       "corner_radius": 10,
       "width": 18
@@ -1313,10 +1337,11 @@
       "family": "Zed Mono",
       "color": "#e2dfe7",
       "size": 14,
-      "padding": {
+      "margin": {
         "left": 8
       }
     },
+    "contact_button_spacing": 8,
     "contact_button": {
       "background": "#26232a",
       "color": "#e2dfe7",
@@ -1334,7 +1359,7 @@
       "button_width": 16,
       "corner_radius": 8
     },
-    "project": {
+    "shared_project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {
@@ -1343,38 +1368,32 @@
       },
       "name": {
         "family": "Zed Mono",
-        "color": "#7e7887",
+        "color": "#8b8792",
         "size": 14,
         "margin": {
+          "left": 8,
           "right": 6
         }
       },
-      "padding": {
-        "left": 8
-      }
-    },
-    "shared_project": {
-      "guest_avatar_spacing": 4,
-      "height": 24,
-      "guest_avatar": {
-        "corner_radius": 8,
-        "width": 14
-      },
-      "name": {
-        "family": "Zed Mono",
-        "color": "#8b8792",
-        "size": 14,
+      "guests": {
         "margin": {
-          "right": 6
+          "left": 8,
+          "right": 8
         }
       },
       "padding": {
-        "left": 8
+        "left": 12,
+        "right": 12
       },
       "background": "#26232a",
-      "corner_radius": 6
+      "hover": {
+        "background": "#5852603d"
+      },
+      "active": {
+        "background": "#5852605c"
+      }
     },
-    "hovered_shared_project": {
+    "unshared_project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {
@@ -1386,53 +1405,27 @@
         "color": "#8b8792",
         "size": 14,
         "margin": {
+          "left": 8,
           "right": 6
         }
       },
-      "padding": {
-        "left": 8
-      },
-      "background": "#5852603d",
-      "corner_radius": 6
-    },
-    "unshared_project": {
-      "guest_avatar_spacing": 4,
-      "height": 24,
-      "guest_avatar": {
-        "corner_radius": 8,
-        "width": 14
-      },
-      "name": {
-        "family": "Zed Mono",
-        "color": "#7e7887",
-        "size": 14,
+      "guests": {
         "margin": {
-          "right": 6
+          "left": 8,
+          "right": 8
         }
       },
       "padding": {
-        "left": 8
-      }
-    },
-    "hovered_unshared_project": {
-      "guest_avatar_spacing": 4,
-      "height": 24,
-      "guest_avatar": {
-        "corner_radius": 8,
-        "width": 14
-      },
-      "name": {
-        "family": "Zed Mono",
-        "color": "#7e7887",
-        "size": 14,
-        "margin": {
-          "right": 6
-        }
+        "left": 12,
+        "right": 12
       },
-      "padding": {
-        "left": 8
+      "background": "#26232a",
+      "hover": {
+        "background": "#5852603d"
       },
-      "corner_radius": 6
+      "active": {
+        "background": "#5852605c"
+      }
     }
   },
   "contact_finder": {

assets/themes/cave-light.json 🔗

@@ -1029,9 +1029,7 @@
   "chat_panel": {
     "padding": {
       "top": 12,
-      "left": 12,
-      "bottom": 12,
-      "right": 12
+      "bottom": 12
     },
     "channel_name": {
       "family": "Zed Sans",
@@ -1248,9 +1246,7 @@
   "contacts_panel": {
     "padding": {
       "top": 12,
-      "left": 12,
-      "bottom": 12,
-      "right": 12
+      "bottom": 12
     },
     "user_query_editor": {
       "background": "#efecf4",
@@ -1278,33 +1274,61 @@
         "left": 8,
         "right": 8,
         "top": 4
+      },
+      "margin": {
+        "left": 12,
+        "right": 12
       }
     },
     "user_query_editor_height": 32,
     "add_contact_button": {
       "margin": {
-        "left": 6
+        "left": 6,
+        "right": 12
       },
       "color": "#26232a",
       "button_width": 8,
       "icon_width": 8
     },
-    "row": {
-      "padding": {
-        "left": 8
-      }
-    },
     "row_height": 28,
-    "header": {
+    "section_icon_size": 8,
+    "header_row": {
       "family": "Zed Mono",
       "color": "#585260",
       "size": 14,
       "margin": {
-        "top": 8
+        "top": 14
+      },
+      "padding": {
+        "left": 12,
+        "right": 12
+      },
+      "active": {
+        "family": "Zed Mono",
+        "color": "#26232a",
+        "size": 14,
+        "background": "#8b87922e"
+      }
+    },
+    "contact_row": {
+      "padding": {
+        "left": 12,
+        "right": 12
+      },
+      "active": {
+        "background": "#8b87922e"
+      }
+    },
+    "tree_branch": {
+      "color": "#7e7887",
+      "width": 1,
+      "hover": {
+        "color": "#7e7887"
+      },
+      "active": {
+        "color": "#7e7887"
       }
     },
-    "tree_branch_color": "#7e7887",
-    "tree_branch_width": 1,
     "contact_avatar": {
       "corner_radius": 10,
       "width": 18
@@ -1313,10 +1337,11 @@
       "family": "Zed Mono",
       "color": "#26232a",
       "size": 14,
-      "padding": {
+      "margin": {
         "left": 8
       }
     },
+    "contact_button_spacing": 8,
     "contact_button": {
       "background": "#e2dfe7",
       "color": "#26232a",
@@ -1334,7 +1359,7 @@
       "button_width": 16,
       "corner_radius": 8
     },
-    "project": {
+    "shared_project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {
@@ -1343,38 +1368,32 @@
       },
       "name": {
         "family": "Zed Mono",
-        "color": "#655f6d",
+        "color": "#585260",
         "size": 14,
         "margin": {
+          "left": 8,
           "right": 6
         }
       },
-      "padding": {
-        "left": 8
-      }
-    },
-    "shared_project": {
-      "guest_avatar_spacing": 4,
-      "height": 24,
-      "guest_avatar": {
-        "corner_radius": 8,
-        "width": 14
-      },
-      "name": {
-        "family": "Zed Mono",
-        "color": "#585260",
-        "size": 14,
+      "guests": {
         "margin": {
-          "right": 6
+          "left": 8,
+          "right": 8
         }
       },
       "padding": {
-        "left": 8
+        "left": 12,
+        "right": 12
       },
       "background": "#e2dfe7",
-      "corner_radius": 6
+      "hover": {
+        "background": "#8b87921f"
+      },
+      "active": {
+        "background": "#8b87922e"
+      }
     },
-    "hovered_shared_project": {
+    "unshared_project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {
@@ -1386,53 +1405,27 @@
         "color": "#585260",
         "size": 14,
         "margin": {
+          "left": 8,
           "right": 6
         }
       },
-      "padding": {
-        "left": 8
-      },
-      "background": "#8b87921f",
-      "corner_radius": 6
-    },
-    "unshared_project": {
-      "guest_avatar_spacing": 4,
-      "height": 24,
-      "guest_avatar": {
-        "corner_radius": 8,
-        "width": 14
-      },
-      "name": {
-        "family": "Zed Mono",
-        "color": "#655f6d",
-        "size": 14,
+      "guests": {
         "margin": {
-          "right": 6
+          "left": 8,
+          "right": 8
         }
       },
       "padding": {
-        "left": 8
-      }
-    },
-    "hovered_unshared_project": {
-      "guest_avatar_spacing": 4,
-      "height": 24,
-      "guest_avatar": {
-        "corner_radius": 8,
-        "width": 14
-      },
-      "name": {
-        "family": "Zed Mono",
-        "color": "#655f6d",
-        "size": 14,
-        "margin": {
-          "right": 6
-        }
+        "left": 12,
+        "right": 12
       },
-      "padding": {
-        "left": 8
+      "background": "#e2dfe7",
+      "hover": {
+        "background": "#8b87921f"
       },
-      "corner_radius": 6
+      "active": {
+        "background": "#8b87922e"
+      }
     }
   },
   "contact_finder": {

assets/themes/dark.json 🔗

@@ -1029,9 +1029,7 @@
   "chat_panel": {
     "padding": {
       "top": 12,
-      "left": 12,
-      "bottom": 12,
-      "right": 12
+      "bottom": 12
     },
     "channel_name": {
       "family": "Zed Sans",
@@ -1248,9 +1246,7 @@
   "contacts_panel": {
     "padding": {
       "top": 12,
-      "left": 12,
-      "bottom": 12,
-      "right": 12
+      "bottom": 12
     },
     "user_query_editor": {
       "background": "#000000",
@@ -1278,33 +1274,61 @@
         "left": 8,
         "right": 8,
         "top": 4
+      },
+      "margin": {
+        "left": 12,
+        "right": 12
       }
     },
     "user_query_editor_height": 32,
     "add_contact_button": {
       "margin": {
-        "left": 6
+        "left": 6,
+        "right": 12
       },
       "color": "#c6c6c6",
       "button_width": 8,
       "icon_width": 8
     },
-    "row": {
-      "padding": {
-        "left": 8
-      }
-    },
     "row_height": 28,
-    "header": {
+    "section_icon_size": 8,
+    "header_row": {
       "family": "Zed Mono",
       "color": "#9c9c9c",
       "size": 14,
       "margin": {
-        "top": 8
+        "top": 14
+      },
+      "padding": {
+        "left": 12,
+        "right": 12
+      },
+      "active": {
+        "family": "Zed Mono",
+        "color": "#f1f1f1",
+        "size": 14,
+        "background": "#1c1c1c"
+      }
+    },
+    "contact_row": {
+      "padding": {
+        "left": 12,
+        "right": 12
+      },
+      "active": {
+        "background": "#1c1c1c"
+      }
+    },
+    "tree_branch": {
+      "color": "#000000",
+      "width": 1,
+      "hover": {
+        "color": "#000000"
+      },
+      "active": {
+        "color": "#000000"
       }
     },
-    "tree_branch_color": "#404040",
-    "tree_branch_width": 1,
     "contact_avatar": {
       "corner_radius": 10,
       "width": 18
@@ -1313,10 +1337,11 @@
       "family": "Zed Mono",
       "color": "#f1f1f1",
       "size": 14,
-      "padding": {
+      "margin": {
         "left": 8
       }
     },
+    "contact_button_spacing": 8,
     "contact_button": {
       "background": "#2b2b2b",
       "color": "#c6c6c6",
@@ -1334,7 +1359,7 @@
       "button_width": 16,
       "corner_radius": 8
     },
-    "project": {
+    "shared_project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {
@@ -1343,38 +1368,32 @@
       },
       "name": {
         "family": "Zed Mono",
-        "color": "#474747",
+        "color": "#9c9c9c",
         "size": 14,
         "margin": {
+          "left": 8,
           "right": 6
         }
       },
-      "padding": {
-        "left": 8
-      }
-    },
-    "shared_project": {
-      "guest_avatar_spacing": 4,
-      "height": 24,
-      "guest_avatar": {
-        "corner_radius": 8,
-        "width": 14
-      },
-      "name": {
-        "family": "Zed Mono",
-        "color": "#9c9c9c",
-        "size": 14,
+      "guests": {
         "margin": {
-          "right": 6
+          "left": 8,
+          "right": 8
         }
       },
       "padding": {
-        "left": 8
+        "left": 12,
+        "right": 12
       },
       "background": "#1c1c1c",
-      "corner_radius": 6
+      "hover": {
+        "background": "#232323"
+      },
+      "active": {
+        "background": "#2b2b2b"
+      }
     },
-    "hovered_shared_project": {
+    "unshared_project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {
@@ -1386,53 +1405,27 @@
         "color": "#9c9c9c",
         "size": 14,
         "margin": {
+          "left": 8,
           "right": 6
         }
       },
-      "padding": {
-        "left": 8
-      },
-      "background": "#232323",
-      "corner_radius": 6
-    },
-    "unshared_project": {
-      "guest_avatar_spacing": 4,
-      "height": 24,
-      "guest_avatar": {
-        "corner_radius": 8,
-        "width": 14
-      },
-      "name": {
-        "family": "Zed Mono",
-        "color": "#474747",
-        "size": 14,
+      "guests": {
         "margin": {
-          "right": 6
+          "left": 8,
+          "right": 8
         }
       },
       "padding": {
-        "left": 8
-      }
-    },
-    "hovered_unshared_project": {
-      "guest_avatar_spacing": 4,
-      "height": 24,
-      "guest_avatar": {
-        "corner_radius": 8,
-        "width": 14
-      },
-      "name": {
-        "family": "Zed Mono",
-        "color": "#474747",
-        "size": 14,
-        "margin": {
-          "right": 6
-        }
+        "left": 12,
+        "right": 12
       },
-      "padding": {
-        "left": 8
+      "background": "#1c1c1c",
+      "hover": {
+        "background": "#232323"
       },
-      "corner_radius": 6
+      "active": {
+        "background": "#2b2b2b"
+      }
     }
   },
   "contact_finder": {

assets/themes/light.json 🔗

@@ -1029,9 +1029,7 @@
   "chat_panel": {
     "padding": {
       "top": 12,
-      "left": 12,
-      "bottom": 12,
-      "right": 12
+      "bottom": 12
     },
     "channel_name": {
       "family": "Zed Sans",
@@ -1248,9 +1246,7 @@
   "contacts_panel": {
     "padding": {
       "top": 12,
-      "left": 12,
-      "bottom": 12,
-      "right": 12
+      "bottom": 12
     },
     "user_query_editor": {
       "background": "#ffffff",
@@ -1278,33 +1274,61 @@
         "left": 8,
         "right": 8,
         "top": 4
+      },
+      "margin": {
+        "left": 12,
+        "right": 12
       }
     },
     "user_query_editor_height": 32,
     "add_contact_button": {
       "margin": {
-        "left": 6
+        "left": 6,
+        "right": 12
       },
       "color": "#393939",
       "button_width": 8,
       "icon_width": 8
     },
-    "row": {
-      "padding": {
-        "left": 8
-      }
-    },
     "row_height": 28,
-    "header": {
+    "section_icon_size": 8,
+    "header_row": {
       "family": "Zed Mono",
       "color": "#474747",
       "size": 14,
       "margin": {
-        "top": 8
+        "top": 14
+      },
+      "padding": {
+        "left": 12,
+        "right": 12
+      },
+      "active": {
+        "family": "Zed Mono",
+        "color": "#2b2b2b",
+        "size": 14,
+        "background": "#d5d5d5"
+      }
+    },
+    "contact_row": {
+      "padding": {
+        "left": 12,
+        "right": 12
+      },
+      "active": {
+        "background": "#d5d5d5"
+      }
+    },
+    "tree_branch": {
+      "color": "#b8b8b8",
+      "width": 1,
+      "hover": {
+        "color": "#b8b8b8"
+      },
+      "active": {
+        "color": "#b8b8b8"
       }
     },
-    "tree_branch_color": "#e3e3e3",
-    "tree_branch_width": 1,
     "contact_avatar": {
       "corner_radius": 10,
       "width": 18
@@ -1313,10 +1337,11 @@
       "family": "Zed Mono",
       "color": "#2b2b2b",
       "size": 14,
-      "padding": {
+      "margin": {
         "left": 8
       }
     },
+    "contact_button_spacing": 8,
     "contact_button": {
       "background": "#eaeaea",
       "color": "#393939",
@@ -1334,7 +1359,7 @@
       "button_width": 16,
       "corner_radius": 8
     },
-    "project": {
+    "shared_project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {
@@ -1343,38 +1368,32 @@
       },
       "name": {
         "family": "Zed Mono",
-        "color": "#808080",
+        "color": "#474747",
         "size": 14,
         "margin": {
+          "left": 8,
           "right": 6
         }
       },
-      "padding": {
-        "left": 8
-      }
-    },
-    "shared_project": {
-      "guest_avatar_spacing": 4,
-      "height": 24,
-      "guest_avatar": {
-        "corner_radius": 8,
-        "width": 14
-      },
-      "name": {
-        "family": "Zed Mono",
-        "color": "#474747",
-        "size": 14,
+      "guests": {
         "margin": {
-          "right": 6
+          "left": 8,
+          "right": 8
         }
       },
       "padding": {
-        "left": 8
+        "left": 12,
+        "right": 12
       },
       "background": "#f8f8f8",
-      "corner_radius": 6
+      "hover": {
+        "background": "#eaeaea"
+      },
+      "active": {
+        "background": "#e3e3e3"
+      }
     },
-    "hovered_shared_project": {
+    "unshared_project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {
@@ -1386,53 +1405,27 @@
         "color": "#474747",
         "size": 14,
         "margin": {
+          "left": 8,
           "right": 6
         }
       },
-      "padding": {
-        "left": 8
-      },
-      "background": "#eaeaea",
-      "corner_radius": 6
-    },
-    "unshared_project": {
-      "guest_avatar_spacing": 4,
-      "height": 24,
-      "guest_avatar": {
-        "corner_radius": 8,
-        "width": 14
-      },
-      "name": {
-        "family": "Zed Mono",
-        "color": "#808080",
-        "size": 14,
+      "guests": {
         "margin": {
-          "right": 6
+          "left": 8,
+          "right": 8
         }
       },
       "padding": {
-        "left": 8
-      }
-    },
-    "hovered_unshared_project": {
-      "guest_avatar_spacing": 4,
-      "height": 24,
-      "guest_avatar": {
-        "corner_radius": 8,
-        "width": 14
-      },
-      "name": {
-        "family": "Zed Mono",
-        "color": "#808080",
-        "size": 14,
-        "margin": {
-          "right": 6
-        }
+        "left": 12,
+        "right": 12
       },
-      "padding": {
-        "left": 8
+      "background": "#f8f8f8",
+      "hover": {
+        "background": "#eaeaea"
       },
-      "corner_radius": 6
+      "active": {
+        "background": "#e3e3e3"
+      }
     }
   },
   "contact_finder": {

assets/themes/solarized-dark.json 🔗

@@ -1029,9 +1029,7 @@
   "chat_panel": {
     "padding": {
       "top": 12,
-      "left": 12,
-      "bottom": 12,
-      "right": 12
+      "bottom": 12
     },
     "channel_name": {
       "family": "Zed Sans",
@@ -1248,9 +1246,7 @@
   "contacts_panel": {
     "padding": {
       "top": 12,
-      "left": 12,
-      "bottom": 12,
-      "right": 12
+      "bottom": 12
     },
     "user_query_editor": {
       "background": "#002b36",
@@ -1278,33 +1274,61 @@
         "left": 8,
         "right": 8,
         "top": 4
+      },
+      "margin": {
+        "left": 12,
+        "right": 12
       }
     },
     "user_query_editor_height": 32,
     "add_contact_button": {
       "margin": {
-        "left": 6
+        "left": 6,
+        "right": 12
       },
       "color": "#eee8d5",
       "button_width": 8,
       "icon_width": 8
     },
-    "row": {
-      "padding": {
-        "left": 8
-      }
-    },
     "row_height": 28,
-    "header": {
+    "section_icon_size": 8,
+    "header_row": {
       "family": "Zed Mono",
       "color": "#93a1a1",
       "size": 14,
       "margin": {
-        "top": 8
+        "top": 14
+      },
+      "padding": {
+        "left": 12,
+        "right": 12
+      },
+      "active": {
+        "family": "Zed Mono",
+        "color": "#eee8d5",
+        "size": 14,
+        "background": "#586e755c"
+      }
+    },
+    "contact_row": {
+      "padding": {
+        "left": 12,
+        "right": 12
+      },
+      "active": {
+        "background": "#586e755c"
+      }
+    },
+    "tree_branch": {
+      "color": "#657b83",
+      "width": 1,
+      "hover": {
+        "color": "#657b83"
+      },
+      "active": {
+        "color": "#657b83"
       }
     },
-    "tree_branch_color": "#657b83",
-    "tree_branch_width": 1,
     "contact_avatar": {
       "corner_radius": 10,
       "width": 18
@@ -1313,10 +1337,11 @@
       "family": "Zed Mono",
       "color": "#eee8d5",
       "size": 14,
-      "padding": {
+      "margin": {
         "left": 8
       }
     },
+    "contact_button_spacing": 8,
     "contact_button": {
       "background": "#073642",
       "color": "#eee8d5",
@@ -1334,7 +1359,7 @@
       "button_width": 16,
       "corner_radius": 8
     },
-    "project": {
+    "shared_project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {
@@ -1343,38 +1368,32 @@
       },
       "name": {
         "family": "Zed Mono",
-        "color": "#839496",
+        "color": "#93a1a1",
         "size": 14,
         "margin": {
+          "left": 8,
           "right": 6
         }
       },
-      "padding": {
-        "left": 8
-      }
-    },
-    "shared_project": {
-      "guest_avatar_spacing": 4,
-      "height": 24,
-      "guest_avatar": {
-        "corner_radius": 8,
-        "width": 14
-      },
-      "name": {
-        "family": "Zed Mono",
-        "color": "#93a1a1",
-        "size": 14,
+      "guests": {
         "margin": {
-          "right": 6
+          "left": 8,
+          "right": 8
         }
       },
       "padding": {
-        "left": 8
+        "left": 12,
+        "right": 12
       },
       "background": "#073642",
-      "corner_radius": 6
+      "hover": {
+        "background": "#586e753d"
+      },
+      "active": {
+        "background": "#586e755c"
+      }
     },
-    "hovered_shared_project": {
+    "unshared_project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {
@@ -1386,53 +1405,27 @@
         "color": "#93a1a1",
         "size": 14,
         "margin": {
+          "left": 8,
           "right": 6
         }
       },
-      "padding": {
-        "left": 8
-      },
-      "background": "#586e753d",
-      "corner_radius": 6
-    },
-    "unshared_project": {
-      "guest_avatar_spacing": 4,
-      "height": 24,
-      "guest_avatar": {
-        "corner_radius": 8,
-        "width": 14
-      },
-      "name": {
-        "family": "Zed Mono",
-        "color": "#839496",
-        "size": 14,
+      "guests": {
         "margin": {
-          "right": 6
+          "left": 8,
+          "right": 8
         }
       },
       "padding": {
-        "left": 8
-      }
-    },
-    "hovered_unshared_project": {
-      "guest_avatar_spacing": 4,
-      "height": 24,
-      "guest_avatar": {
-        "corner_radius": 8,
-        "width": 14
-      },
-      "name": {
-        "family": "Zed Mono",
-        "color": "#839496",
-        "size": 14,
-        "margin": {
-          "right": 6
-        }
+        "left": 12,
+        "right": 12
       },
-      "padding": {
-        "left": 8
+      "background": "#073642",
+      "hover": {
+        "background": "#586e753d"
       },
-      "corner_radius": 6
+      "active": {
+        "background": "#586e755c"
+      }
     }
   },
   "contact_finder": {

assets/themes/solarized-light.json 🔗

@@ -1029,9 +1029,7 @@
   "chat_panel": {
     "padding": {
       "top": 12,
-      "left": 12,
-      "bottom": 12,
-      "right": 12
+      "bottom": 12
     },
     "channel_name": {
       "family": "Zed Sans",
@@ -1248,9 +1246,7 @@
   "contacts_panel": {
     "padding": {
       "top": 12,
-      "left": 12,
-      "bottom": 12,
-      "right": 12
+      "bottom": 12
     },
     "user_query_editor": {
       "background": "#fdf6e3",
@@ -1278,33 +1274,61 @@
         "left": 8,
         "right": 8,
         "top": 4
+      },
+      "margin": {
+        "left": 12,
+        "right": 12
       }
     },
     "user_query_editor_height": 32,
     "add_contact_button": {
       "margin": {
-        "left": 6
+        "left": 6,
+        "right": 12
       },
       "color": "#073642",
       "button_width": 8,
       "icon_width": 8
     },
-    "row": {
-      "padding": {
-        "left": 8
-      }
-    },
     "row_height": 28,
-    "header": {
+    "section_icon_size": 8,
+    "header_row": {
       "family": "Zed Mono",
       "color": "#586e75",
       "size": 14,
       "margin": {
-        "top": 8
+        "top": 14
+      },
+      "padding": {
+        "left": 12,
+        "right": 12
+      },
+      "active": {
+        "family": "Zed Mono",
+        "color": "#073642",
+        "size": 14,
+        "background": "#93a1a12e"
+      }
+    },
+    "contact_row": {
+      "padding": {
+        "left": 12,
+        "right": 12
+      },
+      "active": {
+        "background": "#93a1a12e"
+      }
+    },
+    "tree_branch": {
+      "color": "#839496",
+      "width": 1,
+      "hover": {
+        "color": "#839496"
+      },
+      "active": {
+        "color": "#839496"
       }
     },
-    "tree_branch_color": "#839496",
-    "tree_branch_width": 1,
     "contact_avatar": {
       "corner_radius": 10,
       "width": 18
@@ -1313,10 +1337,11 @@
       "family": "Zed Mono",
       "color": "#073642",
       "size": 14,
-      "padding": {
+      "margin": {
         "left": 8
       }
     },
+    "contact_button_spacing": 8,
     "contact_button": {
       "background": "#eee8d5",
       "color": "#073642",
@@ -1334,7 +1359,7 @@
       "button_width": 16,
       "corner_radius": 8
     },
-    "project": {
+    "shared_project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {
@@ -1343,38 +1368,32 @@
       },
       "name": {
         "family": "Zed Mono",
-        "color": "#657b83",
+        "color": "#586e75",
         "size": 14,
         "margin": {
+          "left": 8,
           "right": 6
         }
       },
-      "padding": {
-        "left": 8
-      }
-    },
-    "shared_project": {
-      "guest_avatar_spacing": 4,
-      "height": 24,
-      "guest_avatar": {
-        "corner_radius": 8,
-        "width": 14
-      },
-      "name": {
-        "family": "Zed Mono",
-        "color": "#586e75",
-        "size": 14,
+      "guests": {
         "margin": {
-          "right": 6
+          "left": 8,
+          "right": 8
         }
       },
       "padding": {
-        "left": 8
+        "left": 12,
+        "right": 12
       },
       "background": "#eee8d5",
-      "corner_radius": 6
+      "hover": {
+        "background": "#93a1a11f"
+      },
+      "active": {
+        "background": "#93a1a12e"
+      }
     },
-    "hovered_shared_project": {
+    "unshared_project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {
@@ -1386,53 +1405,27 @@
         "color": "#586e75",
         "size": 14,
         "margin": {
+          "left": 8,
           "right": 6
         }
       },
-      "padding": {
-        "left": 8
-      },
-      "background": "#93a1a11f",
-      "corner_radius": 6
-    },
-    "unshared_project": {
-      "guest_avatar_spacing": 4,
-      "height": 24,
-      "guest_avatar": {
-        "corner_radius": 8,
-        "width": 14
-      },
-      "name": {
-        "family": "Zed Mono",
-        "color": "#657b83",
-        "size": 14,
+      "guests": {
         "margin": {
-          "right": 6
+          "left": 8,
+          "right": 8
         }
       },
       "padding": {
-        "left": 8
-      }
-    },
-    "hovered_unshared_project": {
-      "guest_avatar_spacing": 4,
-      "height": 24,
-      "guest_avatar": {
-        "corner_radius": 8,
-        "width": 14
-      },
-      "name": {
-        "family": "Zed Mono",
-        "color": "#657b83",
-        "size": 14,
-        "margin": {
-          "right": 6
-        }
+        "left": 12,
+        "right": 12
       },
-      "padding": {
-        "left": 8
+      "background": "#eee8d5",
+      "hover": {
+        "background": "#93a1a11f"
       },
-      "corner_radius": 6
+      "active": {
+        "background": "#93a1a12e"
+      }
     }
   },
   "contact_finder": {

assets/themes/sulphurpool-dark.json 🔗

@@ -1029,9 +1029,7 @@
   "chat_panel": {
     "padding": {
       "top": 12,
-      "left": 12,
-      "bottom": 12,
-      "right": 12
+      "bottom": 12
     },
     "channel_name": {
       "family": "Zed Sans",
@@ -1248,9 +1246,7 @@
   "contacts_panel": {
     "padding": {
       "top": 12,
-      "left": 12,
-      "bottom": 12,
-      "right": 12
+      "bottom": 12
     },
     "user_query_editor": {
       "background": "#202746",
@@ -1278,33 +1274,61 @@
         "left": 8,
         "right": 8,
         "top": 4
+      },
+      "margin": {
+        "left": 12,
+        "right": 12
       }
     },
     "user_query_editor_height": 32,
     "add_contact_button": {
       "margin": {
-        "left": 6
+        "left": 6,
+        "right": 12
       },
       "color": "#dfe2f1",
       "button_width": 8,
       "icon_width": 8
     },
-    "row": {
-      "padding": {
-        "left": 8
-      }
-    },
     "row_height": 28,
-    "header": {
+    "section_icon_size": 8,
+    "header_row": {
       "family": "Zed Mono",
       "color": "#979db4",
       "size": 14,
       "margin": {
-        "top": 8
+        "top": 14
+      },
+      "padding": {
+        "left": 12,
+        "right": 12
+      },
+      "active": {
+        "family": "Zed Mono",
+        "color": "#dfe2f1",
+        "size": 14,
+        "background": "#5e66875c"
+      }
+    },
+    "contact_row": {
+      "padding": {
+        "left": 12,
+        "right": 12
+      },
+      "active": {
+        "background": "#5e66875c"
+      }
+    },
+    "tree_branch": {
+      "color": "#6b7394",
+      "width": 1,
+      "hover": {
+        "color": "#6b7394"
+      },
+      "active": {
+        "color": "#6b7394"
       }
     },
-    "tree_branch_color": "#6b7394",
-    "tree_branch_width": 1,
     "contact_avatar": {
       "corner_radius": 10,
       "width": 18
@@ -1313,10 +1337,11 @@
       "family": "Zed Mono",
       "color": "#dfe2f1",
       "size": 14,
-      "padding": {
+      "margin": {
         "left": 8
       }
     },
+    "contact_button_spacing": 8,
     "contact_button": {
       "background": "#293256",
       "color": "#dfe2f1",
@@ -1334,7 +1359,7 @@
       "button_width": 16,
       "corner_radius": 8
     },
-    "project": {
+    "shared_project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {
@@ -1343,38 +1368,32 @@
       },
       "name": {
         "family": "Zed Mono",
-        "color": "#898ea4",
+        "color": "#979db4",
         "size": 14,
         "margin": {
+          "left": 8,
           "right": 6
         }
       },
-      "padding": {
-        "left": 8
-      }
-    },
-    "shared_project": {
-      "guest_avatar_spacing": 4,
-      "height": 24,
-      "guest_avatar": {
-        "corner_radius": 8,
-        "width": 14
-      },
-      "name": {
-        "family": "Zed Mono",
-        "color": "#979db4",
-        "size": 14,
+      "guests": {
         "margin": {
-          "right": 6
+          "left": 8,
+          "right": 8
         }
       },
       "padding": {
-        "left": 8
+        "left": 12,
+        "right": 12
       },
       "background": "#293256",
-      "corner_radius": 6
+      "hover": {
+        "background": "#5e66873d"
+      },
+      "active": {
+        "background": "#5e66875c"
+      }
     },
-    "hovered_shared_project": {
+    "unshared_project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {
@@ -1386,53 +1405,27 @@
         "color": "#979db4",
         "size": 14,
         "margin": {
+          "left": 8,
           "right": 6
         }
       },
-      "padding": {
-        "left": 8
-      },
-      "background": "#5e66873d",
-      "corner_radius": 6
-    },
-    "unshared_project": {
-      "guest_avatar_spacing": 4,
-      "height": 24,
-      "guest_avatar": {
-        "corner_radius": 8,
-        "width": 14
-      },
-      "name": {
-        "family": "Zed Mono",
-        "color": "#898ea4",
-        "size": 14,
+      "guests": {
         "margin": {
-          "right": 6
+          "left": 8,
+          "right": 8
         }
       },
       "padding": {
-        "left": 8
-      }
-    },
-    "hovered_unshared_project": {
-      "guest_avatar_spacing": 4,
-      "height": 24,
-      "guest_avatar": {
-        "corner_radius": 8,
-        "width": 14
-      },
-      "name": {
-        "family": "Zed Mono",
-        "color": "#898ea4",
-        "size": 14,
-        "margin": {
-          "right": 6
-        }
+        "left": 12,
+        "right": 12
       },
-      "padding": {
-        "left": 8
+      "background": "#293256",
+      "hover": {
+        "background": "#5e66873d"
       },
-      "corner_radius": 6
+      "active": {
+        "background": "#5e66875c"
+      }
     }
   },
   "contact_finder": {

assets/themes/sulphurpool-light.json 🔗

@@ -1029,9 +1029,7 @@
   "chat_panel": {
     "padding": {
       "top": 12,
-      "left": 12,
-      "bottom": 12,
-      "right": 12
+      "bottom": 12
     },
     "channel_name": {
       "family": "Zed Sans",
@@ -1248,9 +1246,7 @@
   "contacts_panel": {
     "padding": {
       "top": 12,
-      "left": 12,
-      "bottom": 12,
-      "right": 12
+      "bottom": 12
     },
     "user_query_editor": {
       "background": "#f5f7ff",
@@ -1278,33 +1274,61 @@
         "left": 8,
         "right": 8,
         "top": 4
+      },
+      "margin": {
+        "left": 12,
+        "right": 12
       }
     },
     "user_query_editor_height": 32,
     "add_contact_button": {
       "margin": {
-        "left": 6
+        "left": 6,
+        "right": 12
       },
       "color": "#293256",
       "button_width": 8,
       "icon_width": 8
     },
-    "row": {
-      "padding": {
-        "left": 8
-      }
-    },
     "row_height": 28,
-    "header": {
+    "section_icon_size": 8,
+    "header_row": {
       "family": "Zed Mono",
       "color": "#5e6687",
       "size": 14,
       "margin": {
-        "top": 8
+        "top": 14
+      },
+      "padding": {
+        "left": 12,
+        "right": 12
+      },
+      "active": {
+        "family": "Zed Mono",
+        "color": "#293256",
+        "size": 14,
+        "background": "#979db42e"
+      }
+    },
+    "contact_row": {
+      "padding": {
+        "left": 12,
+        "right": 12
+      },
+      "active": {
+        "background": "#979db42e"
+      }
+    },
+    "tree_branch": {
+      "color": "#898ea4",
+      "width": 1,
+      "hover": {
+        "color": "#898ea4"
+      },
+      "active": {
+        "color": "#898ea4"
       }
     },
-    "tree_branch_color": "#898ea4",
-    "tree_branch_width": 1,
     "contact_avatar": {
       "corner_radius": 10,
       "width": 18
@@ -1313,10 +1337,11 @@
       "family": "Zed Mono",
       "color": "#293256",
       "size": 14,
-      "padding": {
+      "margin": {
         "left": 8
       }
     },
+    "contact_button_spacing": 8,
     "contact_button": {
       "background": "#dfe2f1",
       "color": "#293256",
@@ -1334,7 +1359,7 @@
       "button_width": 16,
       "corner_radius": 8
     },
-    "project": {
+    "shared_project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {
@@ -1343,38 +1368,32 @@
       },
       "name": {
         "family": "Zed Mono",
-        "color": "#6b7394",
+        "color": "#5e6687",
         "size": 14,
         "margin": {
+          "left": 8,
           "right": 6
         }
       },
-      "padding": {
-        "left": 8
-      }
-    },
-    "shared_project": {
-      "guest_avatar_spacing": 4,
-      "height": 24,
-      "guest_avatar": {
-        "corner_radius": 8,
-        "width": 14
-      },
-      "name": {
-        "family": "Zed Mono",
-        "color": "#5e6687",
-        "size": 14,
+      "guests": {
         "margin": {
-          "right": 6
+          "left": 8,
+          "right": 8
         }
       },
       "padding": {
-        "left": 8
+        "left": 12,
+        "right": 12
       },
       "background": "#dfe2f1",
-      "corner_radius": 6
+      "hover": {
+        "background": "#979db41f"
+      },
+      "active": {
+        "background": "#979db42e"
+      }
     },
-    "hovered_shared_project": {
+    "unshared_project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {
@@ -1386,53 +1405,27 @@
         "color": "#5e6687",
         "size": 14,
         "margin": {
+          "left": 8,
           "right": 6
         }
       },
-      "padding": {
-        "left": 8
-      },
-      "background": "#979db41f",
-      "corner_radius": 6
-    },
-    "unshared_project": {
-      "guest_avatar_spacing": 4,
-      "height": 24,
-      "guest_avatar": {
-        "corner_radius": 8,
-        "width": 14
-      },
-      "name": {
-        "family": "Zed Mono",
-        "color": "#6b7394",
-        "size": 14,
+      "guests": {
         "margin": {
-          "right": 6
+          "left": 8,
+          "right": 8
         }
       },
       "padding": {
-        "left": 8
-      }
-    },
-    "hovered_unshared_project": {
-      "guest_avatar_spacing": 4,
-      "height": 24,
-      "guest_avatar": {
-        "corner_radius": 8,
-        "width": 14
-      },
-      "name": {
-        "family": "Zed Mono",
-        "color": "#6b7394",
-        "size": 14,
-        "margin": {
-          "right": 6
-        }
+        "left": 12,
+        "right": 12
       },
-      "padding": {
-        "left": 8
+      "background": "#dfe2f1",
+      "hover": {
+        "background": "#979db41f"
       },
-      "corner_radius": 6
+      "active": {
+        "background": "#979db42e"
+      }
     }
   },
   "contact_finder": {

crates/client/src/http.rs 🔗

@@ -8,6 +8,7 @@ pub use isahc::{
     http::{Method, Uri},
     Error,
 };
+use smol::future::FutureExt;
 use std::sync::Arc;
 pub use url::Url;
 
@@ -23,18 +24,19 @@ pub trait HttpClient: Send + Sync {
         body: AsyncBody,
         follow_redirects: bool,
     ) -> BoxFuture<'a, Result<Response, Error>> {
-        self.send(
-            isahc::Request::builder()
-                .redirect_policy(if follow_redirects {
-                    RedirectPolicy::Follow
-                } else {
-                    RedirectPolicy::None
-                })
-                .method(Method::GET)
-                .uri(uri)
-                .body(body)
-                .unwrap(),
-        )
+        let request = isahc::Request::builder()
+            .redirect_policy(if follow_redirects {
+                RedirectPolicy::Follow
+            } else {
+                RedirectPolicy::None
+            })
+            .method(Method::GET)
+            .uri(uri)
+            .body(body);
+        match request {
+            Ok(request) => self.send(request),
+            Err(error) => async move { Err(error.into()) }.boxed(),
+        }
     }
 }
 

crates/client/src/test.rs 🔗

@@ -41,12 +41,14 @@ impl FakeServer {
         Arc::get_mut(client)
             .unwrap()
             .override_authenticate({
-                let state = server.state.clone();
+                let state = Arc::downgrade(&server.state);
                 move |cx| {
-                    let mut state = state.lock();
-                    state.auth_count += 1;
-                    let access_token = state.access_token.to_string();
+                    let state = state.clone();
                     cx.spawn(move |_| async move {
+                        let state = state.upgrade().ok_or_else(|| anyhow!("server dropped"))?;
+                        let mut state = state.lock();
+                        state.auth_count += 1;
+                        let access_token = state.access_token.to_string();
                         Ok(Credentials {
                             user_id: client_user_id,
                             access_token,
@@ -55,21 +57,23 @@ impl FakeServer {
                 }
             })
             .override_establish_connection({
-                let peer = server.peer.clone();
-                let state = server.state.clone();
+                let peer = Arc::downgrade(&server.peer).clone();
+                let state = Arc::downgrade(&server.state);
                 move |credentials, cx| {
                     let peer = peer.clone();
                     let state = state.clone();
                     let credentials = credentials.clone();
                     cx.spawn(move |cx| async move {
-                        assert_eq!(credentials.user_id, client_user_id);
-
+                        let state = state.upgrade().ok_or_else(|| anyhow!("server dropped"))?;
+                        let peer = peer.upgrade().ok_or_else(|| anyhow!("server dropped"))?;
                         if state.lock().forbid_connections {
                             Err(EstablishConnectionError::Other(anyhow!(
                                 "server is forbidding connections"
                             )))?
                         }
 
+                        assert_eq!(credentials.user_id, client_user_id);
+
                         if credentials.access_token != state.lock().access_token.to_string() {
                             Err(EstablishConnectionError::Unauthorized)?
                         }

crates/collab/src/rpc.rs 🔗

@@ -5016,13 +5016,11 @@ mod tests {
         cx_c: &mut TestAppContext,
     ) {
         cx_a.foreground().forbid_parking();
-        let lang_registry = Arc::new(LanguageRegistry::test());
-        let fs = FakeFs::new(cx_a.background());
 
         // Connect to a server as 3 clients.
         let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
-        let client_a = server.create_client(cx_a, "user_a").await;
-        let client_b = server.create_client(cx_b, "user_b").await;
+        let mut client_a = server.create_client(cx_a, "user_a").await;
+        let mut client_b = server.create_client(cx_b, "user_b").await;
         let client_c = server.create_client(cx_c, "user_c").await;
         server
             .make_contacts(vec![
@@ -5046,27 +5044,10 @@ mod tests {
             });
         }
 
-        // Share a worktree as client A.
+        // Share a project as client A.
+        let fs = FakeFs::new(cx_a.background());
         fs.create_dir(Path::new("/a")).await.unwrap();
-
-        let project_a = cx_a.update(|cx| {
-            Project::local(
-                client_a.clone(),
-                client_a.user_store.clone(),
-                lang_registry.clone(),
-                fs.clone(),
-                cx,
-            )
-        });
-        let (worktree_a, _) = project_a
-            .update(cx_a, |p, cx| {
-                p.find_or_create_local_worktree("/a", true, cx)
-            })
-            .await
-            .unwrap();
-        worktree_a
-            .read_with(cx_a, |tree, _| tree.as_local().unwrap().scan_complete())
-            .await;
+        let (project_a, _) = client_a.build_local_project(fs, "/a", cx_a).await;
 
         deterministic.run_until_parked();
         for (client, cx) in [(&client_a, &cx_a), (&client_b, &cx_b), (&client_c, &cx_c)] {
@@ -5104,16 +5085,7 @@ mod tests {
             });
         }
 
-        let _project_b = Project::remote(
-            project_id,
-            client_b.clone(),
-            client_b.user_store.clone(),
-            lang_registry.clone(),
-            fs.clone(),
-            &mut cx_b.to_async(),
-        )
-        .await
-        .unwrap();
+        let _project_b = client_b.build_remote_project(project_id, cx_b).await;
 
         deterministic.run_until_parked();
         for (client, cx) in [(&client_a, &cx_a), (&client_b, &cx_b), (&client_c, &cx_c)] {
@@ -5129,12 +5101,32 @@ mod tests {
             });
         }
 
+        // Add a local project as client B
+        let fs = FakeFs::new(cx_b.background());
+        fs.create_dir(Path::new("/b")).await.unwrap();
+        let (_project_b, _) = client_b.build_local_project(fs, "/b", cx_a).await;
+
+        deterministic.run_until_parked();
+        for (client, cx) in [(&client_a, &cx_a), (&client_b, &cx_b), (&client_c, &cx_c)] {
+            client.user_store.read_with(*cx, |store, _| {
+                assert_eq!(
+                    contacts(store),
+                    [
+                        ("user_a", true, vec![("a", true, vec!["user_b"])]),
+                        ("user_b", true, vec![("b", false, vec![])]),
+                        ("user_c", true, vec![])
+                    ]
+                )
+            });
+        }
+
         project_a
             .condition(&cx_a, |project, _| {
                 project.collaborators().contains_key(&client_b.peer_id)
             })
             .await;
 
+        client_a.project.take();
         cx_a.update(move |_| drop(project_a));
         deterministic.run_until_parked();
         for (client, cx) in [(&client_a, &cx_a), (&client_b, &cx_b), (&client_c, &cx_c)] {
@@ -5143,7 +5135,7 @@ mod tests {
                     contacts(store),
                     [
                         ("user_a", true, vec![]),
-                        ("user_b", true, vec![]),
+                        ("user_b", true, vec![("b", false, vec![])]),
                         ("user_c", true, vec![])
                     ]
                 )
@@ -5159,7 +5151,7 @@ mod tests {
                     contacts(store),
                     [
                         ("user_a", true, vec![]),
-                        ("user_b", true, vec![]),
+                        ("user_b", true, vec![("b", false, vec![])]),
                         ("user_c", false, vec![])
                     ]
                 )
@@ -5182,7 +5174,7 @@ mod tests {
                     contacts(store),
                     [
                         ("user_a", true, vec![]),
-                        ("user_b", true, vec![]),
+                        ("user_b", true, vec![("b", false, vec![])]),
                         ("user_c", true, vec![])
                     ]
                 )
@@ -5194,7 +5186,7 @@ mod tests {
                 .contacts()
                 .iter()
                 .map(|contact| {
-                    let worktrees = contact
+                    let projects = contact
                         .projects
                         .iter()
                         .map(|p| {
@@ -5205,11 +5197,7 @@ mod tests {
                             )
                         })
                         .collect();
-                    (
-                        contact.user.github_login.as_str(),
-                        contact.online,
-                        worktrees,
-                    )
+                    (contact.user.github_login.as_str(), contact.online, projects)
                 })
                 .collect()
         }

crates/collab/src/rpc/store.rs 🔗

@@ -272,73 +272,30 @@ impl Store {
         let mut metadata = Vec::new();
         for project_id in project_ids {
             if let Some(project) = self.projects.get(&project_id) {
-                metadata.push(proto::ProjectMetadata {
-                    id: project_id,
-                    is_shared: project.share.is_some(),
-                    worktree_root_names: project
-                        .worktrees
-                        .values()
-                        .map(|worktree| worktree.root_name.clone())
-                        .collect(),
-                    guests: project
-                        .share
-                        .iter()
-                        .flat_map(|share| {
-                            share.guests.values().map(|(_, user_id)| user_id.to_proto())
-                        })
-                        .collect(),
-                });
+                if project.host_user_id == user_id {
+                    metadata.push(proto::ProjectMetadata {
+                        id: project_id,
+                        is_shared: project.share.is_some(),
+                        worktree_root_names: project
+                            .worktrees
+                            .values()
+                            .map(|worktree| worktree.root_name.clone())
+                            .collect(),
+                        guests: project
+                            .share
+                            .iter()
+                            .flat_map(|share| {
+                                share.guests.values().map(|(_, user_id)| user_id.to_proto())
+                            })
+                            .collect(),
+                    });
+                }
             }
         }
 
         metadata
     }
 
-    // pub fn contacts_for_user(&self, user_id: UserId) -> Vec<proto::Contact> {
-    //     let mut contacts = HashMap::default();
-    //     for project_id in self
-    //         .visible_projects_by_user_id
-    //         .get(&user_id)
-    //         .unwrap_or(&HashSet::default())
-    //     {
-    //         let project = &self.projects[project_id];
-
-    //         let mut guests = HashSet::default();
-    //         if let Ok(share) = project.share() {
-    //             for guest_connection_id in share.guests.keys() {
-    //                 if let Ok(user_id) = self.user_id_for_connection(*guest_connection_id) {
-    //                     guests.insert(user_id.to_proto());
-    //                 }
-    //             }
-    //         }
-
-    //         if let Ok(host_user_id) = self.user_id_for_connection(project.host_connection_id) {
-    //             let mut worktree_root_names = project
-    //                 .worktrees
-    //                 .values()
-    //                 .filter(|worktree| worktree.visible)
-    //                 .map(|worktree| worktree.root_name.clone())
-    //                 .collect::<Vec<_>>();
-    //             worktree_root_names.sort_unstable();
-    //             contacts
-    //                 .entry(host_user_id)
-    //                 .or_insert_with(|| proto::Contact {
-    //                     user_id: host_user_id.to_proto(),
-    //                     projects: Vec::new(),
-    //                 })
-    //                 .projects
-    //                 .push(proto::ProjectMetadata {
-    //                     id: *project_id,
-    //                     worktree_root_names,
-    //                     is_shared: project.share.is_some(),
-    //                     guests: guests.into_iter().collect(),
-    //                 });
-    //         }
-    //     }
-
-    //     contacts.into_values().collect()
-    // }
-
     pub fn register_project(
         &mut self,
         host_connection_id: ConnectionId,

crates/contacts_panel/Cargo.toml 🔗

@@ -21,3 +21,8 @@ futures = "0.3"
 log = "0.4"
 postage = { version = "0.4.1", features = ["futures-traits"] }
 serde = { version = "1", features = ["derive"] }
+
+[dev-dependencies]
+language = { path = "../language", features = ["test-support"] }
+project = { path = "../project", features = ["test-support"] }
+workspace = { path = "../workspace", features = ["test-support"] }

crates/contacts_panel/src/contacts_panel.rs 🔗

@@ -8,7 +8,7 @@ use fuzzy::{match_strings, StringMatchCandidate};
 use gpui::{
     elements::*,
     geometry::{rect::RectF, vector::vec2f},
-    impl_actions,
+    impl_actions, impl_internal_actions,
     platform::CursorStyle,
     AppContext, Element, ElementBox, Entity, LayoutContext, ModelHandle, MutableAppContext,
     RenderContext, Subscription, View, ViewContext, ViewHandle, WeakViewHandle,
@@ -17,27 +17,47 @@ use serde::Deserialize;
 use settings::Settings;
 use std::sync::Arc;
 use theme::IconButton;
-use workspace::{sidebar::SidebarItem, AppState, JoinProject, Workspace};
+use workspace::{
+    menu::{Confirm, SelectNext, SelectPrev},
+    sidebar::SidebarItem,
+    AppState, JoinProject, Workspace,
+};
 
 impl_actions!(
     contacts_panel,
     [RequestContact, RemoveContact, RespondToContactRequest]
 );
 
-#[derive(Debug)]
+impl_internal_actions!(contacts_panel, [ToggleExpanded]);
+
+#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
+enum Section {
+    Requests,
+    Online,
+    Offline,
+}
+
+#[derive(Clone, Debug)]
 enum ContactEntry {
-    Header(&'static str),
+    Header(Section),
     IncomingRequest(Arc<User>),
     OutgoingRequest(Arc<User>),
     Contact(Arc<Contact>),
+    ContactProject(Arc<Contact>, usize),
 }
 
+#[derive(Clone)]
+struct ToggleExpanded(Section);
+
 pub struct ContactsPanel {
     entries: Vec<ContactEntry>,
     match_candidates: Vec<StringMatchCandidate>,
     list_state: ListState,
     user_store: ModelHandle<UserStore>,
     filter_editor: ViewHandle<Editor>,
+    collapsed_sections: Vec<Section>,
+    selection: Option<usize>,
+    app_state: Arc<AppState>,
     _maintain_contacts: Subscription,
 }
 
@@ -60,6 +80,10 @@ pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(ContactsPanel::remove_contact);
     cx.add_action(ContactsPanel::respond_to_contact_request);
     cx.add_action(ContactsPanel::clear_filter);
+    cx.add_action(ContactsPanel::select_next);
+    cx.add_action(ContactsPanel::select_prev);
+    cx.add_action(ContactsPanel::confirm);
+    cx.add_action(ContactsPanel::toggle_expanded);
 }
 
 impl ContactsPanel {
@@ -68,7 +92,7 @@ impl ContactsPanel {
         workspace: WeakViewHandle<Workspace>,
         cx: &mut ViewContext<Self>,
     ) -> Self {
-        let user_query_editor = cx.add_view(|cx| {
+        let filter_editor = cx.add_view(|cx| {
             let mut editor = Editor::single_line(
                 Some(|theme| theme.contacts_panel.user_query_editor.clone()),
                 cx,
@@ -77,9 +101,19 @@ impl ContactsPanel {
             editor
         });
 
-        cx.subscribe(&user_query_editor, |this, _, event, cx| {
+        cx.subscribe(&filter_editor, |this, _, event, cx| {
             if let editor::Event::BufferEdited = event {
-                this.update_entries(cx)
+                let query = this.filter_editor.read(cx).text(cx);
+                if !query.is_empty() {
+                    this.selection.take();
+                }
+                this.update_entries(cx);
+                if !query.is_empty() {
+                    this.selection = this
+                        .entries
+                        .iter()
+                        .position(|entry| !matches!(entry, ContactEntry::Header(_)));
+                }
             }
         })
         .detach();
@@ -116,24 +150,19 @@ impl ContactsPanel {
                     let theme = &theme.contacts_panel;
                     let current_user_id =
                         this.user_store.read(cx).current_user().map(|user| user.id);
+                    let is_selected = this.selection == Some(ix);
 
                     match &this.entries[ix] {
-                        ContactEntry::Header(text) => {
-                            Label::new(text.to_string(), theme.header.text.clone())
-                                .contained()
-                                .aligned()
-                                .left()
-                                .constrained()
-                                .with_height(theme.row_height)
-                                .contained()
-                                .with_style(theme.header.container)
-                                .boxed()
+                        ContactEntry::Header(section) => {
+                            let is_collapsed = this.collapsed_sections.contains(&section);
+                            Self::render_header(*section, theme, is_selected, is_collapsed, cx)
                         }
                         ContactEntry::IncomingRequest(user) => Self::render_contact_request(
                             user.clone(),
                             this.user_store.clone(),
                             theme,
                             true,
+                            is_selected,
                             cx,
                         ),
                         ContactEntry::OutgoingRequest(user) => Self::render_contact_request(
@@ -141,200 +170,262 @@ impl ContactsPanel {
                             this.user_store.clone(),
                             theme,
                             false,
+                            is_selected,
                             cx,
                         ),
-                        ContactEntry::Contact(contact) => Self::render_contact(
-                            contact.clone(),
-                            current_user_id,
-                            app_state.clone(),
-                            theme,
-                            cx,
-                        ),
+                        ContactEntry::Contact(contact) => {
+                            Self::render_contact(contact.clone(), theme, is_selected)
+                        }
+                        ContactEntry::ContactProject(contact, project_ix) => {
+                            let is_last_project_for_contact =
+                                this.entries.get(ix + 1).map_or(true, |next| {
+                                    if let ContactEntry::ContactProject(next_contact, _) = next {
+                                        next_contact.user.id != contact.user.id
+                                    } else {
+                                        true
+                                    }
+                                });
+                            Self::render_contact_project(
+                                contact.clone(),
+                                current_user_id,
+                                *project_ix,
+                                app_state.clone(),
+                                theme,
+                                is_last_project_for_contact,
+                                is_selected,
+                                cx,
+                            )
+                        }
                     }
                 }
             }),
+            selection: None,
+            collapsed_sections: Default::default(),
             entries: Default::default(),
             match_candidates: Default::default(),
-            filter_editor: user_query_editor,
+            filter_editor,
             _maintain_contacts: cx
                 .observe(&app_state.user_store, |this, _, cx| this.update_entries(cx)),
             user_store: app_state.user_store.clone(),
+            app_state,
         };
         this.update_entries(cx);
         this
     }
 
+    fn render_header(
+        section: Section,
+        theme: &theme::ContactsPanel,
+        is_selected: bool,
+        is_collapsed: bool,
+        cx: &mut LayoutContext,
+    ) -> ElementBox {
+        enum Header {}
+
+        let header_style = theme.header_row.style_for(&Default::default(), is_selected);
+        let text = match section {
+            Section::Requests => "Requests",
+            Section::Online => "Online",
+            Section::Offline => "Offline",
+        };
+        let icon_size = theme.section_icon_size;
+        MouseEventHandler::new::<Header, _, _>(section as usize, cx, |_, _| {
+            Flex::row()
+                .with_child(
+                    Svg::new(if is_collapsed {
+                        "icons/disclosure-closed.svg"
+                    } else {
+                        "icons/disclosure-open.svg"
+                    })
+                    .with_color(header_style.text.color)
+                    .constrained()
+                    .with_max_width(icon_size)
+                    .with_max_height(icon_size)
+                    .aligned()
+                    .constrained()
+                    .with_width(icon_size)
+                    .boxed(),
+                )
+                .with_child(
+                    Label::new(text.to_string(), header_style.text.clone())
+                        .aligned()
+                        .left()
+                        .contained()
+                        .with_margin_left(theme.contact_username.container.margin.left)
+                        .flex(1., true)
+                        .boxed(),
+                )
+                .constrained()
+                .with_height(theme.row_height)
+                .contained()
+                .with_style(header_style.container)
+                .boxed()
+        })
+        .with_cursor_style(CursorStyle::PointingHand)
+        .on_click(move |_, cx| cx.dispatch_action(ToggleExpanded(section)))
+        .boxed()
+    }
+
     fn render_contact(
+        contact: Arc<Contact>,
+        theme: &theme::ContactsPanel,
+        is_selected: bool,
+    ) -> ElementBox {
+        Flex::row()
+            .with_children(contact.user.avatar.clone().map(|avatar| {
+                Image::new(avatar)
+                    .with_style(theme.contact_avatar)
+                    .aligned()
+                    .left()
+                    .boxed()
+            }))
+            .with_child(
+                Label::new(
+                    contact.user.github_login.clone(),
+                    theme.contact_username.text.clone(),
+                )
+                .contained()
+                .with_style(theme.contact_username.container)
+                .aligned()
+                .left()
+                .flex(1., true)
+                .boxed(),
+            )
+            .constrained()
+            .with_height(theme.row_height)
+            .contained()
+            .with_style(
+                *theme
+                    .contact_row
+                    .style_for(&Default::default(), is_selected),
+            )
+            .boxed()
+    }
+
+    fn render_contact_project(
         contact: Arc<Contact>,
         current_user_id: Option<u64>,
+        project_ix: usize,
         app_state: Arc<AppState>,
         theme: &theme::ContactsPanel,
+        is_last_project: bool,
+        is_selected: bool,
         cx: &mut LayoutContext,
     ) -> ElementBox {
-        let project_count = contact.non_empty_projects().count();
+        let project = &contact.projects[project_ix];
+        let project_id = project.id;
+        let is_host = Some(contact.user.id) == current_user_id;
+        let is_guest = !is_host
+            && project
+                .guests
+                .iter()
+                .any(|guest| Some(guest.id) == current_user_id);
+        let is_shared = project.is_shared;
+
         let font_cache = cx.font_cache();
-        let line_height = theme.unshared_project.name.text.line_height(font_cache);
-        let cap_height = theme.unshared_project.name.text.cap_height(font_cache);
-        let baseline_offset = theme.unshared_project.name.text.baseline_offset(font_cache)
-            + (theme.unshared_project.height - line_height) / 2.;
-        let tree_branch_width = theme.tree_branch_width;
-        let tree_branch_color = theme.tree_branch_color;
         let host_avatar_height = theme
             .contact_avatar
             .width
             .or(theme.contact_avatar.height)
             .unwrap_or(0.);
+        let row = &theme.unshared_project_row.default;
+        let tree_branch = theme.tree_branch.clone();
+        let line_height = row.name.text.line_height(font_cache);
+        let cap_height = row.name.text.cap_height(font_cache);
+        let baseline_offset =
+            row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
 
-        Flex::column()
-            .with_child(
-                Flex::row()
-                    .with_children(contact.user.avatar.clone().map(|avatar| {
+        MouseEventHandler::new::<JoinProject, _, _>(project_id as usize, cx, |mouse_state, _| {
+            let tree_branch = *tree_branch.style_for(mouse_state, is_selected);
+            let row = if project.is_shared {
+                &theme.shared_project_row
+            } else {
+                &theme.unshared_project_row
+            }
+            .style_for(mouse_state, is_selected);
+
+            Flex::row()
+                .with_child(
+                    Canvas::new(move |bounds, _, cx| {
+                        let start_x =
+                            bounds.min_x() + (bounds.width() / 2.) - (tree_branch.width / 2.);
+                        let end_x = bounds.max_x();
+                        let start_y = bounds.min_y();
+                        let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
+
+                        cx.scene.push_quad(gpui::Quad {
+                            bounds: RectF::from_points(
+                                vec2f(start_x, start_y),
+                                vec2f(
+                                    start_x + tree_branch.width,
+                                    if is_last_project {
+                                        end_y
+                                    } else {
+                                        bounds.max_y()
+                                    },
+                                ),
+                            ),
+                            background: Some(tree_branch.color),
+                            border: gpui::Border::default(),
+                            corner_radius: 0.,
+                        });
+                        cx.scene.push_quad(gpui::Quad {
+                            bounds: RectF::from_points(
+                                vec2f(start_x, end_y),
+                                vec2f(end_x, end_y + tree_branch.width),
+                            ),
+                            background: Some(tree_branch.color),
+                            border: gpui::Border::default(),
+                            corner_radius: 0.,
+                        });
+                    })
+                    .constrained()
+                    .with_width(host_avatar_height)
+                    .boxed(),
+                )
+                .with_child(
+                    Label::new(
+                        project.worktree_root_names.join(", "),
+                        row.name.text.clone(),
+                    )
+                    .aligned()
+                    .left()
+                    .contained()
+                    .with_style(row.name.container)
+                    .flex(1., false)
+                    .boxed(),
+                )
+                .with_children(project.guests.iter().filter_map(|participant| {
+                    participant.avatar.clone().map(|avatar| {
                         Image::new(avatar)
-                            .with_style(theme.contact_avatar)
+                            .with_style(row.guest_avatar)
                             .aligned()
                             .left()
+                            .contained()
+                            .with_margin_right(row.guest_avatar_spacing)
                             .boxed()
-                    }))
-                    .with_child(
-                        Label::new(
-                            contact.user.github_login.clone(),
-                            theme.contact_username.text.clone(),
-                        )
-                        .contained()
-                        .with_style(theme.contact_username.container)
-                        .aligned()
-                        .left()
-                        .boxed(),
-                    )
-                    .constrained()
-                    .with_height(theme.row_height)
-                    .boxed(),
-            )
-            .with_children(
-                contact
-                    .non_empty_projects()
-                    .enumerate()
-                    .map(|(ix, project)| {
-                        let project_id = project.id;
-                        Flex::row()
-                            .with_child(
-                                Canvas::new(move |bounds, _, cx| {
-                                    let start_x = bounds.min_x() + (bounds.width() / 2.)
-                                        - (tree_branch_width / 2.);
-                                    let end_x = bounds.max_x();
-                                    let start_y = bounds.min_y();
-                                    let end_y =
-                                        bounds.min_y() + baseline_offset - (cap_height / 2.);
-
-                                    cx.scene.push_quad(gpui::Quad {
-                                        bounds: RectF::from_points(
-                                            vec2f(start_x, start_y),
-                                            vec2f(
-                                                start_x + tree_branch_width,
-                                                if ix + 1 == project_count {
-                                                    end_y
-                                                } else {
-                                                    bounds.max_y()
-                                                },
-                                            ),
-                                        ),
-                                        background: Some(tree_branch_color),
-                                        border: gpui::Border::default(),
-                                        corner_radius: 0.,
-                                    });
-                                    cx.scene.push_quad(gpui::Quad {
-                                        bounds: RectF::from_points(
-                                            vec2f(start_x, end_y),
-                                            vec2f(end_x, end_y + tree_branch_width),
-                                        ),
-                                        background: Some(tree_branch_color),
-                                        border: gpui::Border::default(),
-                                        corner_radius: 0.,
-                                    });
-                                })
-                                .constrained()
-                                .with_width(host_avatar_height)
-                                .boxed(),
-                            )
-                            .with_child({
-                                let is_host = Some(contact.user.id) == current_user_id;
-                                let is_guest = !is_host
-                                    && project
-                                        .guests
-                                        .iter()
-                                        .any(|guest| Some(guest.id) == current_user_id);
-                                let is_shared = project.is_shared;
-                                let app_state = app_state.clone();
-
-                                MouseEventHandler::new::<ContactsPanel, _, _>(
-                                    project_id as usize,
-                                    cx,
-                                    |mouse_state, _| {
-                                        let style = match (project.is_shared, mouse_state.hovered) {
-                                            (false, false) => &theme.unshared_project,
-                                            (false, true) => &theme.hovered_unshared_project,
-                                            (true, false) => &theme.shared_project,
-                                            (true, true) => &theme.hovered_shared_project,
-                                        };
-
-                                        Flex::row()
-                                            .with_child(
-                                                Label::new(
-                                                    project.worktree_root_names.join(", "),
-                                                    style.name.text.clone(),
-                                                )
-                                                .aligned()
-                                                .left()
-                                                .contained()
-                                                .with_style(style.name.container)
-                                                .boxed(),
-                                            )
-                                            .with_children(project.guests.iter().filter_map(
-                                                |participant| {
-                                                    participant.avatar.clone().map(|avatar| {
-                                                        Image::new(avatar)
-                                                            .with_style(style.guest_avatar)
-                                                            .aligned()
-                                                            .left()
-                                                            .contained()
-                                                            .with_margin_right(
-                                                                style.guest_avatar_spacing,
-                                                            )
-                                                            .boxed()
-                                                    })
-                                                },
-                                            ))
-                                            .contained()
-                                            .with_style(style.container)
-                                            .constrained()
-                                            .with_height(style.height)
-                                            .boxed()
-                                    },
-                                )
-                                .with_cursor_style(if !is_host && is_shared {
-                                    CursorStyle::PointingHand
-                                } else {
-                                    CursorStyle::Arrow
-                                })
-                                .on_click(move |_, cx| {
-                                    if !is_host && !is_guest {
-                                        cx.dispatch_global_action(JoinProject {
-                                            project_id,
-                                            app_state: app_state.clone(),
-                                        });
-                                    }
-                                })
-                                .flex(1., true)
-                                .boxed()
-                            })
-                            .constrained()
-                            .with_height(theme.unshared_project.height)
-                            .boxed()
-                    }),
-            )
-            .contained()
-            .with_style(theme.row.clone())
-            .boxed()
+                    })
+                }))
+                .constrained()
+                .with_height(theme.row_height)
+                .contained()
+                .with_style(row.container)
+                .boxed()
+        })
+        .with_cursor_style(if !is_host && is_shared {
+            CursorStyle::PointingHand
+        } else {
+            CursorStyle::Arrow
+        })
+        .on_click(move |_, cx| {
+            if !is_host && !is_guest {
+                cx.dispatch_global_action(JoinProject {
+                    project_id,
+                    app_state: app_state.clone(),
+                });
+            }
+        })
+        .boxed()
     }
 
     fn render_contact_request(
@@ -342,6 +433,7 @@ impl ContactsPanel {
         user_store: ModelHandle<UserStore>,
         theme: &theme::ContactsPanel,
         is_incoming: bool,
+        is_selected: bool,
         cx: &mut LayoutContext,
     ) -> ElementBox {
         enum Decline {}
@@ -365,11 +457,13 @@ impl ContactsPanel {
                 .with_style(theme.contact_username.container)
                 .aligned()
                 .left()
+                .flex(1., true)
                 .boxed(),
             );
 
         let user_id = user.id;
         let is_contact_request_pending = user_store.read(cx).is_contact_request_pending(&user);
+        let button_spacing = theme.contact_button_spacing;
 
         if is_incoming {
             row.add_children([
@@ -381,7 +475,7 @@ impl ContactsPanel {
                     };
                     render_icon_button(button_style, "icons/decline.svg")
                         .aligned()
-                        .flex_float()
+                        // .flex_float()
                         .boxed()
                 })
                 .with_cursor_style(CursorStyle::PointingHand)
@@ -391,7 +485,9 @@ impl ContactsPanel {
                         accept: false,
                     })
                 })
-                .flex_float()
+                // .flex_float()
+                .contained()
+                .with_margin_right(button_spacing)
                 .boxed(),
                 MouseEventHandler::new::<Accept, _, _>(user.id as usize, cx, |mouse_state, _| {
                     let button_style = if is_contact_request_pending {
@@ -437,7 +533,11 @@ impl ContactsPanel {
         row.constrained()
             .with_height(theme.row_height)
             .contained()
-            .with_style(theme.row)
+            .with_style(
+                *theme
+                    .contact_row
+                    .style_for(&Default::default(), is_selected),
+            )
             .boxed()
     }
 
@@ -446,6 +546,7 @@ impl ContactsPanel {
         let query = self.filter_editor.read(cx).text(cx);
         let executor = cx.background().clone();
 
+        let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned());
         self.entries.clear();
 
         let mut request_entries = Vec::new();
@@ -471,13 +572,11 @@ impl ContactsPanel {
                 &Default::default(),
                 executor.clone(),
             ));
-            if !matches.is_empty() {
-                request_entries.extend(
-                    matches.iter().map(|mat| {
-                        ContactEntry::IncomingRequest(incoming[mat.candidate_id].clone())
-                    }),
-                );
-            }
+            request_entries.extend(
+                matches
+                    .iter()
+                    .map(|mat| ContactEntry::IncomingRequest(incoming[mat.candidate_id].clone())),
+            );
         }
 
         let outgoing = user_store.outgoing_contact_requests();
@@ -502,18 +601,18 @@ impl ContactsPanel {
                 &Default::default(),
                 executor.clone(),
             ));
-            if !matches.is_empty() {
-                request_entries.extend(
-                    matches.iter().map(|mat| {
-                        ContactEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())
-                    }),
-                );
-            }
+            request_entries.extend(
+                matches
+                    .iter()
+                    .map(|mat| ContactEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())),
+            );
         }
 
         if !request_entries.is_empty() {
-            self.entries.push(ContactEntry::Header("Requests"));
-            self.entries.append(&mut request_entries);
+            self.entries.push(ContactEntry::Header(Section::Requests));
+            if !self.collapsed_sections.contains(&Section::Requests) {
+                self.entries.append(&mut request_entries);
+            }
         }
 
         let contacts = user_store.contacts();
@@ -543,22 +642,39 @@ impl ContactsPanel {
                 .iter()
                 .partition::<Vec<_>, _>(|mat| contacts[mat.candidate_id].online);
 
-            if !online_contacts.is_empty() {
-                self.entries.push(ContactEntry::Header("Online"));
-                self.entries.extend(
-                    online_contacts
-                        .into_iter()
-                        .map(|mat| ContactEntry::Contact(contacts[mat.candidate_id].clone())),
-                );
+            for (matches, section) in [
+                (online_contacts, Section::Online),
+                (offline_contacts, Section::Offline),
+            ] {
+                if !matches.is_empty() {
+                    self.entries.push(ContactEntry::Header(section));
+                    if !self.collapsed_sections.contains(&section) {
+                        for mat in matches {
+                            let contact = &contacts[mat.candidate_id];
+                            self.entries.push(ContactEntry::Contact(contact.clone()));
+                            self.entries
+                                .extend(contact.projects.iter().enumerate().filter_map(
+                                    |(ix, project)| {
+                                        if project.worktree_root_names.is_empty() {
+                                            None
+                                        } else {
+                                            Some(ContactEntry::ContactProject(contact.clone(), ix))
+                                        }
+                                    },
+                                ));
+                        }
+                    }
+                }
             }
+        }
 
-            if !offline_contacts.is_empty() {
-                self.entries.push(ContactEntry::Header("Offline"));
-                self.entries.extend(
-                    offline_contacts
-                        .into_iter()
-                        .map(|mat| ContactEntry::Contact(contacts[mat.candidate_id].clone())),
-                );
+        if let Some(prev_selected_entry) = prev_selected_entry {
+            self.selection.take();
+            for (ix, entry) in self.entries.iter().enumerate() {
+                if *entry == prev_selected_entry {
+                    self.selection = Some(ix);
+                    break;
+                }
             }
         }
 
@@ -594,6 +710,60 @@ impl ContactsPanel {
         self.filter_editor
             .update(cx, |editor, cx| editor.set_text("", cx));
     }
+
+    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
+        if let Some(ix) = self.selection {
+            if self.entries.len() > ix + 1 {
+                self.selection = Some(ix + 1);
+            }
+        } else if !self.entries.is_empty() {
+            self.selection = Some(0);
+        }
+        cx.notify();
+        self.list_state.reset(self.entries.len());
+    }
+
+    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
+        if let Some(ix) = self.selection {
+            if ix > 0 {
+                self.selection = Some(ix - 1);
+            } else {
+                self.selection = None;
+            }
+        }
+        cx.notify();
+        self.list_state.reset(self.entries.len());
+    }
+
+    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
+        if let Some(selection) = self.selection {
+            if let Some(entry) = self.entries.get(selection) {
+                match entry {
+                    ContactEntry::Header(section) => {
+                        let section = *section;
+                        self.toggle_expanded(&ToggleExpanded(section), cx);
+                    }
+                    ContactEntry::ContactProject(contact, project_ix) => {
+                        cx.dispatch_global_action(JoinProject {
+                            project_id: contact.projects[*project_ix].id,
+                            app_state: self.app_state.clone(),
+                        })
+                    }
+                    _ => {}
+                }
+            }
+        }
+    }
+
+    fn toggle_expanded(&mut self, action: &ToggleExpanded, cx: &mut ViewContext<Self>) {
+        let section = action.0;
+        if let Some(ix) = self.collapsed_sections.iter().position(|s| *s == section) {
+            self.collapsed_sections.remove(ix);
+        } else {
+            self.collapsed_sections.push(section);
+        }
+        self.update_entries(cx);
+    }
 }
 
 impl SidebarItem for ContactsPanel {
@@ -604,6 +774,10 @@ impl SidebarItem for ContactsPanel {
             .incoming_contact_requests()
             .is_empty()
     }
+
+    fn contains_focused_view(&self, cx: &AppContext) -> bool {
+        self.filter_editor.is_focused(cx)
+    }
 }
 
 fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element {
@@ -671,4 +845,270 @@ impl View for ContactsPanel {
         .with_style(theme.container)
         .boxed()
     }
+
+    fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
+        cx.focus(&self.filter_editor);
+    }
+
+    fn keymap_context(&self, _: &gpui::AppContext) -> gpui::keymap::Context {
+        let mut cx = Self::default_keymap_context();
+        cx.set.insert("menu".into());
+        cx
+    }
+}
+
+impl PartialEq for ContactEntry {
+    fn eq(&self, other: &Self) -> bool {
+        match self {
+            ContactEntry::Header(section_1) => {
+                if let ContactEntry::Header(section_2) = other {
+                    return section_1 == section_2;
+                }
+            }
+            ContactEntry::IncomingRequest(user_1) => {
+                if let ContactEntry::IncomingRequest(user_2) = other {
+                    return user_1.id == user_2.id;
+                }
+            }
+            ContactEntry::OutgoingRequest(user_1) => {
+                if let ContactEntry::OutgoingRequest(user_2) = other {
+                    return user_1.id == user_2.id;
+                }
+            }
+            ContactEntry::Contact(contact_1) => {
+                if let ContactEntry::Contact(contact_2) = other {
+                    return contact_1.user.id == contact_2.user.id;
+                }
+            }
+            ContactEntry::ContactProject(contact_1, ix_1) => {
+                if let ContactEntry::ContactProject(contact_2, ix_2) = other {
+                    return contact_1.user.id == contact_2.user.id && ix_1 == ix_2;
+                }
+            }
+        }
+        false
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use client::{proto, test::FakeServer, ChannelList, Client};
+    use gpui::TestAppContext;
+    use language::LanguageRegistry;
+    use theme::ThemeRegistry;
+    use workspace::WorkspaceParams;
+
+    #[gpui::test]
+    async fn test_contact_panel(cx: &mut TestAppContext) {
+        let (app_state, server) = init(cx).await;
+        let workspace_params = cx.update(WorkspaceParams::test);
+        let workspace = cx.add_view(0, |cx| Workspace::new(&workspace_params, cx));
+        let panel = cx.add_view(0, |cx| {
+            ContactsPanel::new(app_state.clone(), workspace.downgrade(), cx)
+        });
+
+        let get_users_request = server.receive::<proto::GetUsers>().await.unwrap();
+        server
+            .respond(
+                get_users_request.receipt(),
+                proto::UsersResponse {
+                    users: [
+                        "user_zero",
+                        "user_one",
+                        "user_two",
+                        "user_three",
+                        "user_four",
+                        "user_five",
+                    ]
+                    .into_iter()
+                    .enumerate()
+                    .map(|(id, name)| proto::User {
+                        id: id as u64,
+                        github_login: name.to_string(),
+                        ..Default::default()
+                    })
+                    .collect(),
+                },
+            )
+            .await;
+
+        server.send(proto::UpdateContacts {
+            incoming_requests: vec![proto::IncomingContactRequest {
+                requester_id: 1,
+                should_notify: false,
+            }],
+            outgoing_requests: vec![2],
+            contacts: vec![
+                proto::Contact {
+                    user_id: 3,
+                    online: true,
+                    should_notify: false,
+                    projects: vec![proto::ProjectMetadata {
+                        id: 101,
+                        worktree_root_names: vec!["dir1".to_string()],
+                        is_shared: true,
+                        guests: vec![2],
+                    }],
+                },
+                proto::Contact {
+                    user_id: 4,
+                    online: true,
+                    should_notify: false,
+                    projects: vec![proto::ProjectMetadata {
+                        id: 102,
+                        worktree_root_names: vec!["dir2".to_string()],
+                        is_shared: true,
+                        guests: vec![2],
+                    }],
+                },
+                proto::Contact {
+                    user_id: 5,
+                    online: false,
+                    should_notify: false,
+                    projects: vec![],
+                },
+            ],
+            ..Default::default()
+        });
+
+        cx.foreground().run_until_parked();
+        assert_eq!(
+            render_to_strings(&panel, cx),
+            &[
+                "+",
+                "v Requests",
+                "  incoming user_one",
+                "  outgoing user_two",
+                "v Online",
+                "  user_four",
+                "    dir2",
+                "  user_three",
+                "    dir1",
+                "v Offline",
+                "  user_five",
+            ]
+        );
+
+        panel.update(cx, |panel, cx| {
+            panel
+                .filter_editor
+                .update(cx, |editor, cx| editor.set_text("f", cx))
+        });
+        cx.foreground().run_until_parked();
+        assert_eq!(
+            render_to_strings(&panel, cx),
+            &[
+                "+",
+                "v Online",
+                "  user_four  <=== selected",
+                "    dir2",
+                "v Offline",
+                "  user_five",
+            ]
+        );
+
+        panel.update(cx, |panel, cx| {
+            panel.select_next(&Default::default(), cx);
+        });
+        assert_eq!(
+            render_to_strings(&panel, cx),
+            &[
+                "+",
+                "v Online",
+                "  user_four",
+                "    dir2  <=== selected",
+                "v Offline",
+                "  user_five",
+            ]
+        );
+
+        panel.update(cx, |panel, cx| {
+            panel.select_next(&Default::default(), cx);
+        });
+        assert_eq!(
+            render_to_strings(&panel, cx),
+            &[
+                "+",
+                "v Online",
+                "  user_four",
+                "    dir2",
+                "v Offline  <=== selected",
+                "  user_five",
+            ]
+        );
+    }
+
+    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) {
+                            ">"
+                        } 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(", ")
+                        )
+                    }
+                };
+
+                if panel.selection == Some(ix) {
+                    string.push_str("  <=== selected");
+                }
+
+                string
+            }));
+            entries
+        })
+    }
+
+    async fn init(cx: &mut TestAppContext) -> (Arc<AppState>, FakeServer) {
+        cx.update(|cx| cx.set_global(Settings::test(cx)));
+        let themes = ThemeRegistry::new((), cx.font_cache());
+        let fs = project::FakeFs::new(cx.background().clone());
+        let languages = Arc::new(LanguageRegistry::test());
+        let http_client = client::test::FakeHttpClient::with_404_response();
+        let mut client = Client::new(http_client.clone());
+        let server = FakeServer::for_client(100, &mut client, &cx).await;
+        let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx));
+        let channel_list =
+            cx.add_model(|cx| ChannelList::new(user_store.clone(), client.clone(), cx));
+
+        let get_channels = server.receive::<proto::GetChannels>().await.unwrap();
+        server
+            .respond(get_channels.receipt(), Default::default())
+            .await;
+
+        (
+            Arc::new(AppState {
+                languages,
+                themes,
+                client,
+                user_store: user_store.clone(),
+                fs,
+                channel_list,
+                build_window_options: || unimplemented!(),
+                build_workspace: |_, _, _| unimplemented!(),
+            }),
+            server,
+        )
+    }
 }

crates/theme/src/theme.rs 🔗

@@ -246,22 +246,27 @@ pub struct CommandPalette {
 pub struct ContactsPanel {
     #[serde(flatten)]
     pub container: ContainerStyle,
-    pub header: ContainedText,
     pub user_query_editor: FieldEditor,
     pub user_query_editor_height: f32,
     pub add_contact_button: IconButton,
-    pub row: ContainerStyle,
+    pub header_row: Interactive<ContainedText>,
+    pub contact_row: Interactive<ContainerStyle>,
+    pub shared_project_row: Interactive<ProjectRow>,
+    pub unshared_project_row: Interactive<ProjectRow>,
     pub row_height: f32,
     pub contact_avatar: ImageStyle,
     pub contact_username: ContainedText,
     pub contact_button: Interactive<IconButton>,
+    pub contact_button_spacing: f32,
     pub disabled_contact_button: IconButton,
-    pub tree_branch_width: f32,
-    pub tree_branch_color: Color,
-    pub shared_project: ProjectRow,
-    pub hovered_shared_project: ProjectRow,
-    pub unshared_project: ProjectRow,
-    pub hovered_unshared_project: ProjectRow,
+    pub tree_branch: Interactive<TreeBranch>,
+    pub section_icon_size: f32,
+}
+
+#[derive(Deserialize, Default, Clone, Copy)]
+pub struct TreeBranch {
+    pub width: f32,
+    pub color: Color,
 }
 
 #[derive(Deserialize, Default)]
@@ -286,8 +291,8 @@ pub struct IconButton {
 pub struct ProjectRow {
     #[serde(flatten)]
     pub container: ContainerStyle,
-    pub height: f32,
     pub name: ContainedText,
+    pub guests: ContainerStyle,
     pub guest_avatar: ImageStyle,
     pub guest_avatar_spacing: f32,
 }

crates/workspace/src/sidebar.rs 🔗

@@ -10,10 +10,14 @@ use theme::Theme;
 
 pub trait SidebarItem: View {
     fn should_show_badge(&self, cx: &AppContext) -> bool;
+    fn contains_focused_view(&self, _: &AppContext) -> bool {
+        false
+    }
 }
 
 pub trait SidebarItemHandle {
     fn should_show_badge(&self, cx: &AppContext) -> bool;
+    fn is_focused(&self, cx: &AppContext) -> bool;
     fn to_any(&self) -> AnyViewHandle;
 }
 
@@ -25,6 +29,10 @@ where
         self.read(cx).should_show_badge(cx)
     }
 
+    fn is_focused(&self, cx: &AppContext) -> bool {
+        ViewHandle::is_focused(&self, cx) || self.read(cx).contains_focused_view(cx)
+    }
+
     fn to_any(&self) -> AnyViewHandle {
         self.into()
     }
@@ -114,10 +122,10 @@ impl Sidebar {
         cx.notify();
     }
 
-    pub fn active_item(&self) -> Option<&dyn SidebarItemHandle> {
+    pub fn active_item(&self) -> Option<&Rc<dyn SidebarItemHandle>> {
         self.active_item_ix
             .and_then(|ix| self.items.get(ix))
-            .map(|item| item.view.as_ref())
+            .map(|item| &item.view)
     }
 
     fn render_resize_handle(&self, theme: &Theme, cx: &mut RenderContext<Self>) -> ElementBox {
@@ -170,7 +178,7 @@ impl View for Sidebar {
 
             container.add_child(
                 Hook::new(
-                    ChildView::new(active_item)
+                    ChildView::new(active_item.to_any())
                         .constrained()
                         .with_max_width(*self.custom_width.borrow())
                         .boxed(),

crates/workspace/src/workspace.rs 🔗

@@ -1123,13 +1123,13 @@ impl Workspace {
         };
         let active_item = sidebar.update(cx, |sidebar, cx| {
             sidebar.activate_item(action.item_index, cx);
-            sidebar.active_item().map(|item| item.to_any())
+            sidebar.active_item().cloned()
         });
         if let Some(active_item) = active_item {
             if active_item.is_focused(cx) {
                 cx.focus_self();
             } else {
-                cx.focus(active_item);
+                cx.focus(active_item.to_any());
             }
         }
         cx.notify();

styles/src/styleTree/app.ts 🔗

@@ -13,7 +13,7 @@ import projectDiagnostics from "./projectDiagnostics";
 import contactNotification from "./contactNotification";
 
 export const panel = {
-  padding: { top: 12, left: 12, bottom: 12, right: 12 },
+  padding: { top: 12, bottom: 12 },
 };
 
 export default function app(theme: Theme): Object {

styles/src/styleTree/contactsPanel.ts 🔗

@@ -3,7 +3,10 @@ import { panel } from "./app";
 import { backgroundColor, border, borderColor, iconColor, player, text } from "./components";
 
 export default function contactsPanel(theme: Theme) {
-  const project = {
+  const nameMargin = 8;
+  const sidePadding = 12;
+
+  const projectRow = {
     guestAvatarSpacing: 4,
     height: 24,
     guestAvatar: {
@@ -13,21 +16,19 @@ export default function contactsPanel(theme: Theme) {
     name: {
       ...text(theme, "mono", "placeholder", { size: "sm" }),
       margin: {
+        left: nameMargin,
         right: 6,
       },
     },
-    padding: {
-      left: 8,
+    guests: {
+      margin: {
+        left: nameMargin,
+        right: nameMargin,
+      }
     },
-  };
-
-  const sharedProject = {
-    ...project,
-    background: backgroundColor(theme, 300),
-    cornerRadius: 6,
-    name: {
-      ...project.name,
-      ...text(theme, "mono", "secondary", { size: "sm" }),
+    padding: {
+      left: sidePadding,
+      right: sidePadding,
     },
   };
 
@@ -54,34 +55,62 @@ export default function contactsPanel(theme: Theme) {
         right: 8,
         top: 4,
       },
+      margin: {
+        left: sidePadding,
+        right: sidePadding,
+      }
     },
     userQueryEditorHeight: 32,
     addContactButton: {
-      margin: { left: 6 },
+      margin: { left: 6, right: 12 },
       color: iconColor(theme, "primary"),
       buttonWidth: 8,
       iconWidth: 8,
     },
-    row: {
-      padding: { left: 8 },
-    },
     rowHeight: 28,
-    header: {
+    sectionIconSize: 8,
+    headerRow: {
       ...text(theme, "mono", "secondary", { size: "sm" }),
-      margin: { top: 8 },
+      margin: { top: 14 },
+      padding: {
+        left: sidePadding,
+        right: sidePadding,
+      },
+      active: {
+        ...text(theme, "mono", "primary", { size: "sm" }),
+        background: backgroundColor(theme, 100, "active"),
+      }
+    },
+    contactRow: {
+      padding: {
+        left: sidePadding,
+        right: sidePadding
+      },
+      active: {
+        background: backgroundColor(theme, 100, "active"),
+      }
+    },
+    treeBranch: {
+      color: borderColor(theme, "active"),
+      width: 1,
+      hover: {
+        color: borderColor(theme, "active"),
+      },
+      active: {
+        color: borderColor(theme, "active"),
+      }
     },
-    treeBranchColor: borderColor(theme, "muted"),
-    treeBranchWidth: 1,
     contactAvatar: {
       cornerRadius: 10,
       width: 18,
     },
     contactUsername: {
       ...text(theme, "mono", "primary", { size: "sm" }),
-      padding: {
-        left: 8,
+      margin: {
+        left: nameMargin,
       },
     },
+    contactButtonSpacing: nameMargin,
     contactButton: {
       ...contactButton,
       hover: {
@@ -93,17 +122,33 @@ export default function contactsPanel(theme: Theme) {
       background: backgroundColor(theme, 100),
       color: iconColor(theme, "muted"),
     },
-    project,
-    sharedProject,
-    hoveredSharedProject: {
-      ...sharedProject,
-      background: backgroundColor(theme, 300, "hovered"),
-      cornerRadius: 6,
-    },
-    unsharedProject: project,
-    hoveredUnsharedProject: {
-      ...project,
-      cornerRadius: 6,
+    sharedProjectRow: {
+      ...projectRow,
+      background: backgroundColor(theme, 300),
+      name: {
+        ...projectRow.name,
+        ...text(theme, "mono", "secondary", { size: "sm" }),
+      },
+      hover: {
+        background: backgroundColor(theme, 300, "hovered"),
+      },
+      active: {
+        background: backgroundColor(theme, 300, "active"),
+      }
     },
+    unsharedProjectRow: {
+      ...projectRow,
+      background: backgroundColor(theme, 300),
+      name: {
+        ...projectRow.name,
+        ...text(theme, "mono", "secondary", { size: "sm" }),
+      },
+      hover: {
+        background: backgroundColor(theme, 300, "hovered"),
+      },
+      active: {
+        background: backgroundColor(theme, 300, "active"),
+      }
+    }
   }
 }