WIP - start work on keyboard navigation in contacts panel

Max Brunsfeld created

Change summary

Cargo.lock                                  |   2 
assets/themes/cave-dark.json                |  99 +--
assets/themes/cave-light.json               |  99 +--
assets/themes/dark.json                     |  99 +--
assets/themes/light.json                    |  99 +--
assets/themes/solarized-dark.json           |  99 +--
assets/themes/solarized-light.json          |  99 +--
assets/themes/sulphurpool-dark.json         |  99 +--
assets/themes/sulphurpool-light.json        |  99 +--
crates/contacts_panel/Cargo.toml            |   5 
crates/contacts_panel/src/contacts_panel.rs | 680 ++++++++++++++++------
crates/theme/src/theme.rs                   |  10 
styles/src/styleTree/contactsPanel.ts       |  61 +
13 files changed, 791 insertions(+), 759 deletions(-)

Detailed changes

Cargo.lock 🔗

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

assets/themes/cave-dark.json 🔗

@@ -1249,20 +1249,29 @@
       "button_width": 8,
       "icon_width": 8
     },
-    "row": {
-      "padding": {
-        "left": 8
-      }
-    },
-    "row_height": 28,
-    "header": {
+    "header_row": {
       "family": "Zed Mono",
       "color": "#8b8792",
       "size": 14,
       "margin": {
         "top": 8
+      },
+      "active": {
+        "family": "Zed Mono",
+        "color": "#e2dfe7",
+        "size": 14,
+        "background": "#5852605c"
       }
     },
+    "contact_row": {
+      "padding": {
+        "left": 8
+      },
+      "active": {
+        "background": "#5852605c"
+      }
+    },
+    "row_height": 28,
     "tree_branch_color": "#655f6d",
     "tree_branch_width": 1,
     "contact_avatar": {
@@ -1294,26 +1303,7 @@
       "button_width": 16,
       "corner_radius": 8
     },
-    "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
-        }
-      },
-      "padding": {
-        "left": 8
-      }
-    },
-    "shared_project": {
+    "shared_project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {
@@ -1332,9 +1322,15 @@
         "left": 8
       },
       "background": "#26232a",
-      "corner_radius": 6
+      "corner_radius": 6,
+      "hover": {
+        "background": "#5852603d"
+      },
+      "active": {
+        "background": "#5852605c"
+      }
     },
-    "hovered_shared_project": {
+    "unshared_project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {
@@ -1352,47 +1348,14 @@
       "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,
-        "margin": {
-          "right": 6
-        }
+      "background": "#26232a",
+      "corner_radius": 6,
+      "hover": {
+        "background": "#5852603d"
       },
-      "padding": {
-        "left": 8
+      "active": {
+        "background": "#5852605c"
       }
-    },
-    "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
-        }
-      },
-      "padding": {
-        "left": 8
-      },
-      "corner_radius": 6
     }
   },
   "contact_finder": {

assets/themes/cave-light.json 🔗

@@ -1249,20 +1249,29 @@
       "button_width": 8,
       "icon_width": 8
     },
-    "row": {
-      "padding": {
-        "left": 8
-      }
-    },
-    "row_height": 28,
-    "header": {
+    "header_row": {
       "family": "Zed Mono",
       "color": "#585260",
       "size": 14,
       "margin": {
         "top": 8
+      },
+      "active": {
+        "family": "Zed Mono",
+        "color": "#26232a",
+        "size": 14,
+        "background": "#8b87922e"
       }
     },
+    "contact_row": {
+      "padding": {
+        "left": 8
+      },
+      "active": {
+        "background": "#8b87922e"
+      }
+    },
+    "row_height": 28,
     "tree_branch_color": "#7e7887",
     "tree_branch_width": 1,
     "contact_avatar": {
@@ -1294,26 +1303,7 @@
       "button_width": 16,
       "corner_radius": 8
     },
-    "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
-        }
-      },
-      "padding": {
-        "left": 8
-      }
-    },
-    "shared_project": {
+    "shared_project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {
@@ -1332,9 +1322,15 @@
         "left": 8
       },
       "background": "#e2dfe7",
-      "corner_radius": 6
+      "corner_radius": 6,
+      "hover": {
+        "background": "#8b87921f"
+      },
+      "active": {
+        "background": "#8b87922e"
+      }
     },
-    "hovered_shared_project": {
+    "unshared_project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {
@@ -1352,47 +1348,14 @@
       "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,
-        "margin": {
-          "right": 6
-        }
+      "background": "#e2dfe7",
+      "corner_radius": 6,
+      "hover": {
+        "background": "#8b87921f"
       },
-      "padding": {
-        "left": 8
+      "active": {
+        "background": "#8b87922e"
       }
-    },
-    "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
-        }
-      },
-      "padding": {
-        "left": 8
-      },
-      "corner_radius": 6
     }
   },
   "contact_finder": {

assets/themes/dark.json 🔗

@@ -1249,20 +1249,29 @@
       "button_width": 8,
       "icon_width": 8
     },
-    "row": {
-      "padding": {
-        "left": 8
-      }
-    },
-    "row_height": 28,
-    "header": {
+    "header_row": {
       "family": "Zed Mono",
       "color": "#9c9c9c",
       "size": 14,
       "margin": {
         "top": 8
+      },
+      "active": {
+        "family": "Zed Mono",
+        "color": "#f1f1f1",
+        "size": 14,
+        "background": "#1c1c1c"
       }
     },
+    "contact_row": {
+      "padding": {
+        "left": 8
+      },
+      "active": {
+        "background": "#1c1c1c"
+      }
+    },
+    "row_height": 28,
     "tree_branch_color": "#404040",
     "tree_branch_width": 1,
     "contact_avatar": {
@@ -1294,26 +1303,7 @@
       "button_width": 16,
       "corner_radius": 8
     },
-    "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
-        }
-      },
-      "padding": {
-        "left": 8
-      }
-    },
-    "shared_project": {
+    "shared_project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {
@@ -1332,9 +1322,15 @@
         "left": 8
       },
       "background": "#1c1c1c",
-      "corner_radius": 6
+      "corner_radius": 6,
+      "hover": {
+        "background": "#232323"
+      },
+      "active": {
+        "background": "#2b2b2b"
+      }
     },
-    "hovered_shared_project": {
+    "unshared_project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {
@@ -1352,47 +1348,14 @@
       "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,
-        "margin": {
-          "right": 6
-        }
+      "background": "#1c1c1c",
+      "corner_radius": 6,
+      "hover": {
+        "background": "#232323"
       },
-      "padding": {
-        "left": 8
+      "active": {
+        "background": "#2b2b2b"
       }
-    },
-    "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
-        }
-      },
-      "padding": {
-        "left": 8
-      },
-      "corner_radius": 6
     }
   },
   "contact_finder": {

assets/themes/light.json 🔗

@@ -1249,20 +1249,29 @@
       "button_width": 8,
       "icon_width": 8
     },
-    "row": {
-      "padding": {
-        "left": 8
-      }
-    },
-    "row_height": 28,
-    "header": {
+    "header_row": {
       "family": "Zed Mono",
       "color": "#474747",
       "size": 14,
       "margin": {
         "top": 8
+      },
+      "active": {
+        "family": "Zed Mono",
+        "color": "#2b2b2b",
+        "size": 14,
+        "background": "#d5d5d5"
       }
     },
+    "contact_row": {
+      "padding": {
+        "left": 8
+      },
+      "active": {
+        "background": "#d5d5d5"
+      }
+    },
+    "row_height": 28,
     "tree_branch_color": "#e3e3e3",
     "tree_branch_width": 1,
     "contact_avatar": {
@@ -1294,26 +1303,7 @@
       "button_width": 16,
       "corner_radius": 8
     },
-    "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
-        }
-      },
-      "padding": {
-        "left": 8
-      }
-    },
-    "shared_project": {
+    "shared_project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {
@@ -1332,9 +1322,15 @@
         "left": 8
       },
       "background": "#f8f8f8",
-      "corner_radius": 6
+      "corner_radius": 6,
+      "hover": {
+        "background": "#eaeaea"
+      },
+      "active": {
+        "background": "#e3e3e3"
+      }
     },
-    "hovered_shared_project": {
+    "unshared_project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {
@@ -1352,47 +1348,14 @@
       "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,
-        "margin": {
-          "right": 6
-        }
+      "background": "#f8f8f8",
+      "corner_radius": 6,
+      "hover": {
+        "background": "#eaeaea"
       },
-      "padding": {
-        "left": 8
+      "active": {
+        "background": "#e3e3e3"
       }
-    },
-    "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
-        }
-      },
-      "padding": {
-        "left": 8
-      },
-      "corner_radius": 6
     }
   },
   "contact_finder": {

assets/themes/solarized-dark.json 🔗

@@ -1249,20 +1249,29 @@
       "button_width": 8,
       "icon_width": 8
     },
-    "row": {
-      "padding": {
-        "left": 8
-      }
-    },
-    "row_height": 28,
-    "header": {
+    "header_row": {
       "family": "Zed Mono",
       "color": "#93a1a1",
       "size": 14,
       "margin": {
         "top": 8
+      },
+      "active": {
+        "family": "Zed Mono",
+        "color": "#eee8d5",
+        "size": 14,
+        "background": "#586e755c"
       }
     },
+    "contact_row": {
+      "padding": {
+        "left": 8
+      },
+      "active": {
+        "background": "#586e755c"
+      }
+    },
+    "row_height": 28,
     "tree_branch_color": "#657b83",
     "tree_branch_width": 1,
     "contact_avatar": {
@@ -1294,26 +1303,7 @@
       "button_width": 16,
       "corner_radius": 8
     },
-    "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
-        }
-      },
-      "padding": {
-        "left": 8
-      }
-    },
-    "shared_project": {
+    "shared_project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {
@@ -1332,9 +1322,15 @@
         "left": 8
       },
       "background": "#073642",
-      "corner_radius": 6
+      "corner_radius": 6,
+      "hover": {
+        "background": "#586e753d"
+      },
+      "active": {
+        "background": "#586e755c"
+      }
     },
-    "hovered_shared_project": {
+    "unshared_project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {
@@ -1352,47 +1348,14 @@
       "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,
-        "margin": {
-          "right": 6
-        }
+      "background": "#073642",
+      "corner_radius": 6,
+      "hover": {
+        "background": "#586e753d"
       },
-      "padding": {
-        "left": 8
+      "active": {
+        "background": "#586e755c"
       }
-    },
-    "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
-        }
-      },
-      "padding": {
-        "left": 8
-      },
-      "corner_radius": 6
     }
   },
   "contact_finder": {

assets/themes/solarized-light.json 🔗

@@ -1249,20 +1249,29 @@
       "button_width": 8,
       "icon_width": 8
     },
-    "row": {
-      "padding": {
-        "left": 8
-      }
-    },
-    "row_height": 28,
-    "header": {
+    "header_row": {
       "family": "Zed Mono",
       "color": "#586e75",
       "size": 14,
       "margin": {
         "top": 8
+      },
+      "active": {
+        "family": "Zed Mono",
+        "color": "#073642",
+        "size": 14,
+        "background": "#93a1a12e"
       }
     },
+    "contact_row": {
+      "padding": {
+        "left": 8
+      },
+      "active": {
+        "background": "#93a1a12e"
+      }
+    },
+    "row_height": 28,
     "tree_branch_color": "#839496",
     "tree_branch_width": 1,
     "contact_avatar": {
@@ -1294,26 +1303,7 @@
       "button_width": 16,
       "corner_radius": 8
     },
-    "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
-        }
-      },
-      "padding": {
-        "left": 8
-      }
-    },
-    "shared_project": {
+    "shared_project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {
@@ -1332,9 +1322,15 @@
         "left": 8
       },
       "background": "#eee8d5",
-      "corner_radius": 6
+      "corner_radius": 6,
+      "hover": {
+        "background": "#93a1a11f"
+      },
+      "active": {
+        "background": "#93a1a12e"
+      }
     },
-    "hovered_shared_project": {
+    "unshared_project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {
@@ -1352,47 +1348,14 @@
       "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,
-        "margin": {
-          "right": 6
-        }
+      "background": "#eee8d5",
+      "corner_radius": 6,
+      "hover": {
+        "background": "#93a1a11f"
       },
-      "padding": {
-        "left": 8
+      "active": {
+        "background": "#93a1a12e"
       }
-    },
-    "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
-        }
-      },
-      "padding": {
-        "left": 8
-      },
-      "corner_radius": 6
     }
   },
   "contact_finder": {

assets/themes/sulphurpool-dark.json 🔗

@@ -1249,20 +1249,29 @@
       "button_width": 8,
       "icon_width": 8
     },
-    "row": {
-      "padding": {
-        "left": 8
-      }
-    },
-    "row_height": 28,
-    "header": {
+    "header_row": {
       "family": "Zed Mono",
       "color": "#979db4",
       "size": 14,
       "margin": {
         "top": 8
+      },
+      "active": {
+        "family": "Zed Mono",
+        "color": "#dfe2f1",
+        "size": 14,
+        "background": "#5e66875c"
       }
     },
+    "contact_row": {
+      "padding": {
+        "left": 8
+      },
+      "active": {
+        "background": "#5e66875c"
+      }
+    },
+    "row_height": 28,
     "tree_branch_color": "#6b7394",
     "tree_branch_width": 1,
     "contact_avatar": {
@@ -1294,26 +1303,7 @@
       "button_width": 16,
       "corner_radius": 8
     },
-    "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
-        }
-      },
-      "padding": {
-        "left": 8
-      }
-    },
-    "shared_project": {
+    "shared_project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {
@@ -1332,9 +1322,15 @@
         "left": 8
       },
       "background": "#293256",
-      "corner_radius": 6
+      "corner_radius": 6,
+      "hover": {
+        "background": "#5e66873d"
+      },
+      "active": {
+        "background": "#5e66875c"
+      }
     },
-    "hovered_shared_project": {
+    "unshared_project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {
@@ -1352,47 +1348,14 @@
       "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,
-        "margin": {
-          "right": 6
-        }
+      "background": "#293256",
+      "corner_radius": 6,
+      "hover": {
+        "background": "#5e66873d"
       },
-      "padding": {
-        "left": 8
+      "active": {
+        "background": "#5e66875c"
       }
-    },
-    "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
-        }
-      },
-      "padding": {
-        "left": 8
-      },
-      "corner_radius": 6
     }
   },
   "contact_finder": {

assets/themes/sulphurpool-light.json 🔗

@@ -1249,20 +1249,29 @@
       "button_width": 8,
       "icon_width": 8
     },
-    "row": {
-      "padding": {
-        "left": 8
-      }
-    },
-    "row_height": 28,
-    "header": {
+    "header_row": {
       "family": "Zed Mono",
       "color": "#5e6687",
       "size": 14,
       "margin": {
         "top": 8
+      },
+      "active": {
+        "family": "Zed Mono",
+        "color": "#293256",
+        "size": 14,
+        "background": "#979db42e"
       }
     },
+    "contact_row": {
+      "padding": {
+        "left": 8
+      },
+      "active": {
+        "background": "#979db42e"
+      }
+    },
+    "row_height": 28,
     "tree_branch_color": "#898ea4",
     "tree_branch_width": 1,
     "contact_avatar": {
@@ -1294,26 +1303,7 @@
       "button_width": 16,
       "corner_radius": 8
     },
-    "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
-        }
-      },
-      "padding": {
-        "left": 8
-      }
-    },
-    "shared_project": {
+    "shared_project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {
@@ -1332,9 +1322,15 @@
         "left": 8
       },
       "background": "#dfe2f1",
-      "corner_radius": 6
+      "corner_radius": 6,
+      "hover": {
+        "background": "#979db41f"
+      },
+      "active": {
+        "background": "#979db42e"
+      }
     },
-    "hovered_shared_project": {
+    "unshared_project_row": {
       "guest_avatar_spacing": 4,
       "height": 24,
       "guest_avatar": {
@@ -1352,47 +1348,14 @@
       "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,
-        "margin": {
-          "right": 6
-        }
+      "background": "#dfe2f1",
+      "corner_radius": 6,
+      "hover": {
+        "background": "#979db41f"
       },
-      "padding": {
-        "left": 8
+      "active": {
+        "background": "#979db42e"
       }
-    },
-    "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
-        }
-      },
-      "padding": {
-        "left": 8
-      },
-      "corner_radius": 6
     }
   },
   "contact_finder": {

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 🔗

@@ -15,6 +15,7 @@ use serde::Deserialize;
 use settings::Settings;
 use std::sync::Arc;
 use theme::IconButton;
+use workspace::menu::{SelectNext, SelectPrev};
 use workspace::{AppState, JoinProject};
 
 impl_actions!(
@@ -22,12 +23,13 @@ impl_actions!(
     [RequestContact, RemoveContact, RespondToContactRequest]
 );
 
-#[derive(Debug)]
+#[derive(Clone, Debug)]
 enum ContactEntry {
     Header(&'static str),
     IncomingRequest(Arc<User>),
     OutgoingRequest(Arc<User>),
     Contact(Arc<Contact>),
+    ContactProject(Arc<Contact>, usize),
 }
 
 pub struct ContactsPanel {
@@ -36,6 +38,7 @@ pub struct ContactsPanel {
     list_state: ListState,
     user_store: ModelHandle<UserStore>,
     filter_editor: ViewHandle<Editor>,
+    selection: Option<usize>,
     _maintain_contacts: Subscription,
 }
 
@@ -57,6 +60,8 @@ 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);
 }
 
 impl ContactsPanel {
@@ -72,6 +77,7 @@ impl ContactsPanel {
 
         cx.subscribe(&user_query_editor, |this, _, event, cx| {
             if let editor::Event::BufferEdited = event {
+                this.selection.take();
                 this.update_entries(cx)
             }
         })
@@ -88,17 +94,20 @@ 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())
+                            let header_style =
+                                theme.header_row.style_for(&Default::default(), is_selected);
+                            Label::new(text.to_string(), header_style.text.clone())
                                 .contained()
                                 .aligned()
                                 .left()
                                 .constrained()
                                 .with_height(theme.row_height)
                                 .contained()
-                                .with_style(theme.header.container)
+                                .with_style(header_style.container)
                                 .boxed()
                         }
                         ContactEntry::IncomingRequest(user) => Self::render_contact_request(
@@ -106,6 +115,7 @@ impl ContactsPanel {
                             this.user_store.clone(),
                             theme,
                             true,
+                            is_selected,
                             cx,
                         ),
                         ContactEntry::OutgoingRequest(user) => Self::render_contact_request(
@@ -113,18 +123,36 @@ 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,
             entries: Default::default(),
             match_candidates: Default::default(),
             filter_editor: user_query_editor,
@@ -137,175 +165,173 @@ impl ContactsPanel {
     }
 
     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()
+                .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 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 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) + (row.height - line_height) / 2.;
+        let tree_branch_width = theme.tree_branch_width;
+        let tree_branch_color = theme.tree_branch_color;
 
-        Flex::column()
+        Flex::row()
             .with_child(
-                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()
-                        .boxed(),
-                    )
-                    .constrained()
-                    .with_height(theme.row_height)
-                    .boxed(),
+                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_children(
-                contact
-                    .non_empty_projects()
-                    .enumerate()
-                    .map(|(ix, project)| {
-                        let project_id = project.id;
+            .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::<JoinProject, _, _>(
+                    project_id as usize,
+                    cx,
+                    |mouse_state, _| {
+                        let style = 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 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)
+                                Label::new(
+                                    project.worktree_root_names.join(", "),
+                                    style.name.text.clone(),
+                                )
+                                .aligned()
+                                .left()
+                                .contained()
+                                .with_style(style.name.container)
                                 .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(),
-                                        });
-                                    }
+                            .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()
                                 })
-                                .flex(1., true)
-                                .boxed()
-                            })
+                            }))
+                            .contained()
+                            .with_style(style.container)
                             .constrained()
-                            .with_height(theme.unshared_project.height)
+                            .with_height(style.height)
                             .boxed()
-                    }),
-            )
-            .contained()
-            .with_style(theme.row.clone())
+                    },
+                )
+                .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(row.height)
             .boxed()
     }
 
@@ -314,6 +340,7 @@ impl ContactsPanel {
         user_store: ModelHandle<UserStore>,
         theme: &theme::ContactsPanel,
         is_incoming: bool,
+        is_selected: bool,
         cx: &mut LayoutContext,
     ) -> ElementBox {
         enum Reject {}
@@ -409,7 +436,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()
     }
 
@@ -418,6 +449,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();
@@ -443,13 +475,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();
@@ -474,13 +504,11 @@ 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() {
@@ -515,22 +543,33 @@ 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, name) in [(online_contacts, "Online"), (offline_contacts, "Offline")] {
+                if !matches.is_empty() {
+                    self.entries.push(ContactEntry::Header(name));
+                    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(selection) = &mut self.selection {
+            for (ix, entry) in self.entries.iter().enumerate() {
+                if Some(entry) == prev_selected_entry.as_ref() {
+                    *selection = ix;
+                    break;
+                }
             }
         }
 
@@ -566,6 +605,30 @@ 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 render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element {
@@ -633,4 +696,249 @@ 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(name_1) => {
+                if let ContactEntry::Header(name_2) = other {
+                    return name_1 == name_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;
+
+    #[gpui::test]
+    async fn test_contact_panel(cx: &mut TestAppContext) {
+        let (app_state, server) = init(cx).await;
+        let panel = cx.add_view(0, |cx| ContactsPanel::new(app_state.clone(), 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,
+                    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,
+                    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,
+                    projects: vec![],
+                },
+            ],
+            ..Default::default()
+        });
+
+        cx.foreground().run_until_parked();
+        assert_eq!(
+            render_to_strings(&panel, cx),
+            &[
+                "+",
+                "v Requests",
+                "  incoming user_one  <=== selected",
+                "  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),
+            &[
+                "+",
+                "Online",
+                "  user_four  <=== selected",
+                "    dir2",
+                "Offline",
+                "  user_five",
+            ]
+        );
+
+        panel.update(cx, |panel, cx| {
+            panel.select_next(&Default::default(), cx);
+        });
+        assert_eq!(
+            render_to_strings(&panel, cx),
+            &[
+                "+",
+                "Online",
+                "  user_four",
+                "    dir2  <=== selected",
+                "Offline",
+                "  user_five",
+            ]
+        );
+
+        panel.update(cx, |panel, cx| {
+            panel.select_next(&Default::default(), cx);
+        });
+        assert_eq!(
+            render_to_strings(&panel, cx),
+            &[
+                "+",
+                "Online",
+                "  user_four",
+                "    dir2",
+                "Offline",
+                "  user_five  <=== selected",
+            ]
+        );
+    }
+
+    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().map(|entry| match entry {
+                ContactEntry::Header(name) => {
+                    format!("{}", 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(", ")
+                    )
+                }
+            }));
+            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 🔗

@@ -235,11 +235,13 @@ 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,
@@ -247,10 +249,6 @@ pub struct ContactsPanel {
     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,
 }
 
 #[derive(Deserialize, Default)]

styles/src/styleTree/contactsPanel.ts 🔗

@@ -21,16 +21,6 @@ export default function contactsPanel(theme: Theme) {
     },
   };
 
-  const sharedProject = {
-    ...project,
-    background: backgroundColor(theme, 300),
-    cornerRadius: 6,
-    name: {
-      ...project.name,
-      ...text(theme, "mono", "secondary", { size: "sm" }),
-    },
-  };
-
   const contactButton = {
     background: backgroundColor(theme, 100),
     color: iconColor(theme, "primary"),
@@ -62,14 +52,21 @@ export default function contactsPanel(theme: Theme) {
       buttonWidth: 8,
       iconWidth: 8,
     },
-    row: {
-      padding: { left: 8 },
-    },
-    rowHeight: 28,
-    header: {
+    headerRow: {
       ...text(theme, "mono", "secondary", { size: "sm" }),
       margin: { top: 8 },
+      active: {
+        ...text(theme, "mono", "primary", { size: "sm" }),
+        background: backgroundColor(theme, 100, "active"),
+      }
+    },
+    contactRow: {
+      padding: { left: 8 },
+      active: {
+        background: backgroundColor(theme, 100, "active"),
+      }
     },
+    rowHeight: 28,
     treeBranchColor: borderColor(theme, "muted"),
     treeBranchWidth: 1,
     contactAvatar: {
@@ -93,17 +90,35 @@ export default function contactsPanel(theme: Theme) {
       background: backgroundColor(theme, 100),
       color: iconColor(theme, "muted"),
     },
-    project,
-    sharedProject,
-    hoveredSharedProject: {
-      ...sharedProject,
-      background: backgroundColor(theme, 300, "hovered"),
+    sharedProjectRow: {
+      ...project,
+      background: backgroundColor(theme, 300),
       cornerRadius: 6,
+      name: {
+        ...project.name,
+        ...text(theme, "mono", "secondary", { size: "sm" }),
+      },
+      hover: {
+        background: backgroundColor(theme, 300, "hovered"),
+      },
+      active: {
+        background: backgroundColor(theme, 300, "active"),
+      }
     },
-    unsharedProject: project,
-    hoveredUnsharedProject: {
+    unsharedProjectRow: {
       ...project,
+      background: backgroundColor(theme, 300),
       cornerRadius: 6,
-    },
+      name: {
+        ...project.name,
+        ...text(theme, "mono", "secondary", { size: "sm" }),
+      },
+      hover: {
+        background: backgroundColor(theme, 300, "hovered"),
+      },
+      active: {
+        background: backgroundColor(theme, 300, "active"),
+      }
+    }
   }
 }