Merge pull request #979 from zed-industries/contacts

Max Brunsfeld created

Manage users' contact relationships on the server

Change summary

Cargo.lock                                                             | 143 
assets/icons/accept.svg                                                |   3 
assets/icons/add-contact.svg                                           |   3 
assets/icons/reject.svg                                                |   3 
assets/themes/cave-dark.json                                           | 189 
assets/themes/cave-light.json                                          | 189 
assets/themes/dark.json                                                | 189 
assets/themes/light.json                                               | 189 
assets/themes/solarized-dark.json                                      | 189 
assets/themes/solarized-light.json                                     | 189 
assets/themes/sulphurpool-dark.json                                    | 189 
assets/themes/sulphurpool-light.json                                   | 189 
crates/client/src/channel.rs                                           |   8 
crates/client/src/client.rs                                            |  14 
crates/client/src/user.rs                                              | 388 
crates/collab/Cargo.toml                                               |   4 
crates/collab/migrations/20220505144506_add_trigram_index_to_users.sql |   2 
crates/collab/migrations/20220506130724_create_contacts.sql            |  11 
crates/collab/src/bin/seed.rs                                          |  62 
crates/collab/src/db.rs                                                | 593 
crates/collab/src/rpc.rs                                               | 764 
crates/collab/src/rpc/store.rs                                         | 250 
crates/contacts_panel/Cargo.toml                                       |   7 
crates/contacts_panel/src/contact_finder.rs                            | 191 
crates/contacts_panel/src/contacts_panel.rs                            | 494 
crates/fuzzy/src/fuzzy.rs                                              |  14 
crates/gpui/src/elements/flex.rs                                       |  29 
crates/gpui/src/platform/mac/atlas.rs                                  | 103 
crates/language/src/language.rs                                        |  11 
crates/lsp/src/lsp.rs                                                  |  10 
crates/project/src/fs.rs                                               |  10 
crates/project/src/project.rs                                          |   4 
crates/project/src/worktree.rs                                         |  20 
crates/project_symbols/src/project_symbols.rs                          |  23 
crates/rpc/proto/zed.proto                                             |  55 
crates/rpc/src/macros.rs                                               |  67 
crates/rpc/src/peer.rs                                                 |  10 
crates/rpc/src/proto.rs                                                |  79 
crates/rpc/src/rpc.rs                                                  |   1 
crates/theme/src/theme.rs                                              |  42 
crates/workspace/src/sidebar.rs                                        |  10 
crates/workspace/src/workspace.rs                                      |   6 
crates/zed/src/main.rs                                                 |   1 
script/seed-db                                                         |   2 
script/zed-with-local-servers                                          |   0 
styles/src/styleTree/app.ts                                            |   2 
styles/src/styleTree/contactFinder.ts                                  |  38 
styles/src/styleTree/contactsPanel.ts                                  |  57 
48 files changed, 4,292 insertions(+), 754 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -841,6 +841,7 @@ dependencies = [
  "async-tungstenite",
  "axum",
  "base64 0.13.0",
+ "clap 3.1.12",
  "client",
  "collections",
  "ctor",
@@ -859,6 +860,7 @@ dependencies = [
  "parking_lot",
  "project",
  "rand 0.8.3",
+ "reqwest",
  "rpc",
  "scrypt",
  "serde",
@@ -930,10 +932,17 @@ name = "contacts_panel"
 version = "0.1.0"
 dependencies = [
  "client",
+ "editor",
+ "futures",
+ "fuzzy",
  "gpui",
+ "log",
+ "picker",
  "postage",
+ "serde",
  "settings",
  "theme",
+ "util",
  "workspace",
 ]
 
@@ -2133,6 +2142,19 @@ dependencies = [
  "tokio-io-timeout",
 ]
 
+[[package]]
+name = "hyper-tls"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905"
+dependencies = [
+ "bytes",
+ "hyper",
+ "native-tls",
+ "tokio",
+ "tokio-native-tls",
+]
+
 [[package]]
 name = "idna"
 version = "0.2.3"
@@ -2237,6 +2259,12 @@ dependencies = [
  "winapi 0.3.9",
 ]
 
+[[package]]
+name = "ipnet"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "879d54834c8c76457ef4293a689b2a8c59b076067ad77b15efafbb05f92a592b"
+
 [[package]]
 name = "isahc"
 version = "1.7.0"
@@ -2720,6 +2748,24 @@ version = "0.8.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e5ce46fe64a9d73be07dcbe690a38ce1b293be448fd8ce1e6c1b8062c9f72c6a"
 
+[[package]]
+name = "native-tls"
+version = "0.2.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd7e2f3618557f980e0b17e8856252eee3c97fa12c54dff0ca290fb6266ca4a9"
+dependencies = [
+ "lazy_static",
+ "libc",
+ "log",
+ "openssl",
+ "openssl-probe",
+ "openssl-sys",
+ "schannel",
+ "security-framework",
+ "security-framework-sys",
+ "tempfile",
+]
+
 [[package]]
 name = "nb-connect"
 version = "1.0.3"
@@ -2903,6 +2949,32 @@ version = "0.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
 
+[[package]]
+name = "openssl"
+version = "0.10.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb81a6430ac911acb25fe5ac8f1d2af1b4ea8a4fdfda0f1ee4292af2e2d8eb0e"
+dependencies = [
+ "bitflags",
+ "cfg-if 1.0.0",
+ "foreign-types",
+ "libc",
+ "once_cell",
+ "openssl-macros",
+ "openssl-sys",
+]
+
+[[package]]
+name = "openssl-macros"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b501e44f11665960c7e7fcf062c7d96a14ade4aa98116c004b2e37b5be7d736c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "openssl-probe"
 version = "0.1.4"
@@ -2911,9 +2983,9 @@ checksum = "28988d872ab76095a6e6ac88d99b54fd267702734fd7ffe610ca27f533ddb95a"
 
 [[package]]
 name = "openssl-sys"
-version = "0.9.65"
+version = "0.9.73"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7a7907e3bfa08bb85105209cdfcb6c63d109f8f6c1ed6ca318fff5c1853fbc1d"
+checksum = "9d5fd19fb3e0a8191c1e34935718976a3e70c112ab9a24af6d7cadccd9d90bc0"
 dependencies = [
  "autocfg 1.0.1",
  "cc",
@@ -3677,6 +3749,42 @@ dependencies = [
  "winapi 0.3.9",
 ]
 
+[[package]]
+name = "reqwest"
+version = "0.11.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87f242f1488a539a79bac6dbe7c8609ae43b7914b7736210f239a37cccb32525"
+dependencies = [
+ "base64 0.13.0",
+ "bytes",
+ "encoding_rs",
+ "futures-core",
+ "futures-util",
+ "h2",
+ "http",
+ "http-body",
+ "hyper",
+ "hyper-tls",
+ "ipnet",
+ "js-sys",
+ "lazy_static",
+ "log",
+ "mime",
+ "native-tls",
+ "percent-encoding",
+ "pin-project-lite 0.2.9",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "tokio",
+ "tokio-native-tls",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "winreg",
+]
+
 [[package]]
 name = "resvg"
 version = "0.14.0"
@@ -4957,6 +5065,16 @@ dependencies = [
  "syn",
 ]
 
+[[package]]
+name = "tokio-native-tls"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7d995660bd2b7f8c1568414c1126076c13fbb725c40112dc0120b78eb9b717b"
+dependencies = [
+ "native-tls",
+ "tokio",
+]
+
 [[package]]
 name = "tokio-rustls"
 version = "0.22.0"
@@ -5632,6 +5750,18 @@ dependencies = [
  "wasm-bindgen-shared",
 ]
 
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fba7978c679d53ce2d0ac80c8c175840feb849a161664365d1287b41f2e67f1"
+dependencies = [
+ "cfg-if 1.0.0",
+ "js-sys",
+ "wasm-bindgen",
+ "web-sys",
+]
+
 [[package]]
 name = "wasm-bindgen-macro"
 version = "0.2.74"
@@ -5777,6 +5907,15 @@ version = "0.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
 
+[[package]]
+name = "winreg"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0120db82e8a1e0b9fb3345a539c478767c0048d842860994d96113d5b667bd69"
+dependencies = [
+ "winapi 0.3.9",
+]
+
 [[package]]
 name = "wio"
 version = "0.2.2"

assets/icons/accept.svg 🔗

@@ -0,0 +1,3 @@
+<svg width="8" height="6" viewBox="0 0 8 6" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M7.25 1L3.25 5L1 2.75" stroke="white" stroke-width="1.33333"/>
+</svg>

assets/icons/add-contact.svg 🔗

@@ -0,0 +1,3 @@
+<svg width="12" height="10" viewBox="0 0 12 10" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M4.2 5.0002C5.52563 5.0002 6.6 3.92563 6.6 2.6002C6.6 1.27476 5.52563 0.200195 4.2 0.200195C2.87438 0.200195 1.8 1.27476 1.8 2.6002C1.8 3.92563 2.87438 5.0002 4.2 5.0002ZM5.15063 5.9002H3.24938C1.45519 5.9002 0 7.3552 0 9.14957C0 9.50957 0.291 9.8002 0.649875 9.8002H7.7505C8.10938 9.8002 8.4 9.50957 8.4 9.14957C8.4 7.3552 6.945 5.9002 5.15063 5.9002ZM11.55 3.9502H10.65V3.0502C10.65 2.8027 10.4494 2.6002 10.2 2.6002C9.95063 2.6002 9.75 2.80176 9.75 3.0502V3.9502H8.85C8.6025 3.9502 8.4 4.1527 8.4 4.4002C8.4 4.6477 8.60156 4.8502 8.85 4.8502H9.75V5.7502C9.75 5.99957 9.9525 6.2002 10.2 6.2002C10.4475 6.2002 10.65 5.99863 10.65 5.7502V4.8502H11.55C11.7994 4.8502 12 4.64957 12 4.4002C12 4.15082 11.7994 3.9502 11.55 3.9502Z" fill="#9C9C9C"/>
+</svg>

assets/icons/reject.svg 🔗

@@ -0,0 +1,3 @@
+<svg width="8" height="8" viewBox="0 0 8 8" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M1 7L4 4M7 1L4 4M4 4L1 1M4 4L7 7" stroke="#9C9C9C" stroke-width="1.33333"/>
+</svg>

assets/themes/cave-dark.json 🔗

@@ -1212,14 +1212,64 @@
       "bottom": 12,
       "right": 12
     },
-    "host_row_height": 28,
+    "user_query_editor": {
+      "background": "#19171c",
+      "corner_radius": 6,
+      "text": {
+        "family": "Zed Mono",
+        "color": "#e2dfe7",
+        "size": 14
+      },
+      "placeholder_text": {
+        "family": "Zed Mono",
+        "color": "#7e7887",
+        "size": 14
+      },
+      "selection": {
+        "cursor": "#576ddb",
+        "selection": "#576ddb3d"
+      },
+      "border": {
+        "color": "#26232a",
+        "width": 1
+      },
+      "padding": {
+        "bottom": 4,
+        "left": 8,
+        "right": 8,
+        "top": 4
+      }
+    },
+    "user_query_editor_height": 32,
+    "add_contact_button": {
+      "margin": {
+        "left": 6
+      },
+      "color": "#e2dfe7",
+      "button_width": 8,
+      "icon_width": 8
+    },
+    "row": {
+      "padding": {
+        "left": 8
+      }
+    },
+    "row_height": 28,
+    "header": {
+      "family": "Zed Mono",
+      "color": "#8b8792",
+      "size": 14,
+      "margin": {
+        "top": 8
+      }
+    },
     "tree_branch_color": "#655f6d",
     "tree_branch_width": 1,
-    "host_avatar": {
+    "contact_avatar": {
       "corner_radius": 10,
       "width": 18
     },
-    "host_username": {
+    "contact_username": {
       "family": "Zed Mono",
       "color": "#e2dfe7",
       "size": 14,
@@ -1227,6 +1277,23 @@
         "left": 8
       }
     },
+    "contact_button": {
+      "background": "#26232a",
+      "color": "#e2dfe7",
+      "icon_width": 8,
+      "button_width": 16,
+      "corner_radius": 8,
+      "hover": {
+        "background": "#5852603d"
+      }
+    },
+    "disabled_contact_button": {
+      "background": "#26232a",
+      "color": "#8b8792",
+      "icon_width": 8,
+      "button_width": 16,
+      "corner_radius": 8
+    },
     "project": {
       "guest_avatar_spacing": 4,
       "height": 24,
@@ -1328,6 +1395,122 @@
       "corner_radius": 6
     }
   },
+  "contact_finder": {
+    "background": "#26232a",
+    "corner_radius": 8,
+    "padding": 8,
+    "item": {
+      "padding": {
+        "bottom": 4,
+        "left": 12,
+        "right": 12,
+        "top": 4
+      },
+      "corner_radius": 8,
+      "text": {
+        "family": "Zed Sans",
+        "color": "#8b8792",
+        "size": 14
+      },
+      "highlight_text": {
+        "family": "Zed Sans",
+        "color": "#576ddb",
+        "weight": "bold",
+        "size": 14
+      },
+      "active": {
+        "background": "#5852605c",
+        "text": {
+          "family": "Zed Sans",
+          "color": "#e2dfe7",
+          "size": 14
+        }
+      },
+      "hover": {
+        "background": "#5852603d"
+      }
+    },
+    "border": {
+      "color": "#19171c",
+      "width": 1
+    },
+    "empty": {
+      "text": {
+        "family": "Zed Sans",
+        "color": "#7e7887",
+        "size": 14
+      },
+      "padding": {
+        "bottom": 4,
+        "left": 12,
+        "right": 12,
+        "top": 8
+      }
+    },
+    "input_editor": {
+      "background": "#19171c",
+      "corner_radius": 8,
+      "placeholder_text": {
+        "family": "Zed Sans",
+        "color": "#7e7887",
+        "size": 14
+      },
+      "selection": {
+        "cursor": "#576ddb",
+        "selection": "#576ddb3d"
+      },
+      "text": {
+        "family": "Zed Mono",
+        "color": "#e2dfe7",
+        "size": 14
+      },
+      "border": {
+        "color": "#26232a",
+        "width": 1
+      },
+      "padding": {
+        "bottom": 7,
+        "left": 16,
+        "right": 16,
+        "top": 7
+      }
+    },
+    "shadow": {
+      "blur": 16,
+      "color": "#0000003d",
+      "offset": [
+        0,
+        2
+      ]
+    },
+    "row_height": 28,
+    "contact_avatar": {
+      "corner_radius": 10,
+      "width": 18
+    },
+    "contact_username": {
+      "padding": {
+        "left": 8
+      }
+    },
+    "contact_button": {
+      "background": "#26232a",
+      "color": "#e2dfe7",
+      "icon_width": 8,
+      "button_width": 16,
+      "corner_radius": 8,
+      "hover": {
+        "background": "#5852603d"
+      }
+    },
+    "disabled_contact_button": {
+      "background": "#26232a",
+      "color": "#8b8792",
+      "icon_width": 8,
+      "button_width": 16,
+      "corner_radius": 8
+    }
+  },
   "search": {
     "match_background": "#955ae77a",
     "tab_icon_spacing": 8,

assets/themes/cave-light.json 🔗

@@ -1212,14 +1212,64 @@
       "bottom": 12,
       "right": 12
     },
-    "host_row_height": 28,
+    "user_query_editor": {
+      "background": "#efecf4",
+      "corner_radius": 6,
+      "text": {
+        "family": "Zed Mono",
+        "color": "#26232a",
+        "size": 14
+      },
+      "placeholder_text": {
+        "family": "Zed Mono",
+        "color": "#655f6d",
+        "size": 14
+      },
+      "selection": {
+        "cursor": "#576ddb",
+        "selection": "#576ddb3d"
+      },
+      "border": {
+        "color": "#e2dfe7",
+        "width": 1
+      },
+      "padding": {
+        "bottom": 4,
+        "left": 8,
+        "right": 8,
+        "top": 4
+      }
+    },
+    "user_query_editor_height": 32,
+    "add_contact_button": {
+      "margin": {
+        "left": 6
+      },
+      "color": "#26232a",
+      "button_width": 8,
+      "icon_width": 8
+    },
+    "row": {
+      "padding": {
+        "left": 8
+      }
+    },
+    "row_height": 28,
+    "header": {
+      "family": "Zed Mono",
+      "color": "#585260",
+      "size": 14,
+      "margin": {
+        "top": 8
+      }
+    },
     "tree_branch_color": "#7e7887",
     "tree_branch_width": 1,
-    "host_avatar": {
+    "contact_avatar": {
       "corner_radius": 10,
       "width": 18
     },
-    "host_username": {
+    "contact_username": {
       "family": "Zed Mono",
       "color": "#26232a",
       "size": 14,
@@ -1227,6 +1277,23 @@
         "left": 8
       }
     },
+    "contact_button": {
+      "background": "#e2dfe7",
+      "color": "#26232a",
+      "icon_width": 8,
+      "button_width": 16,
+      "corner_radius": 8,
+      "hover": {
+        "background": "#8b87921f"
+      }
+    },
+    "disabled_contact_button": {
+      "background": "#e2dfe7",
+      "color": "#585260",
+      "icon_width": 8,
+      "button_width": 16,
+      "corner_radius": 8
+    },
     "project": {
       "guest_avatar_spacing": 4,
       "height": 24,
@@ -1328,6 +1395,122 @@
       "corner_radius": 6
     }
   },
+  "contact_finder": {
+    "background": "#e2dfe7",
+    "corner_radius": 8,
+    "padding": 8,
+    "item": {
+      "padding": {
+        "bottom": 4,
+        "left": 12,
+        "right": 12,
+        "top": 4
+      },
+      "corner_radius": 8,
+      "text": {
+        "family": "Zed Sans",
+        "color": "#585260",
+        "size": 14
+      },
+      "highlight_text": {
+        "family": "Zed Sans",
+        "color": "#576ddb",
+        "weight": "bold",
+        "size": 14
+      },
+      "active": {
+        "background": "#8b87922e",
+        "text": {
+          "family": "Zed Sans",
+          "color": "#26232a",
+          "size": 14
+        }
+      },
+      "hover": {
+        "background": "#8b87921f"
+      }
+    },
+    "border": {
+      "color": "#efecf4",
+      "width": 1
+    },
+    "empty": {
+      "text": {
+        "family": "Zed Sans",
+        "color": "#655f6d",
+        "size": 14
+      },
+      "padding": {
+        "bottom": 4,
+        "left": 12,
+        "right": 12,
+        "top": 8
+      }
+    },
+    "input_editor": {
+      "background": "#efecf4",
+      "corner_radius": 8,
+      "placeholder_text": {
+        "family": "Zed Sans",
+        "color": "#655f6d",
+        "size": 14
+      },
+      "selection": {
+        "cursor": "#576ddb",
+        "selection": "#576ddb3d"
+      },
+      "text": {
+        "family": "Zed Mono",
+        "color": "#26232a",
+        "size": 14
+      },
+      "border": {
+        "color": "#e2dfe7",
+        "width": 1
+      },
+      "padding": {
+        "bottom": 7,
+        "left": 16,
+        "right": 16,
+        "top": 7
+      }
+    },
+    "shadow": {
+      "blur": 16,
+      "color": "#0000001f",
+      "offset": [
+        0,
+        2
+      ]
+    },
+    "row_height": 28,
+    "contact_avatar": {
+      "corner_radius": 10,
+      "width": 18
+    },
+    "contact_username": {
+      "padding": {
+        "left": 8
+      }
+    },
+    "contact_button": {
+      "background": "#e2dfe7",
+      "color": "#26232a",
+      "icon_width": 8,
+      "button_width": 16,
+      "corner_radius": 8,
+      "hover": {
+        "background": "#8b87921f"
+      }
+    },
+    "disabled_contact_button": {
+      "background": "#e2dfe7",
+      "color": "#585260",
+      "icon_width": 8,
+      "button_width": 16,
+      "corner_radius": 8
+    }
+  },
   "search": {
     "match_background": "#955ae73d",
     "tab_icon_spacing": 8,

assets/themes/dark.json 🔗

@@ -1212,14 +1212,64 @@
       "bottom": 12,
       "right": 12
     },
-    "host_row_height": 28,
+    "user_query_editor": {
+      "background": "#000000",
+      "corner_radius": 6,
+      "text": {
+        "family": "Zed Mono",
+        "color": "#f1f1f1",
+        "size": 14
+      },
+      "placeholder_text": {
+        "family": "Zed Mono",
+        "color": "#474747",
+        "size": 14
+      },
+      "selection": {
+        "cursor": "#2472f2",
+        "selection": "#2472f23d"
+      },
+      "border": {
+        "color": "#232323",
+        "width": 1
+      },
+      "padding": {
+        "bottom": 4,
+        "left": 8,
+        "right": 8,
+        "top": 4
+      }
+    },
+    "user_query_editor_height": 32,
+    "add_contact_button": {
+      "margin": {
+        "left": 6
+      },
+      "color": "#c6c6c6",
+      "button_width": 8,
+      "icon_width": 8
+    },
+    "row": {
+      "padding": {
+        "left": 8
+      }
+    },
+    "row_height": 28,
+    "header": {
+      "family": "Zed Mono",
+      "color": "#9c9c9c",
+      "size": 14,
+      "margin": {
+        "top": 8
+      }
+    },
     "tree_branch_color": "#404040",
     "tree_branch_width": 1,
-    "host_avatar": {
+    "contact_avatar": {
       "corner_radius": 10,
       "width": 18
     },
-    "host_username": {
+    "contact_username": {
       "family": "Zed Mono",
       "color": "#f1f1f1",
       "size": 14,
@@ -1227,6 +1277,23 @@
         "left": 8
       }
     },
+    "contact_button": {
+      "background": "#2b2b2b",
+      "color": "#c6c6c6",
+      "icon_width": 8,
+      "button_width": 16,
+      "corner_radius": 8,
+      "hover": {
+        "background": "#323232"
+      }
+    },
+    "disabled_contact_button": {
+      "background": "#2b2b2b",
+      "color": "#555555",
+      "icon_width": 8,
+      "button_width": 16,
+      "corner_radius": 8
+    },
     "project": {
       "guest_avatar_spacing": 4,
       "height": 24,
@@ -1328,6 +1395,122 @@
       "corner_radius": 6
     }
   },
+  "contact_finder": {
+    "background": "#1c1c1c",
+    "corner_radius": 8,
+    "padding": 8,
+    "item": {
+      "padding": {
+        "bottom": 4,
+        "left": 12,
+        "right": 12,
+        "top": 4
+      },
+      "corner_radius": 8,
+      "text": {
+        "family": "Zed Sans",
+        "color": "#9c9c9c",
+        "size": 14
+      },
+      "highlight_text": {
+        "family": "Zed Sans",
+        "color": "#4f8ff7",
+        "weight": "bold",
+        "size": 14
+      },
+      "active": {
+        "background": "#2b2b2b",
+        "text": {
+          "family": "Zed Sans",
+          "color": "#f1f1f1",
+          "size": 14
+        }
+      },
+      "hover": {
+        "background": "#232323"
+      }
+    },
+    "border": {
+      "color": "#070707",
+      "width": 1
+    },
+    "empty": {
+      "text": {
+        "family": "Zed Sans",
+        "color": "#474747",
+        "size": 14
+      },
+      "padding": {
+        "bottom": 4,
+        "left": 12,
+        "right": 12,
+        "top": 8
+      }
+    },
+    "input_editor": {
+      "background": "#000000",
+      "corner_radius": 8,
+      "placeholder_text": {
+        "family": "Zed Sans",
+        "color": "#474747",
+        "size": 14
+      },
+      "selection": {
+        "cursor": "#2472f2",
+        "selection": "#2472f23d"
+      },
+      "text": {
+        "family": "Zed Mono",
+        "color": "#f1f1f1",
+        "size": 14
+      },
+      "border": {
+        "color": "#232323",
+        "width": 1
+      },
+      "padding": {
+        "bottom": 7,
+        "left": 16,
+        "right": 16,
+        "top": 7
+      }
+    },
+    "shadow": {
+      "blur": 16,
+      "color": "#00000052",
+      "offset": [
+        0,
+        2
+      ]
+    },
+    "row_height": 28,
+    "contact_avatar": {
+      "corner_radius": 10,
+      "width": 18
+    },
+    "contact_username": {
+      "padding": {
+        "left": 8
+      }
+    },
+    "contact_button": {
+      "background": "#2b2b2b",
+      "color": "#c6c6c6",
+      "icon_width": 8,
+      "button_width": 16,
+      "corner_radius": 8,
+      "hover": {
+        "background": "#323232"
+      }
+    },
+    "disabled_contact_button": {
+      "background": "#2b2b2b",
+      "color": "#555555",
+      "icon_width": 8,
+      "button_width": 16,
+      "corner_radius": 8
+    }
+  },
   "search": {
     "match_background": "#3f15a380",
     "tab_icon_spacing": 8,

assets/themes/light.json 🔗

@@ -1212,14 +1212,64 @@
       "bottom": 12,
       "right": 12
     },
-    "host_row_height": 28,
+    "user_query_editor": {
+      "background": "#ffffff",
+      "corner_radius": 6,
+      "text": {
+        "family": "Zed Mono",
+        "color": "#2b2b2b",
+        "size": 14
+      },
+      "placeholder_text": {
+        "family": "Zed Mono",
+        "color": "#808080",
+        "size": 14
+      },
+      "selection": {
+        "cursor": "#2472f2",
+        "selection": "#2472f23d"
+      },
+      "border": {
+        "color": "#d5d5d5",
+        "width": 1
+      },
+      "padding": {
+        "bottom": 4,
+        "left": 8,
+        "right": 8,
+        "top": 4
+      }
+    },
+    "user_query_editor_height": 32,
+    "add_contact_button": {
+      "margin": {
+        "left": 6
+      },
+      "color": "#393939",
+      "button_width": 8,
+      "icon_width": 8
+    },
+    "row": {
+      "padding": {
+        "left": 8
+      }
+    },
+    "row_height": 28,
+    "header": {
+      "family": "Zed Mono",
+      "color": "#474747",
+      "size": 14,
+      "margin": {
+        "top": 8
+      }
+    },
     "tree_branch_color": "#e3e3e3",
     "tree_branch_width": 1,
-    "host_avatar": {
+    "contact_avatar": {
       "corner_radius": 10,
       "width": 18
     },
-    "host_username": {
+    "contact_username": {
       "family": "Zed Mono",
       "color": "#2b2b2b",
       "size": 14,
@@ -1227,6 +1277,23 @@
         "left": 8
       }
     },
+    "contact_button": {
+      "background": "#eaeaea",
+      "color": "#393939",
+      "icon_width": 8,
+      "button_width": 16,
+      "corner_radius": 8,
+      "hover": {
+        "background": "#e3e3e3"
+      }
+    },
+    "disabled_contact_button": {
+      "background": "#eaeaea",
+      "color": "#9c9c9c",
+      "icon_width": 8,
+      "button_width": 16,
+      "corner_radius": 8
+    },
     "project": {
       "guest_avatar_spacing": 4,
       "height": 24,
@@ -1328,6 +1395,122 @@
       "corner_radius": 6
     }
   },
+  "contact_finder": {
+    "background": "#f8f8f8",
+    "corner_radius": 8,
+    "padding": 8,
+    "item": {
+      "padding": {
+        "bottom": 4,
+        "left": 12,
+        "right": 12,
+        "top": 4
+      },
+      "corner_radius": 8,
+      "text": {
+        "family": "Zed Sans",
+        "color": "#474747",
+        "size": 14
+      },
+      "highlight_text": {
+        "family": "Zed Sans",
+        "color": "#484bed",
+        "weight": "bold",
+        "size": 14
+      },
+      "active": {
+        "background": "#e3e3e3",
+        "text": {
+          "family": "Zed Sans",
+          "color": "#2b2b2b",
+          "size": 14
+        }
+      },
+      "hover": {
+        "background": "#eaeaea"
+      }
+    },
+    "border": {
+      "color": "#d5d5d5",
+      "width": 1
+    },
+    "empty": {
+      "text": {
+        "family": "Zed Sans",
+        "color": "#808080",
+        "size": 14
+      },
+      "padding": {
+        "bottom": 4,
+        "left": 12,
+        "right": 12,
+        "top": 8
+      }
+    },
+    "input_editor": {
+      "background": "#ffffff",
+      "corner_radius": 8,
+      "placeholder_text": {
+        "family": "Zed Sans",
+        "color": "#808080",
+        "size": 14
+      },
+      "selection": {
+        "cursor": "#2472f2",
+        "selection": "#2472f23d"
+      },
+      "text": {
+        "family": "Zed Mono",
+        "color": "#2b2b2b",
+        "size": 14
+      },
+      "border": {
+        "color": "#d5d5d5",
+        "width": 1
+      },
+      "padding": {
+        "bottom": 7,
+        "left": 16,
+        "right": 16,
+        "top": 7
+      }
+    },
+    "shadow": {
+      "blur": 16,
+      "color": "#0000001f",
+      "offset": [
+        0,
+        2
+      ]
+    },
+    "row_height": 28,
+    "contact_avatar": {
+      "corner_radius": 10,
+      "width": 18
+    },
+    "contact_username": {
+      "padding": {
+        "left": 8
+      }
+    },
+    "contact_button": {
+      "background": "#eaeaea",
+      "color": "#393939",
+      "icon_width": 8,
+      "button_width": 16,
+      "corner_radius": 8,
+      "hover": {
+        "background": "#e3e3e3"
+      }
+    },
+    "disabled_contact_button": {
+      "background": "#eaeaea",
+      "color": "#9c9c9c",
+      "icon_width": 8,
+      "button_width": 16,
+      "corner_radius": 8
+    }
+  },
   "search": {
     "match_background": "#fce9b7",
     "tab_icon_spacing": 8,

assets/themes/solarized-dark.json 🔗

@@ -1212,14 +1212,64 @@
       "bottom": 12,
       "right": 12
     },
-    "host_row_height": 28,
+    "user_query_editor": {
+      "background": "#002b36",
+      "corner_radius": 6,
+      "text": {
+        "family": "Zed Mono",
+        "color": "#eee8d5",
+        "size": 14
+      },
+      "placeholder_text": {
+        "family": "Zed Mono",
+        "color": "#839496",
+        "size": 14
+      },
+      "selection": {
+        "cursor": "#268bd2",
+        "selection": "#268bd23d"
+      },
+      "border": {
+        "color": "#073642",
+        "width": 1
+      },
+      "padding": {
+        "bottom": 4,
+        "left": 8,
+        "right": 8,
+        "top": 4
+      }
+    },
+    "user_query_editor_height": 32,
+    "add_contact_button": {
+      "margin": {
+        "left": 6
+      },
+      "color": "#eee8d5",
+      "button_width": 8,
+      "icon_width": 8
+    },
+    "row": {
+      "padding": {
+        "left": 8
+      }
+    },
+    "row_height": 28,
+    "header": {
+      "family": "Zed Mono",
+      "color": "#93a1a1",
+      "size": 14,
+      "margin": {
+        "top": 8
+      }
+    },
     "tree_branch_color": "#657b83",
     "tree_branch_width": 1,
-    "host_avatar": {
+    "contact_avatar": {
       "corner_radius": 10,
       "width": 18
     },
-    "host_username": {
+    "contact_username": {
       "family": "Zed Mono",
       "color": "#eee8d5",
       "size": 14,
@@ -1227,6 +1277,23 @@
         "left": 8
       }
     },
+    "contact_button": {
+      "background": "#073642",
+      "color": "#eee8d5",
+      "icon_width": 8,
+      "button_width": 16,
+      "corner_radius": 8,
+      "hover": {
+        "background": "#586e753d"
+      }
+    },
+    "disabled_contact_button": {
+      "background": "#073642",
+      "color": "#93a1a1",
+      "icon_width": 8,
+      "button_width": 16,
+      "corner_radius": 8
+    },
     "project": {
       "guest_avatar_spacing": 4,
       "height": 24,
@@ -1328,6 +1395,122 @@
       "corner_radius": 6
     }
   },
+  "contact_finder": {
+    "background": "#073642",
+    "corner_radius": 8,
+    "padding": 8,
+    "item": {
+      "padding": {
+        "bottom": 4,
+        "left": 12,
+        "right": 12,
+        "top": 4
+      },
+      "corner_radius": 8,
+      "text": {
+        "family": "Zed Sans",
+        "color": "#93a1a1",
+        "size": 14
+      },
+      "highlight_text": {
+        "family": "Zed Sans",
+        "color": "#268bd2",
+        "weight": "bold",
+        "size": 14
+      },
+      "active": {
+        "background": "#586e755c",
+        "text": {
+          "family": "Zed Sans",
+          "color": "#eee8d5",
+          "size": 14
+        }
+      },
+      "hover": {
+        "background": "#586e753d"
+      }
+    },
+    "border": {
+      "color": "#002b36",
+      "width": 1
+    },
+    "empty": {
+      "text": {
+        "family": "Zed Sans",
+        "color": "#839496",
+        "size": 14
+      },
+      "padding": {
+        "bottom": 4,
+        "left": 12,
+        "right": 12,
+        "top": 8
+      }
+    },
+    "input_editor": {
+      "background": "#002b36",
+      "corner_radius": 8,
+      "placeholder_text": {
+        "family": "Zed Sans",
+        "color": "#839496",
+        "size": 14
+      },
+      "selection": {
+        "cursor": "#268bd2",
+        "selection": "#268bd23d"
+      },
+      "text": {
+        "family": "Zed Mono",
+        "color": "#eee8d5",
+        "size": 14
+      },
+      "border": {
+        "color": "#073642",
+        "width": 1
+      },
+      "padding": {
+        "bottom": 7,
+        "left": 16,
+        "right": 16,
+        "top": 7
+      }
+    },
+    "shadow": {
+      "blur": 16,
+      "color": "#0000003d",
+      "offset": [
+        0,
+        2
+      ]
+    },
+    "row_height": 28,
+    "contact_avatar": {
+      "corner_radius": 10,
+      "width": 18
+    },
+    "contact_username": {
+      "padding": {
+        "left": 8
+      }
+    },
+    "contact_button": {
+      "background": "#073642",
+      "color": "#eee8d5",
+      "icon_width": 8,
+      "button_width": 16,
+      "corner_radius": 8,
+      "hover": {
+        "background": "#586e753d"
+      }
+    },
+    "disabled_contact_button": {
+      "background": "#073642",
+      "color": "#93a1a1",
+      "icon_width": 8,
+      "button_width": 16,
+      "corner_radius": 8
+    }
+  },
   "search": {
     "match_background": "#6c71c47a",
     "tab_icon_spacing": 8,

assets/themes/solarized-light.json 🔗

@@ -1212,14 +1212,64 @@
       "bottom": 12,
       "right": 12
     },
-    "host_row_height": 28,
+    "user_query_editor": {
+      "background": "#fdf6e3",
+      "corner_radius": 6,
+      "text": {
+        "family": "Zed Mono",
+        "color": "#073642",
+        "size": 14
+      },
+      "placeholder_text": {
+        "family": "Zed Mono",
+        "color": "#657b83",
+        "size": 14
+      },
+      "selection": {
+        "cursor": "#268bd2",
+        "selection": "#268bd23d"
+      },
+      "border": {
+        "color": "#eee8d5",
+        "width": 1
+      },
+      "padding": {
+        "bottom": 4,
+        "left": 8,
+        "right": 8,
+        "top": 4
+      }
+    },
+    "user_query_editor_height": 32,
+    "add_contact_button": {
+      "margin": {
+        "left": 6
+      },
+      "color": "#073642",
+      "button_width": 8,
+      "icon_width": 8
+    },
+    "row": {
+      "padding": {
+        "left": 8
+      }
+    },
+    "row_height": 28,
+    "header": {
+      "family": "Zed Mono",
+      "color": "#586e75",
+      "size": 14,
+      "margin": {
+        "top": 8
+      }
+    },
     "tree_branch_color": "#839496",
     "tree_branch_width": 1,
-    "host_avatar": {
+    "contact_avatar": {
       "corner_radius": 10,
       "width": 18
     },
-    "host_username": {
+    "contact_username": {
       "family": "Zed Mono",
       "color": "#073642",
       "size": 14,
@@ -1227,6 +1277,23 @@
         "left": 8
       }
     },
+    "contact_button": {
+      "background": "#eee8d5",
+      "color": "#073642",
+      "icon_width": 8,
+      "button_width": 16,
+      "corner_radius": 8,
+      "hover": {
+        "background": "#93a1a11f"
+      }
+    },
+    "disabled_contact_button": {
+      "background": "#eee8d5",
+      "color": "#586e75",
+      "icon_width": 8,
+      "button_width": 16,
+      "corner_radius": 8
+    },
     "project": {
       "guest_avatar_spacing": 4,
       "height": 24,
@@ -1328,6 +1395,122 @@
       "corner_radius": 6
     }
   },
+  "contact_finder": {
+    "background": "#eee8d5",
+    "corner_radius": 8,
+    "padding": 8,
+    "item": {
+      "padding": {
+        "bottom": 4,
+        "left": 12,
+        "right": 12,
+        "top": 4
+      },
+      "corner_radius": 8,
+      "text": {
+        "family": "Zed Sans",
+        "color": "#586e75",
+        "size": 14
+      },
+      "highlight_text": {
+        "family": "Zed Sans",
+        "color": "#268bd2",
+        "weight": "bold",
+        "size": 14
+      },
+      "active": {
+        "background": "#93a1a12e",
+        "text": {
+          "family": "Zed Sans",
+          "color": "#073642",
+          "size": 14
+        }
+      },
+      "hover": {
+        "background": "#93a1a11f"
+      }
+    },
+    "border": {
+      "color": "#fdf6e3",
+      "width": 1
+    },
+    "empty": {
+      "text": {
+        "family": "Zed Sans",
+        "color": "#657b83",
+        "size": 14
+      },
+      "padding": {
+        "bottom": 4,
+        "left": 12,
+        "right": 12,
+        "top": 8
+      }
+    },
+    "input_editor": {
+      "background": "#fdf6e3",
+      "corner_radius": 8,
+      "placeholder_text": {
+        "family": "Zed Sans",
+        "color": "#657b83",
+        "size": 14
+      },
+      "selection": {
+        "cursor": "#268bd2",
+        "selection": "#268bd23d"
+      },
+      "text": {
+        "family": "Zed Mono",
+        "color": "#073642",
+        "size": 14
+      },
+      "border": {
+        "color": "#eee8d5",
+        "width": 1
+      },
+      "padding": {
+        "bottom": 7,
+        "left": 16,
+        "right": 16,
+        "top": 7
+      }
+    },
+    "shadow": {
+      "blur": 16,
+      "color": "#0000001f",
+      "offset": [
+        0,
+        2
+      ]
+    },
+    "row_height": 28,
+    "contact_avatar": {
+      "corner_radius": 10,
+      "width": 18
+    },
+    "contact_username": {
+      "padding": {
+        "left": 8
+      }
+    },
+    "contact_button": {
+      "background": "#eee8d5",
+      "color": "#073642",
+      "icon_width": 8,
+      "button_width": 16,
+      "corner_radius": 8,
+      "hover": {
+        "background": "#93a1a11f"
+      }
+    },
+    "disabled_contact_button": {
+      "background": "#eee8d5",
+      "color": "#586e75",
+      "icon_width": 8,
+      "button_width": 16,
+      "corner_radius": 8
+    }
+  },
   "search": {
     "match_background": "#6c71c43d",
     "tab_icon_spacing": 8,

assets/themes/sulphurpool-dark.json 🔗

@@ -1212,14 +1212,64 @@
       "bottom": 12,
       "right": 12
     },
-    "host_row_height": 28,
+    "user_query_editor": {
+      "background": "#202746",
+      "corner_radius": 6,
+      "text": {
+        "family": "Zed Mono",
+        "color": "#dfe2f1",
+        "size": 14
+      },
+      "placeholder_text": {
+        "family": "Zed Mono",
+        "color": "#898ea4",
+        "size": 14
+      },
+      "selection": {
+        "cursor": "#3d8fd1",
+        "selection": "#3d8fd13d"
+      },
+      "border": {
+        "color": "#293256",
+        "width": 1
+      },
+      "padding": {
+        "bottom": 4,
+        "left": 8,
+        "right": 8,
+        "top": 4
+      }
+    },
+    "user_query_editor_height": 32,
+    "add_contact_button": {
+      "margin": {
+        "left": 6
+      },
+      "color": "#dfe2f1",
+      "button_width": 8,
+      "icon_width": 8
+    },
+    "row": {
+      "padding": {
+        "left": 8
+      }
+    },
+    "row_height": 28,
+    "header": {
+      "family": "Zed Mono",
+      "color": "#979db4",
+      "size": 14,
+      "margin": {
+        "top": 8
+      }
+    },
     "tree_branch_color": "#6b7394",
     "tree_branch_width": 1,
-    "host_avatar": {
+    "contact_avatar": {
       "corner_radius": 10,
       "width": 18
     },
-    "host_username": {
+    "contact_username": {
       "family": "Zed Mono",
       "color": "#dfe2f1",
       "size": 14,
@@ -1227,6 +1277,23 @@
         "left": 8
       }
     },
+    "contact_button": {
+      "background": "#293256",
+      "color": "#dfe2f1",
+      "icon_width": 8,
+      "button_width": 16,
+      "corner_radius": 8,
+      "hover": {
+        "background": "#5e66873d"
+      }
+    },
+    "disabled_contact_button": {
+      "background": "#293256",
+      "color": "#979db4",
+      "icon_width": 8,
+      "button_width": 16,
+      "corner_radius": 8
+    },
     "project": {
       "guest_avatar_spacing": 4,
       "height": 24,
@@ -1328,6 +1395,122 @@
       "corner_radius": 6
     }
   },
+  "contact_finder": {
+    "background": "#293256",
+    "corner_radius": 8,
+    "padding": 8,
+    "item": {
+      "padding": {
+        "bottom": 4,
+        "left": 12,
+        "right": 12,
+        "top": 4
+      },
+      "corner_radius": 8,
+      "text": {
+        "family": "Zed Sans",
+        "color": "#979db4",
+        "size": 14
+      },
+      "highlight_text": {
+        "family": "Zed Sans",
+        "color": "#3d8fd1",
+        "weight": "bold",
+        "size": 14
+      },
+      "active": {
+        "background": "#5e66875c",
+        "text": {
+          "family": "Zed Sans",
+          "color": "#dfe2f1",
+          "size": 14
+        }
+      },
+      "hover": {
+        "background": "#5e66873d"
+      }
+    },
+    "border": {
+      "color": "#202746",
+      "width": 1
+    },
+    "empty": {
+      "text": {
+        "family": "Zed Sans",
+        "color": "#898ea4",
+        "size": 14
+      },
+      "padding": {
+        "bottom": 4,
+        "left": 12,
+        "right": 12,
+        "top": 8
+      }
+    },
+    "input_editor": {
+      "background": "#202746",
+      "corner_radius": 8,
+      "placeholder_text": {
+        "family": "Zed Sans",
+        "color": "#898ea4",
+        "size": 14
+      },
+      "selection": {
+        "cursor": "#3d8fd1",
+        "selection": "#3d8fd13d"
+      },
+      "text": {
+        "family": "Zed Mono",
+        "color": "#dfe2f1",
+        "size": 14
+      },
+      "border": {
+        "color": "#293256",
+        "width": 1
+      },
+      "padding": {
+        "bottom": 7,
+        "left": 16,
+        "right": 16,
+        "top": 7
+      }
+    },
+    "shadow": {
+      "blur": 16,
+      "color": "#0000003d",
+      "offset": [
+        0,
+        2
+      ]
+    },
+    "row_height": 28,
+    "contact_avatar": {
+      "corner_radius": 10,
+      "width": 18
+    },
+    "contact_username": {
+      "padding": {
+        "left": 8
+      }
+    },
+    "contact_button": {
+      "background": "#293256",
+      "color": "#dfe2f1",
+      "icon_width": 8,
+      "button_width": 16,
+      "corner_radius": 8,
+      "hover": {
+        "background": "#5e66873d"
+      }
+    },
+    "disabled_contact_button": {
+      "background": "#293256",
+      "color": "#979db4",
+      "icon_width": 8,
+      "button_width": 16,
+      "corner_radius": 8
+    }
+  },
   "search": {
     "match_background": "#6679cc7a",
     "tab_icon_spacing": 8,

assets/themes/sulphurpool-light.json 🔗

@@ -1212,14 +1212,64 @@
       "bottom": 12,
       "right": 12
     },
-    "host_row_height": 28,
+    "user_query_editor": {
+      "background": "#f5f7ff",
+      "corner_radius": 6,
+      "text": {
+        "family": "Zed Mono",
+        "color": "#293256",
+        "size": 14
+      },
+      "placeholder_text": {
+        "family": "Zed Mono",
+        "color": "#6b7394",
+        "size": 14
+      },
+      "selection": {
+        "cursor": "#3d8fd1",
+        "selection": "#3d8fd13d"
+      },
+      "border": {
+        "color": "#dfe2f1",
+        "width": 1
+      },
+      "padding": {
+        "bottom": 4,
+        "left": 8,
+        "right": 8,
+        "top": 4
+      }
+    },
+    "user_query_editor_height": 32,
+    "add_contact_button": {
+      "margin": {
+        "left": 6
+      },
+      "color": "#293256",
+      "button_width": 8,
+      "icon_width": 8
+    },
+    "row": {
+      "padding": {
+        "left": 8
+      }
+    },
+    "row_height": 28,
+    "header": {
+      "family": "Zed Mono",
+      "color": "#5e6687",
+      "size": 14,
+      "margin": {
+        "top": 8
+      }
+    },
     "tree_branch_color": "#898ea4",
     "tree_branch_width": 1,
-    "host_avatar": {
+    "contact_avatar": {
       "corner_radius": 10,
       "width": 18
     },
-    "host_username": {
+    "contact_username": {
       "family": "Zed Mono",
       "color": "#293256",
       "size": 14,
@@ -1227,6 +1277,23 @@
         "left": 8
       }
     },
+    "contact_button": {
+      "background": "#dfe2f1",
+      "color": "#293256",
+      "icon_width": 8,
+      "button_width": 16,
+      "corner_radius": 8,
+      "hover": {
+        "background": "#979db41f"
+      }
+    },
+    "disabled_contact_button": {
+      "background": "#dfe2f1",
+      "color": "#5e6687",
+      "icon_width": 8,
+      "button_width": 16,
+      "corner_radius": 8
+    },
     "project": {
       "guest_avatar_spacing": 4,
       "height": 24,
@@ -1328,6 +1395,122 @@
       "corner_radius": 6
     }
   },
+  "contact_finder": {
+    "background": "#dfe2f1",
+    "corner_radius": 8,
+    "padding": 8,
+    "item": {
+      "padding": {
+        "bottom": 4,
+        "left": 12,
+        "right": 12,
+        "top": 4
+      },
+      "corner_radius": 8,
+      "text": {
+        "family": "Zed Sans",
+        "color": "#5e6687",
+        "size": 14
+      },
+      "highlight_text": {
+        "family": "Zed Sans",
+        "color": "#3d8fd1",
+        "weight": "bold",
+        "size": 14
+      },
+      "active": {
+        "background": "#979db42e",
+        "text": {
+          "family": "Zed Sans",
+          "color": "#293256",
+          "size": 14
+        }
+      },
+      "hover": {
+        "background": "#979db41f"
+      }
+    },
+    "border": {
+      "color": "#f5f7ff",
+      "width": 1
+    },
+    "empty": {
+      "text": {
+        "family": "Zed Sans",
+        "color": "#6b7394",
+        "size": 14
+      },
+      "padding": {
+        "bottom": 4,
+        "left": 12,
+        "right": 12,
+        "top": 8
+      }
+    },
+    "input_editor": {
+      "background": "#f5f7ff",
+      "corner_radius": 8,
+      "placeholder_text": {
+        "family": "Zed Sans",
+        "color": "#6b7394",
+        "size": 14
+      },
+      "selection": {
+        "cursor": "#3d8fd1",
+        "selection": "#3d8fd13d"
+      },
+      "text": {
+        "family": "Zed Mono",
+        "color": "#293256",
+        "size": 14
+      },
+      "border": {
+        "color": "#dfe2f1",
+        "width": 1
+      },
+      "padding": {
+        "bottom": 7,
+        "left": 16,
+        "right": 16,
+        "top": 7
+      }
+    },
+    "shadow": {
+      "blur": 16,
+      "color": "#0000001f",
+      "offset": [
+        0,
+        2
+      ]
+    },
+    "row_height": 28,
+    "contact_avatar": {
+      "corner_radius": 10,
+      "width": 18
+    },
+    "contact_username": {
+      "padding": {
+        "left": 8
+      }
+    },
+    "contact_button": {
+      "background": "#dfe2f1",
+      "color": "#293256",
+      "icon_width": 8,
+      "button_width": 16,
+      "corner_radius": 8,
+      "hover": {
+        "background": "#979db41f"
+      }
+    },
+    "disabled_contact_button": {
+      "background": "#dfe2f1",
+      "color": "#5e6687",
+      "icon_width": 8,
+      "button_width": 16,
+      "corner_radius": 8
+    }
+  },
   "search": {
     "match_background": "#6679cc3d",
     "tab_icon_spacing": 8,

crates/client/src/channel.rs 🔗

@@ -500,7 +500,7 @@ async fn messages_from_proto(
         .collect();
     user_store
         .update(cx, |user_store, cx| {
-            user_store.load_users(unique_user_ids, cx)
+            user_store.get_users(unique_user_ids, cx)
         })
         .await?;
 
@@ -639,7 +639,7 @@ mod tests {
         server
             .respond(
                 get_users.receipt(),
-                proto::GetUsersResponse {
+                proto::UsersResponse {
                     users: vec![proto::User {
                         id: 5,
                         github_login: "nathansobo".into(),
@@ -690,7 +690,7 @@ mod tests {
         server
             .respond(
                 get_users.receipt(),
-                proto::GetUsersResponse {
+                proto::UsersResponse {
                     users: vec![proto::User {
                         id: 6,
                         github_login: "maxbrunsfeld".into(),
@@ -738,7 +738,7 @@ mod tests {
         server
             .respond(
                 get_users.receipt(),
-                proto::GetUsersResponse {
+                proto::UsersResponse {
                     users: vec![proto::User {
                         id: 7,
                         github_login: "as-cii".into(),

crates/client/src/client.rs 🔗

@@ -117,7 +117,7 @@ impl EstablishConnectionError {
     }
 }
 
-#[derive(Copy, Clone, Debug)]
+#[derive(Copy, Clone, Debug, Eq, PartialEq)]
 pub enum Status {
     SignedOut,
     UpgradeRequired,
@@ -293,6 +293,7 @@ impl Client {
     }
 
     fn set_status(self: &Arc<Self>, status: Status, cx: &AsyncAppContext) {
+        log::info!("set status on client {}: {:?}", self.id, status);
         let mut state = self.state.write();
         *state.status.0.borrow_mut() = status;
 
@@ -629,10 +630,13 @@ impl Client {
 
     async fn set_connection(self: &Arc<Self>, conn: Connection, cx: &AsyncAppContext) {
         let executor = cx.background();
+        log::info!("add connection to peer");
         let (connection_id, handle_io, mut incoming) = self
             .peer
             .add_connection(conn, move |duration| executor.timer(duration))
             .await;
+        log::info!("set status to connected {}", connection_id);
+        self.set_status(Status::Connected { connection_id }, cx);
         cx.foreground()
             .spawn({
                 let cx = cx.clone();
@@ -730,15 +734,17 @@ impl Client {
             })
             .detach();
 
-        self.set_status(Status::Connected { connection_id }, cx);
-
         let handle_io = cx.background().spawn(handle_io);
         let this = self.clone();
         let cx = cx.clone();
         cx.foreground()
             .spawn(async move {
                 match handle_io.await {
-                    Ok(()) => this.set_status(Status::SignedOut, &cx),
+                    Ok(()) => {
+                        if *this.status().borrow() == (Status::Connected { connection_id }) {
+                            this.set_status(Status::SignedOut, &cx);
+                        }
+                    }
                     Err(err) => {
                         log::error!("connection error: {:?}", err);
                         this.set_status(Status::ConnectionLost, &cx);

crates/client/src/user.rs 🔗

@@ -1,10 +1,11 @@
 use super::{http::HttpClient, proto, Client, Status, TypedEnvelope};
-use anyhow::{anyhow, Result};
-use futures::{future, AsyncReadExt};
+use anyhow::{anyhow, Context, Result};
+use futures::{channel::mpsc, future, AsyncReadExt, Future, StreamExt};
 use gpui::{AsyncAppContext, Entity, ImageData, ModelContext, ModelHandle, Task};
 use postage::{prelude::Stream, sink::Sink, watch};
+use rpc::proto::{RequestMessage, UsersResponse};
 use std::{
-    collections::{HashMap, HashSet},
+    collections::{hash_map::Entry, HashMap, HashSet},
     sync::{Arc, Weak},
 };
 use util::TryFutureExt as _;
@@ -19,6 +20,7 @@ pub struct User {
 #[derive(Debug)]
 pub struct Contact {
     pub user: Arc<User>,
+    pub online: bool,
     pub projects: Vec<ProjectMetadata>,
 }
 
@@ -30,11 +32,22 @@ pub struct ProjectMetadata {
     pub guests: Vec<Arc<User>>,
 }
 
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ContactRequestStatus {
+    None,
+    RequestSent,
+    RequestReceived,
+    RequestAccepted,
+}
+
 pub struct UserStore {
     users: HashMap<u64, Arc<User>>,
-    update_contacts_tx: watch::Sender<Option<proto::UpdateContacts>>,
+    update_contacts_tx: mpsc::UnboundedSender<UpdateContacts>,
     current_user: watch::Receiver<Option<Arc<User>>>,
-    contacts: Arc<[Contact]>,
+    contacts: Vec<Arc<Contact>>,
+    incoming_contact_requests: Vec<Arc<User>>,
+    outgoing_contact_requests: Vec<Arc<User>>,
+    pending_contact_requests: HashMap<u64, usize>,
     client: Weak<Client>,
     http: Arc<dyn HttpClient>,
     _maintain_contacts: Task<()>,
@@ -47,6 +60,11 @@ impl Entity for UserStore {
     type Event = Event;
 }
 
+enum UpdateContacts {
+    Update(proto::UpdateContacts),
+    Clear(postage::barrier::Sender),
+}
+
 impl UserStore {
     pub fn new(
         client: Arc<Client>,
@@ -54,21 +72,22 @@ impl UserStore {
         cx: &mut ModelContext<Self>,
     ) -> Self {
         let (mut current_user_tx, current_user_rx) = watch::channel();
-        let (update_contacts_tx, mut update_contacts_rx) =
-            watch::channel::<Option<proto::UpdateContacts>>();
+        let (update_contacts_tx, mut update_contacts_rx) = mpsc::unbounded();
         let rpc_subscription =
             client.add_message_handler(cx.handle(), Self::handle_update_contacts);
         Self {
             users: Default::default(),
             current_user: current_user_rx,
-            contacts: Arc::from([]),
+            contacts: Default::default(),
+            incoming_contact_requests: Default::default(),
+            outgoing_contact_requests: Default::default(),
             client: Arc::downgrade(&client),
             update_contacts_tx,
             http,
             _maintain_contacts: cx.spawn_weak(|this, mut cx| async move {
                 let _subscription = rpc_subscription;
-                while let Some(message) = update_contacts_rx.recv().await {
-                    if let Some((message, this)) = message.zip(this.upgrade(&cx)) {
+                while let Some(message) = update_contacts_rx.next().await {
+                    if let Some(this) = this.upgrade(&cx) {
                         this.update(&mut cx, |this, cx| this.update_contacts(message, cx))
                             .log_err()
                             .await;
@@ -90,11 +109,20 @@ impl UserStore {
                         }
                         Status::SignedOut => {
                             current_user_tx.send(None).await.ok();
+                            if let Some(this) = this.upgrade(&cx) {
+                                this.update(&mut cx, |this, _| this.clear_contacts()).await;
+                            }
+                        }
+                        Status::ConnectionLost => {
+                            if let Some(this) = this.upgrade(&cx) {
+                                this.update(&mut cx, |this, _| this.clear_contacts()).await;
+                            }
                         }
                         _ => {}
                     }
                 }
             }),
+            pending_contact_requests: Default::default(),
         }
     }
 
@@ -105,76 +133,278 @@ impl UserStore {
         mut cx: AsyncAppContext,
     ) -> Result<()> {
         this.update(&mut cx, |this, _| {
-            *this.update_contacts_tx.borrow_mut() = Some(msg.payload);
+            this.update_contacts_tx
+                .unbounded_send(UpdateContacts::Update(msg.payload))
+                .unwrap();
         });
         Ok(())
     }
 
     fn update_contacts(
         &mut self,
-        message: proto::UpdateContacts,
+        message: UpdateContacts,
         cx: &mut ModelContext<Self>,
     ) -> Task<Result<()>> {
-        let mut user_ids = HashSet::new();
-        for contact in &message.contacts {
-            user_ids.insert(contact.user_id);
-            user_ids.extend(contact.projects.iter().flat_map(|w| &w.guests).copied());
-        }
+        match message {
+            UpdateContacts::Clear(barrier) => {
+                self.contacts.clear();
+                self.incoming_contact_requests.clear();
+                self.outgoing_contact_requests.clear();
+                drop(barrier);
+                Task::ready(Ok(()))
+            }
+            UpdateContacts::Update(message) => {
+                log::info!(
+                    "update contacts on client {}: {:?}",
+                    self.client.upgrade().unwrap().id,
+                    message
+                );
+                let mut user_ids = HashSet::new();
+                for contact in &message.contacts {
+                    user_ids.insert(contact.user_id);
+                    user_ids.extend(contact.projects.iter().flat_map(|w| &w.guests).copied());
+                }
+                user_ids.extend(message.incoming_requests.iter().map(|req| req.requester_id));
+                user_ids.extend(message.outgoing_requests.iter());
 
-        let load_users = self.load_users(user_ids.into_iter().collect(), cx);
-        cx.spawn(|this, mut cx| async move {
-            load_users.await?;
+                let load_users = self.get_users(user_ids.into_iter().collect(), cx);
+                cx.spawn(|this, mut cx| async move {
+                    load_users.await?;
+
+                    // Users are fetched in parallel above and cached in call to get_users
+                    // No need to paralellize here
+                    let mut updated_contacts = Vec::new();
+                    for contact in message.contacts {
+                        updated_contacts.push(Arc::new(
+                            Contact::from_proto(contact, &this, &mut cx).await?,
+                        ));
+                    }
+
+                    let mut incoming_requests = Vec::new();
+                    for request in message.incoming_requests {
+                        incoming_requests.push(
+                            this.update(&mut cx, |this, cx| {
+                                this.fetch_user(request.requester_id, cx)
+                            })
+                            .await?,
+                        );
+                    }
+
+                    let mut outgoing_requests = Vec::new();
+                    for requested_user_id in message.outgoing_requests {
+                        outgoing_requests.push(
+                            this.update(&mut cx, |this, cx| this.fetch_user(requested_user_id, cx))
+                                .await?,
+                        );
+                    }
+
+                    let removed_contacts =
+                        HashSet::<u64>::from_iter(message.remove_contacts.iter().copied());
+                    let removed_incoming_requests =
+                        HashSet::<u64>::from_iter(message.remove_incoming_requests.iter().copied());
+                    let removed_outgoing_requests =
+                        HashSet::<u64>::from_iter(message.remove_outgoing_requests.iter().copied());
+
+                    this.update(&mut cx, |this, cx| {
+                        // Remove contacts
+                        this.contacts
+                            .retain(|contact| !removed_contacts.contains(&contact.user.id));
+                        // Update existing contacts and insert new ones
+                        for updated_contact in updated_contacts {
+                            match this.contacts.binary_search_by_key(
+                                &&updated_contact.user.github_login,
+                                |contact| &contact.user.github_login,
+                            ) {
+                                Ok(ix) => this.contacts[ix] = updated_contact,
+                                Err(ix) => this.contacts.insert(ix, updated_contact),
+                            }
+                        }
+
+                        // Remove incoming contact requests
+                        this.incoming_contact_requests
+                            .retain(|user| !removed_incoming_requests.contains(&user.id));
+                        // Update existing incoming requests and insert new ones
+                        for request in incoming_requests {
+                            match this
+                                .incoming_contact_requests
+                                .binary_search_by_key(&&request.github_login, |contact| {
+                                    &contact.github_login
+                                }) {
+                                Ok(ix) => this.incoming_contact_requests[ix] = request,
+                                Err(ix) => this.incoming_contact_requests.insert(ix, request),
+                            }
+                        }
+
+                        // Remove outgoing contact requests
+                        this.outgoing_contact_requests
+                            .retain(|user| !removed_outgoing_requests.contains(&user.id));
+                        // Update existing incoming requests and insert new ones
+                        for request in outgoing_requests {
+                            match this
+                                .outgoing_contact_requests
+                                .binary_search_by_key(&&request.github_login, |contact| {
+                                    &contact.github_login
+                                }) {
+                                Ok(ix) => this.outgoing_contact_requests[ix] = request,
+                                Err(ix) => this.outgoing_contact_requests.insert(ix, request),
+                            }
+                        }
 
-            let mut contacts = Vec::new();
-            for contact in message.contacts {
-                contacts.push(Contact::from_proto(contact, &this, &mut cx).await?);
+                        cx.notify();
+                    });
+
+                    Ok(())
+                })
             }
+        }
+    }
+
+    pub fn contacts(&self) -> &[Arc<Contact>] {
+        &self.contacts
+    }
+
+    pub fn has_contact(&self, user: &Arc<User>) -> bool {
+        self.contacts
+            .binary_search_by_key(&&user.github_login, |contact| &contact.user.github_login)
+            .is_ok()
+    }
+
+    pub fn incoming_contact_requests(&self) -> &[Arc<User>] {
+        &self.incoming_contact_requests
+    }
+
+    pub fn outgoing_contact_requests(&self) -> &[Arc<User>] {
+        &self.outgoing_contact_requests
+    }
+
+    pub fn is_contact_request_pending(&self, user: &User) -> bool {
+        self.pending_contact_requests.contains_key(&user.id)
+    }
+
+    pub fn contact_request_status(&self, user: &User) -> ContactRequestStatus {
+        if self
+            .contacts
+            .binary_search_by_key(&&user.github_login, |contact| &contact.user.github_login)
+            .is_ok()
+        {
+            ContactRequestStatus::RequestAccepted
+        } else if self
+            .outgoing_contact_requests
+            .binary_search_by_key(&&user.github_login, |user| &user.github_login)
+            .is_ok()
+        {
+            ContactRequestStatus::RequestSent
+        } else if self
+            .incoming_contact_requests
+            .binary_search_by_key(&&user.github_login, |user| &user.github_login)
+            .is_ok()
+        {
+            ContactRequestStatus::RequestReceived
+        } else {
+            ContactRequestStatus::None
+        }
+    }
+
+    pub fn request_contact(
+        &mut self,
+        responder_id: u64,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<()>> {
+        self.perform_contact_request(responder_id, proto::RequestContact { responder_id }, cx)
+    }
 
+    pub fn remove_contact(
+        &mut self,
+        user_id: u64,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<()>> {
+        self.perform_contact_request(user_id, proto::RemoveContact { user_id }, cx)
+    }
+
+    pub fn respond_to_contact_request(
+        &mut self,
+        requester_id: u64,
+        accept: bool,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<()>> {
+        self.perform_contact_request(
+            requester_id,
+            proto::RespondToContactRequest {
+                requester_id,
+                response: if accept {
+                    proto::ContactRequestResponse::Accept
+                } else {
+                    proto::ContactRequestResponse::Reject
+                } as i32,
+            },
+            cx,
+        )
+    }
+
+    fn perform_contact_request<T: RequestMessage>(
+        &mut self,
+        user_id: u64,
+        request: T,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<()>> {
+        let client = self.client.upgrade();
+        *self.pending_contact_requests.entry(user_id).or_insert(0) += 1;
+        cx.notify();
+
+        cx.spawn(|this, mut cx| async move {
+            let response = client
+                .ok_or_else(|| anyhow!("can't upgrade client reference"))?
+                .request(request)
+                .await;
             this.update(&mut cx, |this, cx| {
-                contacts.sort_by(|a, b| a.user.github_login.cmp(&b.user.github_login));
-                this.contacts = contacts.into();
+                if let Entry::Occupied(mut request_count) =
+                    this.pending_contact_requests.entry(user_id)
+                {
+                    *request_count.get_mut() -= 1;
+                    if *request_count.get() == 0 {
+                        request_count.remove();
+                    }
+                }
                 cx.notify();
             });
-
+            response?;
             Ok(())
         })
     }
 
-    pub fn contacts(&self) -> &Arc<[Contact]> {
-        &self.contacts
+    pub fn clear_contacts(&mut self) -> impl Future<Output = ()> {
+        let (tx, mut rx) = postage::barrier::channel();
+        self.update_contacts_tx
+            .unbounded_send(UpdateContacts::Clear(tx))
+            .unwrap();
+        async move {
+            rx.recv().await;
+        }
     }
 
-    pub fn load_users(
+    pub fn get_users(
         &mut self,
         mut user_ids: Vec<u64>,
         cx: &mut ModelContext<Self>,
     ) -> Task<Result<()>> {
-        let rpc = self.client.clone();
-        let http = self.http.clone();
         user_ids.retain(|id| !self.users.contains_key(id));
-        cx.spawn_weak(|this, mut cx| async move {
-            if let Some(rpc) = rpc.upgrade() {
-                if !user_ids.is_empty() {
-                    let response = rpc.request(proto::GetUsers { user_ids }).await?;
-                    let new_users = future::join_all(
-                        response
-                            .users
-                            .into_iter()
-                            .map(|user| User::new(user, http.as_ref())),
-                    )
-                    .await;
+        if user_ids.is_empty() {
+            Task::ready(Ok(()))
+        } else {
+            let load = self.load_users(proto::GetUsers { user_ids }, cx);
+            cx.foreground().spawn(async move {
+                load.await?;
+                Ok(())
+            })
+        }
+    }
 
-                    if let Some(this) = this.upgrade(&cx) {
-                        this.update(&mut cx, |this, _| {
-                            for user in new_users {
-                                this.users.insert(user.id, Arc::new(user));
-                            }
-                        });
-                    }
-                }
-            }
-            Ok(())
-        })
+    pub fn fuzzy_search_users(
+        &mut self,
+        query: String,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<Vec<Arc<User>>>> {
+        self.load_users(proto::FuzzySearchUsers { query }, cx)
     }
 
     pub fn fetch_user(
@@ -186,7 +416,7 @@ impl UserStore {
             return cx.foreground().spawn(async move { Ok(user) });
         }
 
-        let load_users = self.load_users(vec![user_id], cx);
+        let load_users = self.get_users(vec![user_id], cx);
         cx.spawn(|this, mut cx| async move {
             load_users.await?;
             this.update(&mut cx, |this, _| {
@@ -205,15 +435,47 @@ impl UserStore {
     pub fn watch_current_user(&self) -> watch::Receiver<Option<Arc<User>>> {
         self.current_user.clone()
     }
+
+    fn load_users(
+        &mut self,
+        request: impl RequestMessage<Response = UsersResponse>,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<Vec<Arc<User>>>> {
+        let client = self.client.clone();
+        let http = self.http.clone();
+        cx.spawn_weak(|this, mut cx| async move {
+            if let Some(rpc) = client.upgrade() {
+                let response = rpc.request(request).await.context("error loading users")?;
+                let users = future::join_all(
+                    response
+                        .users
+                        .into_iter()
+                        .map(|user| User::new(user, http.as_ref())),
+                )
+                .await;
+
+                if let Some(this) = this.upgrade(&cx) {
+                    this.update(&mut cx, |this, _| {
+                        for user in &users {
+                            this.users.insert(user.id, user.clone());
+                        }
+                    });
+                }
+                Ok(users)
+            } else {
+                Ok(Vec::new())
+            }
+        })
+    }
 }
 
 impl User {
-    async fn new(message: proto::User, http: &dyn HttpClient) -> Self {
-        User {
+    async fn new(message: proto::User, http: &dyn HttpClient) -> Arc<Self> {
+        Arc::new(User {
             id: message.id,
             github_login: message.github_login,
             avatar: fetch_avatar(http, &message.avatar_url).warn_on_err().await,
-        }
+        })
     }
 }
 
@@ -247,7 +509,17 @@ impl Contact {
                 guests,
             });
         }
-        Ok(Self { user, projects })
+        Ok(Self {
+            user,
+            online: contact.online,
+            projects,
+        })
+    }
+
+    pub fn non_empty_projects(&self) -> impl Iterator<Item = &ProjectMetadata> {
+        self.projects
+            .iter()
+            .filter(|project| !project.worktree_root_names.is_empty())
     }
 }
 

crates/collab/Cargo.toml 🔗

@@ -22,6 +22,7 @@ async-trait = "0.1.50"
 async-tungstenite = "0.16"
 axum = { version = "0.5", features = ["json", "headers", "ws"] }
 base64 = "0.13"
+clap = { version = "3.1", features = ["derive"], optional = true }
 envy = "0.4.2"
 env_logger = "0.8"
 futures = "0.3"
@@ -32,6 +33,7 @@ opentelemetry = { version = "0.17", features = ["rt-tokio"] }
 opentelemetry-otlp = { version = "0.10", features = ["tls-roots"] }
 parking_lot = "0.11.1"
 rand = "0.8"
+reqwest = { version = "0.11", features = ["json"], optional = true }
 scrypt = "0.7"
 serde = { version = "1.0", features = ["derive"] }
 serde_json = "1.0"
@@ -69,4 +71,4 @@ lazy_static = "1.4"
 serde_json = { version = "1.0.64", features = ["preserve_order"] }
 
 [features]
-seed-support = ["lipsum"]
+seed-support = ["clap", "lipsum", "reqwest"]

crates/collab/migrations/20220506130724_create_contacts.sql 🔗

@@ -0,0 +1,11 @@
+CREATE TABLE IF NOT EXISTS "contacts" (
+    "id" SERIAL PRIMARY KEY,
+    "user_id_a" INTEGER REFERENCES users (id) NOT NULL,
+    "user_id_b" INTEGER REFERENCES users (id) NOT NULL,
+    "a_to_b" BOOLEAN NOT NULL,
+    "should_notify" BOOLEAN NOT NULL,
+    "accepted" BOOLEAN NOT NULL
+);
+
+CREATE UNIQUE INDEX "index_contacts_user_ids" ON "contacts" ("user_id_a", "user_id_b");
+CREATE INDEX "index_contacts_user_id_b" ON "contacts" ("user_id_b");

crates/collab/src/bin/seed.rs 🔗

@@ -1,31 +1,87 @@
+use clap::Parser;
 use db::{Db, PostgresDb, UserId};
 use rand::prelude::*;
+use serde::Deserialize;
+use std::fmt::Write;
 use time::{Duration, OffsetDateTime};
 
 #[allow(unused)]
 #[path = "../db.rs"]
 mod db;
 
+#[derive(Parser)]
+struct Args {
+    /// Seed users from GitHub.
+    #[clap(short, long)]
+    github_users: bool,
+}
+
+#[derive(Debug, Deserialize)]
+struct GitHubUser {
+    id: usize,
+    login: String,
+}
+
 #[tokio::main]
 async fn main() {
+    let args = Args::parse();
     let mut rng = StdRng::from_entropy();
     let database_url = std::env::var("DATABASE_URL").expect("missing DATABASE_URL env var");
     let db = PostgresDb::new(&database_url, 5)
         .await
         .expect("failed to connect to postgres database");
 
-    let zed_users = ["nathansobo", "maxbrunsfeld", "as-cii", "iamnbutler"];
+    let mut zed_users = vec![
+        "nathansobo".to_string(),
+        "maxbrunsfeld".to_string(),
+        "as-cii".to_string(),
+        "iamnbutler".to_string(),
+        "gibusu".to_string(),
+        "Kethku".to_string(),
+    ];
+
+    if args.github_users {
+        let github_token = std::env::var("GITHUB_TOKEN").expect("missing GITHUB_TOKEN env var");
+        let client = reqwest::Client::new();
+        let mut last_user_id = None;
+        for page in 0..20 {
+            println!("Downloading users from GitHub, page {}", page);
+            let mut uri = "https://api.github.com/users?per_page=100".to_string();
+            if let Some(last_user_id) = last_user_id {
+                write!(&mut uri, "&since={}", last_user_id).unwrap();
+            }
+            let response = client
+                .get(uri)
+                .bearer_auth(&github_token)
+                .header("user-agent", "zed")
+                .send()
+                .await
+                .expect("failed to fetch github users");
+            let users = response
+                .json::<Vec<GitHubUser>>()
+                .await
+                .expect("failed to deserialize github user");
+            zed_users.extend(users.iter().map(|user| user.login.clone()));
+
+            if let Some(last_user) = users.last() {
+                last_user_id = Some(last_user.id);
+            } else {
+                break;
+            }
+        }
+    }
+
     let mut zed_user_ids = Vec::<UserId>::new();
     for zed_user in zed_users {
         if let Some(user) = db
-            .get_user_by_github_login(zed_user)
+            .get_user_by_github_login(&zed_user)
             .await
             .expect("failed to fetch user")
         {
             zed_user_ids.push(user.id);
         } else {
             zed_user_ids.push(
-                db.create_user(zed_user, true)
+                db.create_user(&zed_user, true)
                     .await
                     .expect("failed to insert user"),
             );

crates/collab/src/db.rs 🔗

@@ -1,6 +1,6 @@
-use anyhow::Context;
-use anyhow::Result;
+use anyhow::{anyhow, Context, Result};
 use async_trait::async_trait;
+use futures::StreamExt;
 use serde::Serialize;
 pub use sqlx::postgres::PgPoolOptions as DbOptions;
 use sqlx::{types::Uuid, FromRow};
@@ -10,11 +10,28 @@ use time::OffsetDateTime;
 pub trait Db: Send + Sync {
     async fn create_user(&self, github_login: &str, admin: bool) -> Result<UserId>;
     async fn get_all_users(&self) -> Result<Vec<User>>;
+    async fn fuzzy_search_users(&self, query: &str, limit: u32) -> Result<Vec<User>>;
     async fn get_user_by_id(&self, id: UserId) -> Result<Option<User>>;
     async fn get_users_by_ids(&self, ids: Vec<UserId>) -> Result<Vec<User>>;
     async fn get_user_by_github_login(&self, github_login: &str) -> Result<Option<User>>;
     async fn set_user_is_admin(&self, id: UserId, is_admin: bool) -> Result<()>;
     async fn destroy_user(&self, id: UserId) -> Result<()>;
+
+    async fn get_contacts(&self, id: UserId) -> Result<Contacts>;
+    async fn send_contact_request(&self, requester_id: UserId, responder_id: UserId) -> Result<()>;
+    async fn remove_contact(&self, requester_id: UserId, responder_id: UserId) -> Result<()>;
+    async fn dismiss_contact_request(
+        &self,
+        responder_id: UserId,
+        requester_id: UserId,
+    ) -> Result<()>;
+    async fn respond_to_contact_request(
+        &self,
+        responder_id: UserId,
+        requester_id: UserId,
+        accept: bool,
+    ) -> Result<()>;
+
     async fn create_access_token_hash(
         &self,
         user_id: UserId,
@@ -23,6 +40,7 @@ pub trait Db: Send + Sync {
     ) -> Result<()>;
     async fn get_access_token_hashes(&self, user_id: UserId) -> Result<Vec<String>>;
     #[cfg(any(test, feature = "seed-support"))]
+
     async fn find_org_by_slug(&self, slug: &str) -> Result<Option<Org>>;
     #[cfg(any(test, feature = "seed-support"))]
     async fn create_org(&self, name: &str, slug: &str) -> Result<OrgId>;
@@ -31,6 +49,7 @@ pub trait Db: Send + Sync {
     #[cfg(any(test, feature = "seed-support"))]
     async fn create_org_channel(&self, org_id: OrgId, name: &str) -> Result<ChannelId>;
     #[cfg(any(test, feature = "seed-support"))]
+
     async fn get_org_channels(&self, org_id: OrgId) -> Result<Vec<Channel>>;
     async fn get_accessible_channels(&self, user_id: UserId) -> Result<Vec<Channel>>;
     async fn can_user_access_channel(&self, user_id: UserId, channel_id: ChannelId)
@@ -58,6 +77,8 @@ pub trait Db: Send + Sync {
     ) -> Result<Vec<ChannelMessage>>;
     #[cfg(test)]
     async fn teardown(&self, url: &str);
+    #[cfg(test)]
+    fn as_fake<'a>(&'a self) -> Option<&'a tests::FakeDb>;
 }
 
 pub struct PostgresDb {
@@ -99,6 +120,23 @@ impl Db for PostgresDb {
         Ok(sqlx::query_as(query).fetch_all(&self.pool).await?)
     }
 
+    async fn fuzzy_search_users(&self, name_query: &str, limit: u32) -> Result<Vec<User>> {
+        let like_string = fuzzy_like_string(name_query);
+        let query = "
+            SELECT users.*
+            FROM users
+            WHERE github_login ILIKE $1
+            ORDER BY github_login <-> $2
+            LIMIT $3
+        ";
+        Ok(sqlx::query_as(query)
+            .bind(like_string)
+            .bind(name_query)
+            .bind(limit)
+            .fetch_all(&self.pool)
+            .await?)
+    }
+
     async fn get_user_by_id(&self, id: UserId) -> Result<Option<User>> {
         let users = self.get_users_by_ids(vec![id]).await?;
         Ok(users.into_iter().next())
@@ -150,6 +188,188 @@ impl Db for PostgresDb {
             .map(drop)?)
     }
 
+    // contacts
+
+    async fn get_contacts(&self, user_id: UserId) -> Result<Contacts> {
+        let query = "
+            SELECT user_id_a, user_id_b, a_to_b, accepted, should_notify
+            FROM contacts
+            WHERE user_id_a = $1 OR user_id_b = $1;
+        ";
+
+        let mut rows = sqlx::query_as::<_, (UserId, UserId, bool, bool, bool)>(query)
+            .bind(user_id)
+            .fetch(&self.pool);
+
+        let mut current = vec![user_id];
+        let mut outgoing_requests = Vec::new();
+        let mut incoming_requests = Vec::new();
+        while let Some(row) = rows.next().await {
+            let (user_id_a, user_id_b, a_to_b, accepted, should_notify) = row?;
+
+            if user_id_a == user_id {
+                if accepted {
+                    current.push(user_id_b);
+                } else if a_to_b {
+                    outgoing_requests.push(user_id_b);
+                } else {
+                    incoming_requests.push(IncomingContactRequest {
+                        requester_id: user_id_b,
+                        should_notify,
+                    });
+                }
+            } else {
+                if accepted {
+                    current.push(user_id_a);
+                } else if a_to_b {
+                    incoming_requests.push(IncomingContactRequest {
+                        requester_id: user_id_a,
+                        should_notify,
+                    });
+                } else {
+                    outgoing_requests.push(user_id_a);
+                }
+            }
+        }
+
+        current.sort_unstable();
+        outgoing_requests.sort_unstable();
+        incoming_requests.sort_unstable();
+
+        Ok(Contacts {
+            current,
+            outgoing_requests,
+            incoming_requests,
+        })
+    }
+
+    async fn send_contact_request(&self, sender_id: UserId, receiver_id: UserId) -> Result<()> {
+        let (id_a, id_b, a_to_b) = if sender_id < receiver_id {
+            (sender_id, receiver_id, true)
+        } else {
+            (receiver_id, sender_id, false)
+        };
+        let query = "
+            INSERT into contacts (user_id_a, user_id_b, a_to_b, accepted, should_notify)
+            VALUES ($1, $2, $3, 'f', 't')
+            ON CONFLICT (user_id_a, user_id_b) DO UPDATE
+            SET
+                accepted = 't'
+            WHERE
+                NOT contacts.accepted AND
+                ((contacts.a_to_b = excluded.a_to_b AND contacts.user_id_a = excluded.user_id_b) OR
+                (contacts.a_to_b != excluded.a_to_b AND contacts.user_id_a = excluded.user_id_a));
+        ";
+        let result = sqlx::query(query)
+            .bind(id_a.0)
+            .bind(id_b.0)
+            .bind(a_to_b)
+            .execute(&self.pool)
+            .await?;
+
+        if result.rows_affected() == 1 {
+            Ok(())
+        } else {
+            Err(anyhow!("contact already requested"))
+        }
+    }
+
+    async fn remove_contact(&self, requester_id: UserId, responder_id: UserId) -> Result<()> {
+        let (id_a, id_b) = if responder_id < requester_id {
+            (responder_id, requester_id)
+        } else {
+            (requester_id, responder_id)
+        };
+        let query = "
+            DELETE FROM contacts
+            WHERE user_id_a = $1 AND user_id_b = $2;
+        ";
+        let result = sqlx::query(query)
+            .bind(id_a.0)
+            .bind(id_b.0)
+            .execute(&self.pool)
+            .await?;
+
+        if result.rows_affected() == 1 {
+            Ok(())
+        } else {
+            Err(anyhow!("no such contact"))
+        }
+    }
+
+    async fn dismiss_contact_request(
+        &self,
+        responder_id: UserId,
+        requester_id: UserId,
+    ) -> Result<()> {
+        let (id_a, id_b, a_to_b) = if responder_id < requester_id {
+            (responder_id, requester_id, false)
+        } else {
+            (requester_id, responder_id, true)
+        };
+
+        let query = "
+            UPDATE contacts
+            SET should_notify = 'f'
+            WHERE user_id_a = $1 AND user_id_b = $2 AND a_to_b = $3;
+        ";
+
+        let result = sqlx::query(query)
+            .bind(id_a.0)
+            .bind(id_b.0)
+            .bind(a_to_b)
+            .execute(&self.pool)
+            .await?;
+
+        if result.rows_affected() == 0 {
+            Err(anyhow!("no such contact request"))?;
+        }
+
+        Ok(())
+    }
+
+    async fn respond_to_contact_request(
+        &self,
+        responder_id: UserId,
+        requester_id: UserId,
+        accept: bool,
+    ) -> Result<()> {
+        let (id_a, id_b, a_to_b) = if responder_id < requester_id {
+            (responder_id, requester_id, false)
+        } else {
+            (requester_id, responder_id, true)
+        };
+        let result = if accept {
+            let query = "
+                UPDATE contacts
+                SET accepted = 't', should_notify = 'f'
+                WHERE user_id_a = $1 AND user_id_b = $2 AND a_to_b = $3;
+            ";
+            sqlx::query(query)
+                .bind(id_a.0)
+                .bind(id_b.0)
+                .bind(a_to_b)
+                .execute(&self.pool)
+                .await?
+        } else {
+            let query = "
+                DELETE FROM contacts
+                WHERE user_id_a = $1 AND user_id_b = $2 AND a_to_b = $3 AND NOT accepted;
+            ";
+            sqlx::query(query)
+                .bind(id_a.0)
+                .bind(id_b.0)
+                .bind(a_to_b)
+                .execute(&self.pool)
+                .await?
+        };
+        if result.rows_affected() == 1 {
+            Ok(())
+        } else {
+            Err(anyhow!("no such contact request"))
+        }
+    }
+
     // access tokens
 
     async fn create_access_token_hash(
@@ -406,12 +626,17 @@ impl Db for PostgresDb {
             .await
             .log_err();
     }
+
+    #[cfg(test)]
+    fn as_fake(&self) -> Option<&tests::FakeDb> {
+        None
+    }
 }
 
 macro_rules! id_type {
     ($name:ident) => {
         #[derive(
-            Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, sqlx::Type, Serialize,
+            Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, sqlx::Type, Serialize,
         )]
         #[sqlx(transparent)]
         #[serde(transparent)]
@@ -476,6 +701,31 @@ pub struct ChannelMessage {
     pub nonce: Uuid,
 }
 
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Contacts {
+    pub current: Vec<UserId>,
+    pub incoming_requests: Vec<IncomingContactRequest>,
+    pub outgoing_requests: Vec<UserId>,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
+pub struct IncomingContactRequest {
+    pub requester_id: UserId,
+    pub should_notify: bool,
+}
+
+fn fuzzy_like_string(string: &str) -> String {
+    let mut result = String::with_capacity(string.len() * 2 + 1);
+    for c in string.chars() {
+        if c.is_alphanumeric() {
+            result.push('%');
+            result.push(c);
+        }
+    }
+    result.push('%');
+    result
+}
+
 #[cfg(test)]
 pub mod tests {
     use super::*;
@@ -640,6 +890,185 @@ pub mod tests {
         );
     }
 
+    #[test]
+    fn test_fuzzy_like_string() {
+        assert_eq!(fuzzy_like_string("abcd"), "%a%b%c%d%");
+        assert_eq!(fuzzy_like_string("x y"), "%x%y%");
+        assert_eq!(fuzzy_like_string(" z  "), "%z%");
+    }
+
+    #[tokio::test(flavor = "multi_thread")]
+    async fn test_fuzzy_search_users() {
+        let test_db = TestDb::postgres().await;
+        let db = test_db.db();
+        for github_login in [
+            "California",
+            "colorado",
+            "oregon",
+            "washington",
+            "florida",
+            "delaware",
+            "rhode-island",
+        ] {
+            db.create_user(github_login, false).await.unwrap();
+        }
+
+        assert_eq!(
+            fuzzy_search_user_names(db, "clr").await,
+            &["colorado", "California"]
+        );
+        assert_eq!(
+            fuzzy_search_user_names(db, "ro").await,
+            &["rhode-island", "colorado", "oregon"],
+        );
+
+        async fn fuzzy_search_user_names(db: &Arc<dyn Db>, query: &str) -> Vec<String> {
+            db.fuzzy_search_users(query, 10)
+                .await
+                .unwrap()
+                .into_iter()
+                .map(|user| user.github_login)
+                .collect::<Vec<_>>()
+        }
+    }
+
+    #[tokio::test(flavor = "multi_thread")]
+    async fn test_add_contacts() {
+        for test_db in [
+            TestDb::postgres().await,
+            TestDb::fake(Arc::new(gpui::executor::Background::new())),
+        ] {
+            let db = test_db.db();
+
+            let user_1 = db.create_user("user1", false).await.unwrap();
+            let user_2 = db.create_user("user2", false).await.unwrap();
+            let user_3 = db.create_user("user3", false).await.unwrap();
+
+            // User starts with no contacts
+            assert_eq!(
+                db.get_contacts(user_1).await.unwrap(),
+                Contacts {
+                    current: vec![user_1],
+                    outgoing_requests: vec![],
+                    incoming_requests: vec![],
+                },
+            );
+
+            // User requests a contact. Both users see the pending request.
+            db.send_contact_request(user_1, user_2).await.unwrap();
+            assert_eq!(
+                db.get_contacts(user_1).await.unwrap(),
+                Contacts {
+                    current: vec![user_1],
+                    outgoing_requests: vec![user_2],
+                    incoming_requests: vec![],
+                },
+            );
+            assert_eq!(
+                db.get_contacts(user_2).await.unwrap(),
+                Contacts {
+                    current: vec![user_2],
+                    outgoing_requests: vec![],
+                    incoming_requests: vec![IncomingContactRequest {
+                        requester_id: user_1,
+                        should_notify: true
+                    }],
+                },
+            );
+
+            // User 2 dismisses the contact request notification without accepting or rejecting.
+            // We shouldn't notify them again.
+            db.dismiss_contact_request(user_1, user_2)
+                .await
+                .unwrap_err();
+            db.dismiss_contact_request(user_2, user_1).await.unwrap();
+            assert_eq!(
+                db.get_contacts(user_2).await.unwrap(),
+                Contacts {
+                    current: vec![user_2],
+                    outgoing_requests: vec![],
+                    incoming_requests: vec![IncomingContactRequest {
+                        requester_id: user_1,
+                        should_notify: false
+                    }],
+                },
+            );
+
+            // User can't accept their own contact request
+            db.respond_to_contact_request(user_1, user_2, true)
+                .await
+                .unwrap_err();
+
+            // User accepts a contact request. Both users see the contact.
+            db.respond_to_contact_request(user_2, user_1, true)
+                .await
+                .unwrap();
+            assert_eq!(
+                db.get_contacts(user_1).await.unwrap(),
+                Contacts {
+                    current: vec![user_1, user_2],
+                    outgoing_requests: vec![],
+                    incoming_requests: vec![],
+                },
+            );
+            assert_eq!(
+                db.get_contacts(user_2).await.unwrap(),
+                Contacts {
+                    current: vec![user_1, user_2],
+                    outgoing_requests: vec![],
+                    incoming_requests: vec![],
+                },
+            );
+
+            // Users cannot re-request existing contacts.
+            db.send_contact_request(user_1, user_2).await.unwrap_err();
+            db.send_contact_request(user_2, user_1).await.unwrap_err();
+
+            // Users send each other concurrent contact requests and
+            // see that they are immediately accepted.
+            db.send_contact_request(user_1, user_3).await.unwrap();
+            db.send_contact_request(user_3, user_1).await.unwrap();
+            assert_eq!(
+                db.get_contacts(user_1).await.unwrap(),
+                Contacts {
+                    current: vec![user_1, user_2, user_3],
+                    outgoing_requests: vec![],
+                    incoming_requests: vec![],
+                },
+            );
+            assert_eq!(
+                db.get_contacts(user_3).await.unwrap(),
+                Contacts {
+                    current: vec![user_1, user_3],
+                    outgoing_requests: vec![],
+                    incoming_requests: vec![],
+                },
+            );
+
+            // User declines a contact request. Both users see that it is gone.
+            db.send_contact_request(user_2, user_3).await.unwrap();
+            db.respond_to_contact_request(user_3, user_2, false)
+                .await
+                .unwrap();
+            assert_eq!(
+                db.get_contacts(user_2).await.unwrap(),
+                Contacts {
+                    current: vec![user_1, user_2],
+                    outgoing_requests: vec![],
+                    incoming_requests: vec![],
+                },
+            );
+            assert_eq!(
+                db.get_contacts(user_3).await.unwrap(),
+                Contacts {
+                    current: vec![user_1, user_3],
+                    outgoing_requests: vec![],
+                    incoming_requests: vec![],
+                },
+            );
+        }
+    }
+
     pub struct TestDb {
         pub db: Option<Arc<dyn Db>>,
         pub url: String,
@@ -690,16 +1119,25 @@ pub mod tests {
 
     pub struct FakeDb {
         background: Arc<Background>,
-        users: Mutex<BTreeMap<UserId, User>>,
+        pub users: Mutex<BTreeMap<UserId, User>>,
+        pub orgs: Mutex<BTreeMap<OrgId, Org>>,
+        pub org_memberships: Mutex<BTreeMap<(OrgId, UserId), bool>>,
+        pub channels: Mutex<BTreeMap<ChannelId, Channel>>,
+        pub channel_memberships: Mutex<BTreeMap<(ChannelId, UserId), bool>>,
+        pub channel_messages: Mutex<BTreeMap<MessageId, ChannelMessage>>,
+        pub contacts: Mutex<Vec<FakeContact>>,
+        next_channel_message_id: Mutex<i32>,
         next_user_id: Mutex<i32>,
-        orgs: Mutex<BTreeMap<OrgId, Org>>,
         next_org_id: Mutex<i32>,
-        org_memberships: Mutex<BTreeMap<(OrgId, UserId), bool>>,
-        channels: Mutex<BTreeMap<ChannelId, Channel>>,
         next_channel_id: Mutex<i32>,
-        channel_memberships: Mutex<BTreeMap<(ChannelId, UserId), bool>>,
-        channel_messages: Mutex<BTreeMap<MessageId, ChannelMessage>>,
-        next_channel_message_id: Mutex<i32>,
+    }
+
+    #[derive(Debug)]
+    pub struct FakeContact {
+        pub requester_id: UserId,
+        pub responder_id: UserId,
+        pub accepted: bool,
+        pub should_notify: bool,
     }
 
     impl FakeDb {
@@ -716,6 +1154,7 @@ pub mod tests {
                 channel_memberships: Default::default(),
                 channel_messages: Default::default(),
                 next_channel_message_id: Mutex::new(1),
+                contacts: Default::default(),
             }
         }
     }
@@ -749,6 +1188,10 @@ pub mod tests {
             unimplemented!()
         }
 
+        async fn fuzzy_search_users(&self, _: &str, _: u32) -> Result<Vec<User>> {
+            unimplemented!()
+        }
+
         async fn get_user_by_id(&self, id: UserId) -> Result<Option<User>> {
             Ok(self.get_users_by_ids(vec![id]).await?.into_iter().next())
         }
@@ -759,8 +1202,13 @@ pub mod tests {
             Ok(ids.iter().filter_map(|id| users.get(id).cloned()).collect())
         }
 
-        async fn get_user_by_github_login(&self, _github_login: &str) -> Result<Option<User>> {
-            unimplemented!()
+        async fn get_user_by_github_login(&self, github_login: &str) -> Result<Option<User>> {
+            Ok(self
+                .users
+                .lock()
+                .values()
+                .find(|user| user.github_login == github_login)
+                .cloned())
         }
 
         async fn set_user_is_admin(&self, _id: UserId, _is_admin: bool) -> Result<()> {
@@ -771,6 +1219,122 @@ pub mod tests {
             unimplemented!()
         }
 
+        async fn get_contacts(&self, id: UserId) -> Result<Contacts> {
+            self.background.simulate_random_delay().await;
+            let mut current = vec![id];
+            let mut outgoing_requests = Vec::new();
+            let mut incoming_requests = Vec::new();
+
+            for contact in self.contacts.lock().iter() {
+                if contact.requester_id == id {
+                    if contact.accepted {
+                        current.push(contact.responder_id);
+                    } else {
+                        outgoing_requests.push(contact.responder_id);
+                    }
+                } else if contact.responder_id == id {
+                    if contact.accepted {
+                        current.push(contact.requester_id);
+                    } else {
+                        incoming_requests.push(IncomingContactRequest {
+                            requester_id: contact.requester_id,
+                            should_notify: contact.should_notify,
+                        });
+                    }
+                }
+            }
+
+            current.sort_unstable();
+            outgoing_requests.sort_unstable();
+            incoming_requests.sort_unstable();
+
+            Ok(Contacts {
+                current,
+                outgoing_requests,
+                incoming_requests,
+            })
+        }
+
+        async fn send_contact_request(
+            &self,
+            requester_id: UserId,
+            responder_id: UserId,
+        ) -> Result<()> {
+            let mut contacts = self.contacts.lock();
+            for contact in contacts.iter_mut() {
+                if contact.requester_id == requester_id && contact.responder_id == responder_id {
+                    if contact.accepted {
+                        Err(anyhow!("contact already exists"))?;
+                    } else {
+                        Err(anyhow!("contact already requested"))?;
+                    }
+                }
+                if contact.responder_id == requester_id && contact.requester_id == responder_id {
+                    if contact.accepted {
+                        Err(anyhow!("contact already exists"))?;
+                    } else {
+                        contact.accepted = true;
+                        return Ok(());
+                    }
+                }
+            }
+            contacts.push(FakeContact {
+                requester_id,
+                responder_id,
+                accepted: false,
+                should_notify: true,
+            });
+            Ok(())
+        }
+
+        async fn remove_contact(&self, requester_id: UserId, responder_id: UserId) -> Result<()> {
+            self.contacts.lock().retain(|contact| {
+                !(contact.requester_id == requester_id && contact.responder_id == responder_id)
+            });
+            Ok(())
+        }
+
+        async fn dismiss_contact_request(
+            &self,
+            responder_id: UserId,
+            requester_id: UserId,
+        ) -> Result<()> {
+            let mut contacts = self.contacts.lock();
+            for contact in contacts.iter_mut() {
+                if contact.requester_id == requester_id && contact.responder_id == responder_id {
+                    if contact.accepted {
+                        return Err(anyhow!("contact already confirmed"));
+                    }
+                    contact.should_notify = false;
+                    return Ok(());
+                }
+            }
+            Err(anyhow!("no such contact request"))
+        }
+
+        async fn respond_to_contact_request(
+            &self,
+            responder_id: UserId,
+            requester_id: UserId,
+            accept: bool,
+        ) -> Result<()> {
+            let mut contacts = self.contacts.lock();
+            for (ix, contact) in contacts.iter_mut().enumerate() {
+                if contact.requester_id == requester_id && contact.responder_id == responder_id {
+                    if contact.accepted {
+                        return Err(anyhow!("contact already confirmed"));
+                    }
+                    if accept {
+                        contact.accepted = true;
+                    } else {
+                        contacts.remove(ix);
+                    }
+                    return Ok(());
+                }
+            }
+            Err(anyhow!("no such contact request"))
+        }
+
         async fn create_access_token_hash(
             &self,
             _user_id: UserId,
@@ -965,5 +1529,10 @@ pub mod tests {
         }
 
         async fn teardown(&self, _: &str) {}
+
+        #[cfg(test)]
+        fn as_fake(&self) -> Option<&FakeDb> {
+            Some(self)
+        }
     }
 }

crates/collab/src/rpc.rs 🔗

@@ -18,16 +18,16 @@ use axum::{
     headers::{Header, HeaderName},
     http::StatusCode,
     middleware,
-    response::{IntoResponse, Response},
+    response::IntoResponse,
     routing::get,
     Extension, Router, TypedHeader,
 };
-use collections::{HashMap, HashSet};
+use collections::HashMap;
 use futures::{channel::mpsc, future::BoxFuture, FutureExt, SinkExt, StreamExt, TryStreamExt};
 use lazy_static::lazy_static;
 use rpc::{
     proto::{self, AnyTypedEnvelope, EntityMessage, EnvelopedMessage, RequestMessage},
-    Connection, ConnectionId, Peer, TypedEnvelope,
+    Connection, ConnectionId, Peer, Receipt, TypedEnvelope,
 };
 use std::{
     any::TypeId,
@@ -36,7 +36,10 @@ use std::{
     net::SocketAddr,
     ops::{Deref, DerefMut},
     rc::Rc,
-    sync::Arc,
+    sync::{
+        atomic::{AtomicBool, Ordering::SeqCst},
+        Arc,
+    },
     time::Duration,
 };
 use store::{Store, Worktree};
@@ -46,11 +49,25 @@ use tokio::{
     time::Sleep,
 };
 use tower::ServiceBuilder;
-use tracing::{info_span, instrument, Instrument};
+use tracing::{info_span, Instrument};
 
 type MessageHandler =
     Box<dyn Send + Sync + Fn(Arc<Server>, Box<dyn AnyTypedEnvelope>) -> BoxFuture<'static, ()>>;
 
+struct Response<R> {
+    server: Arc<Server>,
+    receipt: Receipt<R>,
+    responded: Arc<AtomicBool>,
+}
+
+impl<R: RequestMessage> Response<R> {
+    fn send(self, payload: R::Response) -> Result<()> {
+        self.responded.store(true, SeqCst);
+        self.server.peer.respond(self.receipt, payload)?;
+        Ok(())
+    }
+}
+
 pub struct Server {
     peer: Arc<Peer>,
     store: RwLock<Store>,
@@ -100,7 +117,7 @@ impl Server {
             .add_message_handler(Server::unregister_project)
             .add_request_handler(Server::share_project)
             .add_message_handler(Server::unshare_project)
-            .add_sync_request_handler(Server::join_project)
+            .add_request_handler(Server::join_project)
             .add_message_handler(Server::leave_project)
             .add_request_handler(Server::register_worktree)
             .add_message_handler(Server::unregister_worktree)
@@ -136,6 +153,10 @@ impl Server {
             .add_request_handler(Server::save_buffer)
             .add_request_handler(Server::get_channels)
             .add_request_handler(Server::get_users)
+            .add_request_handler(Server::fuzzy_search_users)
+            .add_request_handler(Server::request_contact)
+            .add_request_handler(Server::remove_contact)
+            .add_request_handler(Server::respond_to_contact_request)
             .add_request_handler(Server::join_channel)
             .add_message_handler(Server::leave_channel)
             .add_request_handler(Server::send_channel_message)
@@ -178,43 +199,12 @@ impl Server {
         self
     }
 
-    fn add_request_handler<F, Fut, M>(&mut self, handler: F) -> &mut Self
-    where
-        F: 'static + Send + Sync + Fn(Arc<Self>, TypedEnvelope<M>) -> Fut,
-        Fut: 'static + Send + Future<Output = Result<M::Response>>,
-        M: RequestMessage,
-    {
-        self.add_message_handler(move |server, envelope| {
-            let receipt = envelope.receipt();
-            let response = (handler)(server.clone(), envelope);
-            async move {
-                match response.await {
-                    Ok(response) => {
-                        server.peer.respond(receipt, response)?;
-                        Ok(())
-                    }
-                    Err(error) => {
-                        server.peer.respond_with_error(
-                            receipt,
-                            proto::Error {
-                                message: error.to_string(),
-                            },
-                        )?;
-                        Err(error)
-                    }
-                }
-            }
-        })
-    }
-
     /// Handle a request while holding a lock to the store. This is useful when we're registering
     /// a connection but we want to respond on the connection before anybody else can send on it.
-    fn add_sync_request_handler<F, M>(&mut self, handler: F) -> &mut Self
+    fn add_request_handler<F, Fut, M>(&mut self, handler: F) -> &mut Self
     where
-        F: 'static
-            + Send
-            + Sync
-            + Fn(Arc<Self>, &mut Store, TypedEnvelope<M>) -> Result<M::Response>,
+        F: 'static + Send + Sync + Fn(Arc<Self>, TypedEnvelope<M>, Response<M>) -> Fut,
+        Fut: Send + Future<Output = Result<()>>,
         M: RequestMessage,
     {
         let handler = Arc::new(handler);
@@ -222,12 +212,19 @@ impl Server {
             let receipt = envelope.receipt();
             let handler = handler.clone();
             async move {
-                let mut store = server.state_mut().await;
-                let response = (handler)(server.clone(), &mut *store, envelope);
-                match response {
-                    Ok(response) => {
-                        server.peer.respond(receipt, response)?;
-                        Ok(())
+                let responded = Arc::new(AtomicBool::default());
+                let response = Response {
+                    server: server.clone(),
+                    responded: responded.clone(),
+                    receipt: envelope.receipt(),
+                };
+                match (handler)(server.clone(), envelope, response).await {
+                    Ok(()) => {
+                        if responded.load(std::sync::atomic::Ordering::SeqCst) {
+                            Ok(())
+                        } else {
+                            Err(anyhow!("handler did not send a response"))?
+                        }
                     }
                     Err(error) => {
                         server.peer.respond_with_error(
@@ -250,7 +247,7 @@ impl Server {
         user_id: UserId,
         mut send_connection_id: Option<mpsc::Sender<ConnectionId>>,
         executor: E,
-    ) -> impl Future<Output = ()> {
+    ) -> impl Future<Output = Result<()>> {
         let mut this = self.clone();
         let span = info_span!("handle connection", %user_id, %address);
         async move {
@@ -273,11 +270,14 @@ impl Server {
                 let _ = send_connection_id.send(connection_id).await;
             }
 
+            let contacts = this.app_state.db.get_contacts(user_id).await?;
+
             {
-                let mut state = this.state_mut().await;
-                state.add_connection(connection_id, user_id);
-                this.update_contacts_for_users(&*state, &[user_id]);
+                let mut store = this.store_mut().await;
+                store.add_connection(connection_id, user_id);
+                this.peer.send(connection_id, store.build_initial_contacts_update(contacts))?;
             }
+            this.update_user_contacts(user_id).await?;
 
             let handle_io = handle_io.fuse();
             futures::pin_mut!(handle_io);
@@ -326,24 +326,21 @@ impl Server {
             if let Err(error) = this.sign_out(connection_id).await {
                 tracing::error!(%error, "error signing out");
             }
+
+            Ok(())
         }.instrument(span)
     }
 
     async fn sign_out(self: &mut Arc<Self>, connection_id: ConnectionId) -> Result<()> {
         self.peer.disconnect(connection_id);
-        let mut state = self.state_mut().await;
-        let removed_connection = state.remove_connection(connection_id)?;
+        let removed_connection = self.store_mut().await.remove_connection(connection_id)?;
 
         for (project_id, project) in removed_connection.hosted_projects {
             if let Some(share) = project.share {
-                broadcast(
-                    connection_id,
-                    share.guests.keys().copied().collect(),
-                    |conn_id| {
-                        self.peer
-                            .send(conn_id, proto::UnshareProject { project_id })
-                    },
-                );
+                broadcast(connection_id, share.guests.keys().copied(), |conn_id| {
+                    self.peer
+                        .send(conn_id, proto::UnshareProject { project_id })
+                });
             }
         }
 
@@ -359,44 +356,89 @@ impl Server {
             });
         }
 
-        self.update_contacts_for_users(&*state, removed_connection.contact_ids.iter());
+        self.update_user_contacts(removed_connection.user_id)
+            .await?;
+
         Ok(())
     }
 
-    async fn ping(self: Arc<Server>, _: TypedEnvelope<proto::Ping>) -> Result<proto::Ack> {
-        Ok(proto::Ack {})
+    async fn ping(
+        self: Arc<Server>,
+        _: TypedEnvelope<proto::Ping>,
+        response: Response<proto::Ping>,
+    ) -> Result<()> {
+        response.send(proto::Ack {})?;
+        Ok(())
     }
 
     async fn register_project(
         self: Arc<Server>,
         request: TypedEnvelope<proto::RegisterProject>,
-    ) -> Result<proto::RegisterProjectResponse> {
-        let project_id = {
-            let mut state = self.state_mut().await;
-            let user_id = state.user_id_for_connection(request.sender_id)?;
-            state.register_project(request.sender_id, user_id)
+        response: Response<proto::RegisterProject>,
+    ) -> Result<()> {
+        let user_id;
+        let project_id;
+        {
+            let mut state = self.store_mut().await;
+            user_id = state.user_id_for_connection(request.sender_id)?;
+            project_id = state.register_project(request.sender_id, user_id);
         };
-        Ok(proto::RegisterProjectResponse { project_id })
+        self.update_user_contacts(user_id).await?;
+        response.send(proto::RegisterProjectResponse { project_id })?;
+        Ok(())
     }
 
     async fn unregister_project(
         self: Arc<Server>,
         request: TypedEnvelope<proto::UnregisterProject>,
     ) -> Result<()> {
-        let mut state = self.state_mut().await;
-        let project = state.unregister_project(request.payload.project_id, request.sender_id)?;
-        self.update_contacts_for_users(&*state, &project.authorized_user_ids());
+        let user_id = {
+            let mut state = self.store_mut().await;
+            state.unregister_project(request.payload.project_id, request.sender_id)?;
+            state.user_id_for_connection(request.sender_id)?
+        };
+
+        self.update_user_contacts(user_id).await?;
         Ok(())
     }
 
     async fn share_project(
         self: Arc<Server>,
         request: TypedEnvelope<proto::ShareProject>,
-    ) -> Result<proto::Ack> {
-        let mut state = self.state_mut().await;
-        let project = state.share_project(request.payload.project_id, request.sender_id)?;
-        self.update_contacts_for_users(&mut *state, &project.authorized_user_ids);
-        Ok(proto::Ack {})
+        response: Response<proto::ShareProject>,
+    ) -> Result<()> {
+        let user_id = {
+            let mut state = self.store_mut().await;
+            state.share_project(request.payload.project_id, request.sender_id)?;
+            state.user_id_for_connection(request.sender_id)?
+        };
+        self.update_user_contacts(user_id).await?;
+        response.send(proto::Ack {})?;
+        Ok(())
+    }
+
+    async fn update_user_contacts(self: &Arc<Server>, user_id: UserId) -> Result<()> {
+        let contacts = self.app_state.db.get_contacts(user_id).await?;
+        let store = self.store().await;
+        let updated_contact = store.contact_for_user(user_id);
+        for contact_user_id in contacts.current {
+            for contact_conn_id in store.connection_ids_for_user(contact_user_id) {
+                self.peer
+                    .send(
+                        contact_conn_id,
+                        proto::UpdateContacts {
+                            contacts: vec![updated_contact.clone()],
+                            remove_contacts: Default::default(),
+                            incoming_requests: Default::default(),
+                            remove_incoming_requests: Default::default(),
+                            outgoing_requests: Default::default(),
+                            remove_outgoing_requests: Default::default(),
+                        },
+                    )
+                    .trace_err();
+            }
+        }
+        Ok(())
     }
 
     async fn unshare_project(
@@ -404,89 +446,103 @@ impl Server {
         request: TypedEnvelope<proto::UnshareProject>,
     ) -> Result<()> {
         let project_id = request.payload.project_id;
-        let mut state = self.state_mut().await;
-        let project = state.unshare_project(project_id, request.sender_id)?;
-        broadcast(request.sender_id, project.connection_ids, |conn_id| {
-            self.peer
-                .send(conn_id, proto::UnshareProject { project_id })
-        });
-        self.update_contacts_for_users(&mut *state, &project.authorized_user_ids);
+        let project;
+        {
+            let mut state = self.store_mut().await;
+            project = state.unshare_project(project_id, request.sender_id)?;
+            broadcast(request.sender_id, project.connection_ids, |conn_id| {
+                self.peer
+                    .send(conn_id, proto::UnshareProject { project_id })
+            });
+        }
+        self.update_user_contacts(project.host_user_id).await?;
         Ok(())
     }
 
-    fn join_project(
+    async fn join_project(
         self: Arc<Server>,
-        state: &mut Store,
         request: TypedEnvelope<proto::JoinProject>,
-    ) -> Result<proto::JoinProjectResponse> {
+        response: Response<proto::JoinProject>,
+    ) -> Result<()> {
         let project_id = request.payload.project_id;
+        let host_user_id;
+        let guest_user_id;
+        {
+            let state = self.store().await;
+            host_user_id = state.project(project_id)?.host_user_id;
+            guest_user_id = state.user_id_for_connection(request.sender_id)?;
+        };
 
-        let user_id = state.user_id_for_connection(request.sender_id)?;
-        let (response, connection_ids, contact_user_ids) = state
-            .join_project(request.sender_id, user_id, project_id)
-            .and_then(|joined| {
-                let share = joined.project.share()?;
-                let peer_count = share.guests.len();
-                let mut collaborators = Vec::with_capacity(peer_count);
-                collaborators.push(proto::Collaborator {
-                    peer_id: joined.project.host_connection_id.0,
-                    replica_id: 0,
-                    user_id: joined.project.host_user_id.to_proto(),
-                });
-                let worktrees = share
-                    .worktrees
-                    .iter()
-                    .filter_map(|(id, shared_worktree)| {
-                        let worktree = joined.project.worktrees.get(&id)?;
-                        Some(proto::Worktree {
-                            id: *id,
-                            root_name: worktree.root_name.clone(),
-                            entries: shared_worktree.entries.values().cloned().collect(),
-                            diagnostic_summaries: shared_worktree
-                                .diagnostic_summaries
-                                .values()
-                                .cloned()
-                                .collect(),
-                            visible: worktree.visible,
-                            scan_id: shared_worktree.scan_id,
-                        })
+        let guest_contacts = self.app_state.db.get_contacts(guest_user_id).await?;
+        if !guest_contacts.current.contains(&host_user_id) {
+            return Err(anyhow!("no such project"))?;
+        }
+
+        {
+            let state = &mut *self.store_mut().await;
+            let joined = state.join_project(request.sender_id, guest_user_id, project_id)?;
+            let share = joined.project.share()?;
+            let peer_count = share.guests.len();
+            let mut collaborators = Vec::with_capacity(peer_count);
+            collaborators.push(proto::Collaborator {
+                peer_id: joined.project.host_connection_id.0,
+                replica_id: 0,
+                user_id: joined.project.host_user_id.to_proto(),
+            });
+            let worktrees = share
+                .worktrees
+                .iter()
+                .filter_map(|(id, shared_worktree)| {
+                    let worktree = joined.project.worktrees.get(&id)?;
+                    Some(proto::Worktree {
+                        id: *id,
+                        root_name: worktree.root_name.clone(),
+                        entries: shared_worktree.entries.values().cloned().collect(),
+                        diagnostic_summaries: shared_worktree
+                            .diagnostic_summaries
+                            .values()
+                            .cloned()
+                            .collect(),
+                        visible: worktree.visible,
+                        scan_id: shared_worktree.scan_id,
                     })
-                    .collect();
-                for (peer_conn_id, (peer_replica_id, peer_user_id)) in &share.guests {
-                    if *peer_conn_id != request.sender_id {
-                        collaborators.push(proto::Collaborator {
-                            peer_id: peer_conn_id.0,
-                            replica_id: *peer_replica_id as u32,
-                            user_id: peer_user_id.to_proto(),
-                        });
-                    }
+                })
+                .collect();
+            for (peer_conn_id, (peer_replica_id, peer_user_id)) in &share.guests {
+                if *peer_conn_id != request.sender_id {
+                    collaborators.push(proto::Collaborator {
+                        peer_id: peer_conn_id.0,
+                        replica_id: *peer_replica_id as u32,
+                        user_id: peer_user_id.to_proto(),
+                    });
                 }
-                let response = proto::JoinProjectResponse {
-                    worktrees,
-                    replica_id: joined.replica_id as u32,
-                    collaborators,
-                    language_servers: joined.project.language_servers.clone(),
-                };
-                let connection_ids = joined.project.connection_ids();
-                let contact_user_ids = joined.project.authorized_user_ids();
-                Ok((response, connection_ids, contact_user_ids))
-            })?;
-
-        broadcast(request.sender_id, connection_ids, |conn_id| {
-            self.peer.send(
-                conn_id,
-                proto::AddProjectCollaborator {
-                    project_id,
-                    collaborator: Some(proto::Collaborator {
-                        peer_id: request.sender_id.0,
-                        replica_id: response.replica_id,
-                        user_id: user_id.to_proto(),
-                    }),
+            }
+            broadcast(
+                request.sender_id,
+                joined.project.connection_ids(),
+                |conn_id| {
+                    self.peer.send(
+                        conn_id,
+                        proto::AddProjectCollaborator {
+                            project_id,
+                            collaborator: Some(proto::Collaborator {
+                                peer_id: request.sender_id.0,
+                                replica_id: joined.replica_id as u32,
+                                user_id: guest_user_id.to_proto(),
+                            }),
+                        },
+                    )
                 },
-            )
-        });
-        self.update_contacts_for_users(state, &contact_user_ids);
-        Ok(response)
+            );
+            response.send(proto::JoinProjectResponse {
+                worktrees,
+                replica_id: joined.replica_id as u32,
+                collaborators,
+                language_servers: joined.project.language_servers.clone(),
+            })?;
+        }
+        self.update_user_contacts(host_user_id).await?;
+        Ok(())
     }
 
     async fn leave_project(
@@ -495,85 +551,89 @@ impl Server {
     ) -> Result<()> {
         let sender_id = request.sender_id;
         let project_id = request.payload.project_id;
-        let mut state = self.state_mut().await;
-        let worktree = state.leave_project(sender_id, project_id)?;
-        broadcast(sender_id, worktree.connection_ids, |conn_id| {
-            self.peer.send(
-                conn_id,
-                proto::RemoveProjectCollaborator {
-                    project_id,
-                    peer_id: sender_id.0,
-                },
-            )
-        });
-        self.update_contacts_for_users(&*state, &worktree.authorized_user_ids);
+        let project;
+        {
+            let mut state = self.store_mut().await;
+            project = state.leave_project(sender_id, project_id)?;
+            broadcast(sender_id, project.connection_ids, |conn_id| {
+                self.peer.send(
+                    conn_id,
+                    proto::RemoveProjectCollaborator {
+                        project_id,
+                        peer_id: sender_id.0,
+                    },
+                )
+            });
+        }
+        self.update_user_contacts(project.host_user_id).await?;
         Ok(())
     }
 
     async fn register_worktree(
         self: Arc<Server>,
         request: TypedEnvelope<proto::RegisterWorktree>,
-    ) -> Result<proto::Ack> {
-        let mut contact_user_ids = HashSet::default();
-        for github_login in &request.payload.authorized_logins {
-            let contact_user_id = self.app_state.db.create_user(github_login, false).await?;
-            contact_user_ids.insert(contact_user_id);
-        }
-
-        let mut state = self.state_mut().await;
-        let host_user_id = state.user_id_for_connection(request.sender_id)?;
-        contact_user_ids.insert(host_user_id);
-
-        let contact_user_ids = contact_user_ids.into_iter().collect::<Vec<_>>();
-        let guest_connection_ids = state
-            .read_project(request.payload.project_id, request.sender_id)?
-            .guest_connection_ids();
-        state.register_worktree(
-            request.payload.project_id,
-            request.payload.worktree_id,
-            request.sender_id,
-            Worktree {
-                authorized_user_ids: contact_user_ids.clone(),
-                root_name: request.payload.root_name.clone(),
-                visible: request.payload.visible,
-            },
-        )?;
+        response: Response<proto::RegisterWorktree>,
+    ) -> Result<()> {
+        let host_user_id;
+        {
+            let mut state = self.store_mut().await;
+            host_user_id = state.user_id_for_connection(request.sender_id)?;
+
+            let guest_connection_ids = state
+                .read_project(request.payload.project_id, request.sender_id)?
+                .guest_connection_ids();
+            state.register_worktree(
+                request.payload.project_id,
+                request.payload.worktree_id,
+                request.sender_id,
+                Worktree {
+                    root_name: request.payload.root_name.clone(),
+                    visible: request.payload.visible,
+                },
+            )?;
 
-        broadcast(request.sender_id, guest_connection_ids, |connection_id| {
-            self.peer
-                .forward_send(request.sender_id, connection_id, request.payload.clone())
-        });
-        self.update_contacts_for_users(&*state, &contact_user_ids);
-        Ok(proto::Ack {})
+            broadcast(request.sender_id, guest_connection_ids, |connection_id| {
+                self.peer
+                    .forward_send(request.sender_id, connection_id, request.payload.clone())
+            });
+        }
+        self.update_user_contacts(host_user_id).await?;
+        response.send(proto::Ack {})?;
+        Ok(())
     }
 
     async fn unregister_worktree(
         self: Arc<Server>,
         request: TypedEnvelope<proto::UnregisterWorktree>,
     ) -> Result<()> {
+        let host_user_id;
         let project_id = request.payload.project_id;
         let worktree_id = request.payload.worktree_id;
-        let mut state = self.state_mut().await;
-        let (worktree, guest_connection_ids) =
-            state.unregister_worktree(project_id, worktree_id, request.sender_id)?;
-        broadcast(request.sender_id, guest_connection_ids, |conn_id| {
-            self.peer.send(
-                conn_id,
-                proto::UnregisterWorktree {
-                    project_id,
-                    worktree_id,
-                },
-            )
-        });
-        self.update_contacts_for_users(&*state, &worktree.authorized_user_ids);
+        {
+            let mut state = self.store_mut().await;
+            let (_, guest_connection_ids) =
+                state.unregister_worktree(project_id, worktree_id, request.sender_id)?;
+            host_user_id = state.user_id_for_connection(request.sender_id)?;
+            broadcast(request.sender_id, guest_connection_ids, |conn_id| {
+                self.peer.send(
+                    conn_id,
+                    proto::UnregisterWorktree {
+                        project_id,
+                        worktree_id,
+                    },
+                )
+            });
+        }
+        self.update_user_contacts(host_user_id).await?;
         Ok(())
     }
 
     async fn update_worktree(
         self: Arc<Server>,
         request: TypedEnvelope<proto::UpdateWorktree>,
-    ) -> Result<proto::Ack> {
-        let connection_ids = self.state_mut().await.update_worktree(
+        response: Response<proto::UpdateWorktree>,
+    ) -> Result<()> {
+        let connection_ids = self.store_mut().await.update_worktree(
             request.sender_id,
             request.payload.project_id,
             request.payload.worktree_id,
@@ -586,8 +646,8 @@ impl Server {
             self.peer
                 .forward_send(request.sender_id, connection_id, request.payload.clone())
         });
-
-        Ok(proto::Ack {})
+        response.send(proto::Ack {})?;
+        Ok(())
     }
 
     async fn update_diagnostic_summary(
@@ -599,7 +659,7 @@ impl Server {
             .summary
             .clone()
             .ok_or_else(|| anyhow!("invalid summary"))?;
-        let receiver_ids = self.state_mut().await.update_diagnostic_summary(
+        let receiver_ids = self.store_mut().await.update_diagnostic_summary(
             request.payload.project_id,
             request.payload.worktree_id,
             request.sender_id,
@@ -617,7 +677,7 @@ impl Server {
         self: Arc<Server>,
         request: TypedEnvelope<proto::StartLanguageServer>,
     ) -> Result<()> {
-        let receiver_ids = self.state_mut().await.start_language_server(
+        let receiver_ids = self.store_mut().await.start_language_server(
             request.payload.project_id,
             request.sender_id,
             request
@@ -638,7 +698,7 @@ impl Server {
         request: TypedEnvelope<proto::UpdateLanguageServer>,
     ) -> Result<()> {
         let receiver_ids = self
-            .state()
+            .store()
             .await
             .project_connection_ids(request.payload.project_id, request.sender_id)?;
         broadcast(request.sender_id, receiver_ids, |connection_id| {
@@ -651,61 +711,69 @@ impl Server {
     async fn forward_project_request<T>(
         self: Arc<Server>,
         request: TypedEnvelope<T>,
-    ) -> Result<T::Response>
+        response: Response<T>,
+    ) -> Result<()>
     where
         T: EntityMessage + RequestMessage,
     {
         let host_connection_id = self
-            .state()
+            .store()
             .await
             .read_project(request.payload.remote_entity_id(), request.sender_id)?
             .host_connection_id;
-        Ok(self
-            .peer
-            .forward_request(request.sender_id, host_connection_id, request.payload)
-            .await?)
+
+        response.send(
+            self.peer
+                .forward_request(request.sender_id, host_connection_id, request.payload)
+                .await?,
+        )?;
+        Ok(())
     }
 
     async fn save_buffer(
         self: Arc<Server>,
         request: TypedEnvelope<proto::SaveBuffer>,
-    ) -> Result<proto::BufferSaved> {
+        response: Response<proto::SaveBuffer>,
+    ) -> Result<()> {
         let host = self
-            .state()
+            .store()
             .await
             .read_project(request.payload.project_id, request.sender_id)?
             .host_connection_id;
-        let response = self
+        let response_payload = self
             .peer
             .forward_request(request.sender_id, host, request.payload.clone())
             .await?;
 
         let mut guests = self
-            .state()
+            .store()
             .await
             .read_project(request.payload.project_id, request.sender_id)?
             .connection_ids();
         guests.retain(|guest_connection_id| *guest_connection_id != request.sender_id);
         broadcast(host, guests, |conn_id| {
-            self.peer.forward_send(host, conn_id, response.clone())
+            self.peer
+                .forward_send(host, conn_id, response_payload.clone())
         });
-
-        Ok(response)
+        response.send(response_payload)?;
+        Ok(())
     }
 
     async fn update_buffer(
         self: Arc<Server>,
         request: TypedEnvelope<proto::UpdateBuffer>,
-    ) -> Result<proto::Ack> {
+        response: Response<proto::UpdateBuffer>,
+    ) -> Result<()> {
         let receiver_ids = self
-            .state()
+            .store()
             .await
             .project_connection_ids(request.payload.project_id, request.sender_id)?;
         broadcast(request.sender_id, receiver_ids, |connection_id| {
             self.peer
                 .forward_send(request.sender_id, connection_id, request.payload.clone())
         });
-        Ok(proto::Ack {})
+        response.send(proto::Ack {})?;
+        Ok(())
     }
 
     async fn update_buffer_file(
@@ -713,7 +781,7 @@ impl Server {
         request: TypedEnvelope<proto::UpdateBufferFile>,
     ) -> Result<()> {
         let receiver_ids = self
-            .state()
+            .store()
             .await
             .project_connection_ids(request.payload.project_id, request.sender_id)?;
         broadcast(request.sender_id, receiver_ids, |connection_id| {
@@ -728,7 +796,7 @@ impl Server {
         request: TypedEnvelope<proto::BufferReloaded>,
     ) -> Result<()> {
         let receiver_ids = self
-            .state()
+            .store()
             .await
             .project_connection_ids(request.payload.project_id, request.sender_id)?;
         broadcast(request.sender_id, receiver_ids, |connection_id| {
@@ -743,7 +811,7 @@ impl Server {
         request: TypedEnvelope<proto::BufferSaved>,
     ) -> Result<()> {
         let receiver_ids = self
-            .state()
+            .store()
             .await
             .project_connection_ids(request.payload.project_id, request.sender_id)?;
         broadcast(request.sender_id, receiver_ids, |connection_id| {
@@ -756,31 +824,33 @@ impl Server {
     async fn follow(
         self: Arc<Self>,
         request: TypedEnvelope<proto::Follow>,
-    ) -> Result<proto::FollowResponse> {
+        response: Response<proto::Follow>,
+    ) -> Result<()> {
         let leader_id = ConnectionId(request.payload.leader_id);
         let follower_id = request.sender_id;
         if !self
-            .state()
+            .store()
             .await
             .project_connection_ids(request.payload.project_id, follower_id)?
             .contains(&leader_id)
         {
             Err(anyhow!("no such peer"))?;
         }
-        let mut response = self
+        let mut response_payload = self
             .peer
             .forward_request(request.sender_id, leader_id, request.payload)
             .await?;
-        response
+        response_payload
             .views
             .retain(|view| view.leader_id != Some(follower_id.0));
-        Ok(response)
+        response.send(response_payload)?;
+        Ok(())
     }
 
     async fn unfollow(self: Arc<Self>, request: TypedEnvelope<proto::Unfollow>) -> Result<()> {
         let leader_id = ConnectionId(request.payload.leader_id);
         if !self
-            .state()
+            .store()
             .await
             .project_connection_ids(request.payload.project_id, request.sender_id)?
             .contains(&leader_id)
@@ -797,7 +867,7 @@ impl Server {
         request: TypedEnvelope<proto::UpdateFollowers>,
     ) -> Result<()> {
         let connection_ids = self
-            .state()
+            .store()
             .await
             .project_connection_ids(request.payload.project_id, request.sender_id)?;
         let leader_id = request
@@ -822,13 +892,14 @@ impl Server {
     async fn get_channels(
         self: Arc<Server>,
         request: TypedEnvelope<proto::GetChannels>,
-    ) -> Result<proto::GetChannelsResponse> {
+        response: Response<proto::GetChannels>,
+    ) -> Result<()> {
         let user_id = self
-            .state()
+            .store()
             .await
             .user_id_for_connection(request.sender_id)?;
         let channels = self.app_state.db.get_accessible_channels(user_id).await?;
-        Ok(proto::GetChannelsResponse {
+        response.send(proto::GetChannelsResponse {
             channels: channels
                 .into_iter()
                 .map(|chan| proto::Channel {
@@ -836,13 +907,15 @@ impl Server {
                     name: chan.name,
                 })
                 .collect(),
-        })
+        })?;
+        Ok(())
     }
 
     async fn get_users(
         self: Arc<Server>,
         request: TypedEnvelope<proto::GetUsers>,
-    ) -> Result<proto::GetUsersResponse> {
+        response: Response<proto::GetUsers>,
+    ) -> Result<()> {
         let user_ids = request
             .payload
             .user_ids
@@ -861,36 +934,197 @@ impl Server {
                 github_login: user.github_login,
             })
             .collect();
-        Ok(proto::GetUsersResponse { users })
+        response.send(proto::UsersResponse { users })?;
+        Ok(())
     }
 
-    #[instrument(skip(self, state, user_ids))]
-    fn update_contacts_for_users<'a>(
-        self: &Arc<Self>,
-        state: &Store,
-        user_ids: impl IntoIterator<Item = &'a UserId>,
-    ) {
-        for user_id in user_ids {
-            let contacts = state.contacts_for_user(*user_id);
-            for connection_id in state.connection_ids_for_user(*user_id) {
-                self.peer
-                    .send(
-                        connection_id,
-                        proto::UpdateContacts {
-                            contacts: contacts.clone(),
-                        },
-                    )
-                    .trace_err();
-            }
+    async fn fuzzy_search_users(
+        self: Arc<Server>,
+        request: TypedEnvelope<proto::FuzzySearchUsers>,
+        response: Response<proto::FuzzySearchUsers>,
+    ) -> Result<()> {
+        let user_id = self
+            .store()
+            .await
+            .user_id_for_connection(request.sender_id)?;
+        let query = request.payload.query;
+        let db = &self.app_state.db;
+        let users = match query.len() {
+            0 => vec![],
+            1 | 2 => db
+                .get_user_by_github_login(&query)
+                .await?
+                .into_iter()
+                .collect(),
+            _ => db.fuzzy_search_users(&query, 10).await?,
+        };
+        let users = users
+            .into_iter()
+            .filter(|user| user.id != user_id)
+            .map(|user| proto::User {
+                id: user.id.to_proto(),
+                avatar_url: format!("https://github.com/{}.png?size=128", user.github_login),
+                github_login: user.github_login,
+            })
+            .collect();
+        response.send(proto::UsersResponse { users })?;
+        Ok(())
+    }
+
+    async fn request_contact(
+        self: Arc<Server>,
+        request: TypedEnvelope<proto::RequestContact>,
+        response: Response<proto::RequestContact>,
+    ) -> Result<()> {
+        let requester_id = self
+            .store()
+            .await
+            .user_id_for_connection(request.sender_id)?;
+        let responder_id = UserId::from_proto(request.payload.responder_id);
+        if requester_id == responder_id {
+            return Err(anyhow!("cannot add yourself as a contact"))?;
+        }
+
+        self.app_state
+            .db
+            .send_contact_request(requester_id, responder_id)
+            .await?;
+
+        // Update outgoing contact requests of requester
+        let mut update = proto::UpdateContacts::default();
+        update.outgoing_requests.push(responder_id.to_proto());
+        for connection_id in self.store().await.connection_ids_for_user(requester_id) {
+            self.peer.send(connection_id, update.clone())?;
         }
+
+        // Update incoming contact requests of responder
+        let mut update = proto::UpdateContacts::default();
+        update
+            .incoming_requests
+            .push(proto::IncomingContactRequest {
+                requester_id: requester_id.to_proto(),
+                should_notify: true,
+            });
+        for connection_id in self.store().await.connection_ids_for_user(responder_id) {
+            self.peer.send(connection_id, update.clone())?;
+        }
+
+        response.send(proto::Ack {})?;
+        Ok(())
     }
 
+    async fn respond_to_contact_request(
+        self: Arc<Server>,
+        request: TypedEnvelope<proto::RespondToContactRequest>,
+        response: Response<proto::RespondToContactRequest>,
+    ) -> Result<()> {
+        let responder_id = self
+            .store()
+            .await
+            .user_id_for_connection(request.sender_id)?;
+        let requester_id = UserId::from_proto(request.payload.requester_id);
+        let accept = request.payload.response == proto::ContactRequestResponse::Accept as i32;
+        self.app_state
+            .db
+            .respond_to_contact_request(responder_id, requester_id, accept)
+            .await?;
+
+        let store = self.store().await;
+        // Update responder with new contact
+        let mut update = proto::UpdateContacts::default();
+        if accept {
+            update.contacts.push(store.contact_for_user(requester_id));
+        }
+        update
+            .remove_incoming_requests
+            .push(requester_id.to_proto());
+        for connection_id in store.connection_ids_for_user(responder_id) {
+            self.peer.send(connection_id, update.clone())?;
+        }
+
+        // Update requester with new contact
+        let mut update = proto::UpdateContacts::default();
+        if accept {
+            update.contacts.push(store.contact_for_user(responder_id));
+        }
+        update
+            .remove_outgoing_requests
+            .push(responder_id.to_proto());
+        for connection_id in store.connection_ids_for_user(requester_id) {
+            self.peer.send(connection_id, update.clone())?;
+        }
+
+        response.send(proto::Ack {})?;
+        Ok(())
+    }
+
+    async fn remove_contact(
+        self: Arc<Server>,
+        request: TypedEnvelope<proto::RemoveContact>,
+        response: Response<proto::RemoveContact>,
+    ) -> Result<()> {
+        let requester_id = self
+            .store()
+            .await
+            .user_id_for_connection(request.sender_id)?;
+        let responder_id = UserId::from_proto(request.payload.user_id);
+        self.app_state
+            .db
+            .remove_contact(requester_id, responder_id)
+            .await?;
+
+        // Update outgoing contact requests of requester
+        let mut update = proto::UpdateContacts::default();
+        update
+            .remove_outgoing_requests
+            .push(responder_id.to_proto());
+        for connection_id in self.store().await.connection_ids_for_user(requester_id) {
+            self.peer.send(connection_id, update.clone())?;
+        }
+
+        // Update incoming contact requests of responder
+        let mut update = proto::UpdateContacts::default();
+        update
+            .remove_incoming_requests
+            .push(requester_id.to_proto());
+        for connection_id in self.store().await.connection_ids_for_user(responder_id) {
+            self.peer.send(connection_id, update.clone())?;
+        }
+
+        response.send(proto::Ack {})?;
+        Ok(())
+    }
+
+    // #[instrument(skip(self, state, user_ids))]
+    // fn update_contacts_for_users<'a>(
+    //     self: &Arc<Self>,
+    //     state: &Store,
+    //     user_ids: impl IntoIterator<Item = &'a UserId>,
+    // ) {
+    //     for user_id in user_ids {
+    //         let contacts = state.contacts_for_user(*user_id);
+    //         for connection_id in state.connection_ids_for_user(*user_id) {
+    //             self.peer
+    //                 .send(
+    //                     connection_id,
+    //                     proto::UpdateContacts {
+    //                         contacts: contacts.clone(),
+    //                         pending_requests_from_user_ids: Default::default(),
+    //                         pending_requests_to_user_ids: Default::default(),
+    //                     },
+    //                 )
+    //                 .trace_err();
+    //         }
+    //     }
+    // }
+
     async fn join_channel(
         self: Arc<Self>,
         request: TypedEnvelope<proto::JoinChannel>,
-    ) -> Result<proto::JoinChannelResponse> {
+        response: Response<proto::JoinChannel>,
+    ) -> Result<()> {
         let user_id = self
-            .state()
+            .store()
             .await
             .user_id_for_connection(request.sender_id)?;
         let channel_id = ChannelId::from_proto(request.payload.channel_id);

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

@@ -1,4 +1,4 @@
-use crate::db::{ChannelId, UserId};
+use crate::db::{self, ChannelId, UserId};
 use anyhow::{anyhow, Result};
 use collections::{BTreeMap, HashMap, HashSet};
 use rpc::{proto, ConnectionId};
@@ -10,7 +10,6 @@ pub struct Store {
     connections: HashMap<ConnectionId, ConnectionState>,
     connections_by_user_id: HashMap<UserId, HashSet<ConnectionId>>,
     projects: HashMap<u64, Project>,
-    visible_projects_by_user_id: HashMap<UserId, HashSet<u64>>,
     channels: HashMap<ChannelId, Channel>,
     next_project_id: u64,
 }
@@ -30,7 +29,6 @@ pub struct Project {
 }
 
 pub struct Worktree {
-    pub authorized_user_ids: Vec<UserId>,
     pub root_name: String,
     pub visible: bool,
 }
@@ -58,6 +56,7 @@ pub type ReplicaId = u16;
 
 #[derive(Default)]
 pub struct RemovedConnectionState {
+    pub user_id: UserId,
     pub hosted_projects: HashMap<u64, Project>,
     pub guest_project_ids: HashMap<u64, Vec<ConnectionId>>,
     pub contact_ids: HashSet<UserId>,
@@ -68,18 +67,16 @@ pub struct JoinedProject<'a> {
     pub project: &'a Project,
 }
 
-pub struct SharedProject {
-    pub authorized_user_ids: Vec<UserId>,
-}
+pub struct SharedProject {}
 
 pub struct UnsharedProject {
     pub connection_ids: Vec<ConnectionId>,
-    pub authorized_user_ids: Vec<UserId>,
+    pub host_user_id: UserId,
 }
 
 pub struct LeftProject {
     pub connection_ids: Vec<ConnectionId>,
-    pub authorized_user_ids: Vec<UserId>,
+    pub host_user_id: UserId,
 }
 
 #[derive(Copy, Clone)]
@@ -151,15 +148,14 @@ impl Store {
         }
 
         let mut result = RemovedConnectionState::default();
+        result.user_id = connection.user_id;
         for project_id in connection.projects.clone() {
             if let Ok(project) = self.unregister_project(project_id, connection_id) {
-                result.contact_ids.extend(project.authorized_user_ids());
                 result.hosted_projects.insert(project_id, project);
             } else if let Ok(project) = self.leave_project(connection_id, project_id) {
                 result
                     .guest_project_ids
                     .insert(project_id, project.connection_ids);
-                result.contact_ids.extend(project.authorized_user_ids);
             }
         }
 
@@ -213,51 +209,123 @@ impl Store {
             .copied()
     }
 
-    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
+    pub fn is_user_online(&self, user_id: UserId) -> bool {
+        !self
+            .connections_by_user_id
             .get(&user_id)
-            .unwrap_or(&HashSet::default())
-        {
-            let project = &self.projects[project_id];
+            .unwrap_or(&Default::default())
+            .is_empty()
+    }
 
-            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());
-                    }
-                }
-            }
+    pub fn build_initial_contacts_update(&self, contacts: db::Contacts) -> proto::UpdateContacts {
+        let mut update = proto::UpdateContacts::default();
+        for user_id in contacts.current {
+            update.contacts.push(self.contact_for_user(user_id));
+        }
 
-            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(),
-                    });
-            }
+        for request in contacts.incoming_requests {
+            update
+                .incoming_requests
+                .push(proto::IncomingContactRequest {
+                    requester_id: request.requester_id.to_proto(),
+                    should_notify: request.should_notify,
+                })
+        }
+
+        for requested_user_id in contacts.outgoing_requests {
+            update.outgoing_requests.push(requested_user_id.to_proto())
         }
 
-        contacts.into_values().collect()
+        update
     }
 
+    pub fn contact_for_user(&self, user_id: UserId) -> proto::Contact {
+        proto::Contact {
+            user_id: user_id.to_proto(),
+            projects: self.project_metadata_for_user(user_id),
+            online: self.is_user_online(user_id),
+        }
+    }
+
+    pub fn project_metadata_for_user(&self, user_id: UserId) -> Vec<proto::ProjectMetadata> {
+        let connection_ids = self.connections_by_user_id.get(&user_id);
+        let project_ids = connection_ids.iter().flat_map(|connection_ids| {
+            connection_ids
+                .iter()
+                .filter_map(|connection_id| self.connections.get(connection_id))
+                .flat_map(|connection| connection.projects.iter().copied())
+        });
+
+        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(),
+                });
+            }
+        }
+
+        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,
@@ -293,13 +361,6 @@ impl Store {
             .get_mut(&project_id)
             .ok_or_else(|| anyhow!("no such project"))?;
         if project.host_connection_id == connection_id {
-            for authorized_user_id in &worktree.authorized_user_ids {
-                self.visible_projects_by_user_id
-                    .entry(*authorized_user_id)
-                    .or_default()
-                    .insert(project_id);
-            }
-
             project.worktrees.insert(worktree_id, worktree);
             if let Ok(share) = project.share_mut() {
                 share.worktrees.insert(worktree_id, Default::default());
@@ -319,14 +380,6 @@ impl Store {
         match self.projects.entry(project_id) {
             hash_map::Entry::Occupied(e) => {
                 if e.get().host_connection_id == connection_id {
-                    for user_id in e.get().authorized_user_ids() {
-                        if let hash_map::Entry::Occupied(mut projects) =
-                            self.visible_projects_by_user_id.entry(user_id)
-                        {
-                            projects.get_mut().remove(&project_id);
-                        }
-                    }
-
                     let project = e.remove();
 
                     if let Some(host_connection) = self.connections.get_mut(&connection_id) {
@@ -375,16 +428,6 @@ impl Store {
             share.worktrees.remove(&worktree_id);
         }
 
-        for authorized_user_id in &worktree.authorized_user_ids {
-            if let Some(visible_projects) =
-                self.visible_projects_by_user_id.get_mut(authorized_user_id)
-            {
-                if !project.has_authorized_user_id(*authorized_user_id) {
-                    visible_projects.remove(&project_id);
-                }
-            }
-        }
-
         Ok((worktree, guest_connection_ids))
     }
 
@@ -400,9 +443,7 @@ impl Store {
                     share.worktrees.insert(*worktree_id, Default::default());
                 }
                 project.share = Some(share);
-                return Ok(SharedProject {
-                    authorized_user_ids: project.authorized_user_ids(),
-                });
+                return Ok(SharedProject {});
             }
         }
         Err(anyhow!("no such project"))?
@@ -424,7 +465,6 @@ impl Store {
         }
 
         let connection_ids = project.connection_ids();
-        let authorized_user_ids = project.authorized_user_ids();
         if let Some(share) = project.share.take() {
             for connection_id in share.guests.into_keys() {
                 if let Some(connection) = self.connections.get_mut(&connection_id) {
@@ -434,7 +474,7 @@ impl Store {
 
             Ok(UnsharedProject {
                 connection_ids,
-                authorized_user_ids,
+                host_user_id: project.host_user_id,
             })
         } else {
             Err(anyhow!("project is not shared"))?
@@ -498,13 +538,6 @@ impl Store {
         let project = self
             .projects
             .get_mut(&project_id)
-            .and_then(|project| {
-                if project.has_authorized_user_id(user_id) {
-                    Some(project)
-                } else {
-                    None
-                }
-            })
             .ok_or_else(|| anyhow!("no such project"))?;
 
         let share = project.share_mut()?;
@@ -546,12 +579,9 @@ impl Store {
             connection.projects.remove(&project_id);
         }
 
-        let connection_ids = project.connection_ids();
-        let authorized_user_ids = project.authorized_user_ids();
-
         Ok(LeftProject {
-            connection_ids,
-            authorized_user_ids,
+            connection_ids: project.connection_ids(),
+            host_user_id: project.host_user_id,
         })
     }
 
@@ -599,9 +629,10 @@ impl Store {
             .connection_ids())
     }
 
-    #[cfg(test)]
-    pub fn project(&self, project_id: u64) -> Option<&Project> {
-        self.projects.get(&project_id)
+    pub fn project(&self, project_id: u64) -> Result<&Project> {
+        self.projects
+            .get(&project_id)
+            .ok_or_else(|| anyhow!("no such project"))
     }
 
     pub fn read_project(&self, project_id: u64, connection_id: ConnectionId) -> Result<&Project> {
@@ -701,14 +732,6 @@ impl Store {
             let host_connection = self.connections.get(&project.host_connection_id).unwrap();
             assert!(host_connection.projects.contains(project_id));
 
-            for authorized_user_ids in project.authorized_user_ids() {
-                let visible_project_ids = self
-                    .visible_projects_by_user_id
-                    .get(&authorized_user_ids)
-                    .unwrap();
-                assert!(visible_project_ids.contains(project_id));
-            }
-
             if let Some(share) = &project.share {
                 for guest_connection_id in share.guests.keys() {
                     let guest_connection = self.connections.get(guest_connection_id).unwrap();
@@ -726,13 +749,6 @@ impl Store {
             }
         }
 
-        for (user_id, visible_project_ids) in &self.visible_projects_by_user_id {
-            for project_id in visible_project_ids {
-                let project = self.projects.get(project_id).unwrap();
-                assert!(project.authorized_user_ids().contains(user_id));
-            }
-        }
-
         for (channel_id, channel) in &self.channels {
             for connection_id in &channel.connection_ids {
                 let connection = self.connections.get(connection_id).unwrap();
@@ -743,24 +759,6 @@ impl Store {
 }
 
 impl Project {
-    pub fn has_authorized_user_id(&self, user_id: UserId) -> bool {
-        self.worktrees
-            .values()
-            .any(|worktree| worktree.authorized_user_ids.contains(&user_id))
-    }
-
-    pub fn authorized_user_ids(&self) -> Vec<UserId> {
-        let mut ids = self
-            .worktrees
-            .values()
-            .flat_map(|worktree| worktree.authorized_user_ids.iter())
-            .copied()
-            .collect::<Vec<_>>();
-        ids.sort_unstable();
-        ids.dedup();
-        ids
-    }
-
     pub fn guest_connection_ids(&self) -> Vec<ConnectionId> {
         if let Some(share) = &self.share {
             share.guests.keys().copied().collect()

crates/contacts_panel/Cargo.toml 🔗

@@ -9,8 +9,15 @@ doctest = false
 
 [dependencies]
 client = { path = "../client" }
+editor = { path = "../editor" }
+fuzzy = { path = "../fuzzy" }
 gpui = { path = "../gpui" }
+picker = { path = "../picker" }
 settings = { path = "../settings" }
 theme = { path = "../theme" }
+util = { path = "../util" }
 workspace = { path = "../workspace" }
+futures = "0.3"
+log = "0.4"
 postage = { version = "0.4.1", features = ["futures-traits"] }
+serde = { version = "1", features = ["derive"] }

crates/contacts_panel/src/contact_finder.rs 🔗

@@ -0,0 +1,191 @@
+use client::{ContactRequestStatus, User, UserStore};
+use gpui::{
+    actions, elements::*, Entity, ModelHandle, MutableAppContext, RenderContext, Task, View,
+    ViewContext, ViewHandle,
+};
+use picker::{Picker, PickerDelegate};
+use settings::Settings;
+use std::sync::Arc;
+use util::TryFutureExt;
+use workspace::Workspace;
+
+use crate::render_icon_button;
+
+actions!(contact_finder, [Toggle]);
+
+pub fn init(cx: &mut MutableAppContext) {
+    Picker::<ContactFinder>::init(cx);
+    cx.add_action(ContactFinder::toggle);
+}
+
+pub struct ContactFinder {
+    picker: ViewHandle<Picker<Self>>,
+    potential_contacts: Arc<[Arc<User>]>,
+    user_store: ModelHandle<UserStore>,
+    selected_index: usize,
+}
+
+pub enum Event {
+    Dismissed,
+}
+
+impl Entity for ContactFinder {
+    type Event = Event;
+}
+
+impl View for ContactFinder {
+    fn ui_name() -> &'static str {
+        "ContactFinder"
+    }
+
+    fn render(&mut self, _: &mut RenderContext<Self>) -> ElementBox {
+        ChildView::new(self.picker.clone()).boxed()
+    }
+
+    fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
+        cx.focus(&self.picker);
+    }
+}
+
+impl PickerDelegate for ContactFinder {
+    fn match_count(&self) -> usize {
+        self.potential_contacts.len()
+    }
+
+    fn selected_index(&self) -> usize {
+        self.selected_index
+    }
+
+    fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext<Self>) {
+        self.selected_index = ix;
+    }
+
+    fn update_matches(&mut self, query: String, cx: &mut ViewContext<Self>) -> Task<()> {
+        let search_users = self
+            .user_store
+            .update(cx, |store, cx| store.fuzzy_search_users(query, cx));
+
+        cx.spawn(|this, mut cx| async move {
+            async {
+                let potential_contacts = search_users.await?;
+                this.update(&mut cx, |this, cx| {
+                    this.potential_contacts = potential_contacts.into();
+                    cx.notify();
+                });
+                Ok(())
+            }
+            .log_err()
+            .await;
+        })
+    }
+
+    fn confirm(&mut self, cx: &mut ViewContext<Self>) {
+        if let Some(user) = self.potential_contacts.get(self.selected_index) {
+            let user_store = self.user_store.read(cx);
+            match user_store.contact_request_status(user) {
+                ContactRequestStatus::None | ContactRequestStatus::RequestReceived => {
+                    self.user_store
+                        .update(cx, |store, cx| store.request_contact(user.id, cx))
+                        .detach();
+                }
+                ContactRequestStatus::RequestSent => {
+                    self.user_store
+                        .update(cx, |store, cx| store.remove_contact(user.id, cx))
+                        .detach();
+                }
+                _ => {}
+            }
+        }
+    }
+
+    fn dismiss(&mut self, cx: &mut ViewContext<Self>) {
+        cx.emit(Event::Dismissed);
+    }
+
+    fn render_match(
+        &self,
+        ix: usize,
+        mouse_state: &MouseState,
+        selected: bool,
+        cx: &gpui::AppContext,
+    ) -> ElementBox {
+        let theme = &cx.global::<Settings>().theme;
+        let user = &self.potential_contacts[ix];
+        let request_status = self.user_store.read(cx).contact_request_status(&user);
+
+        let icon_path = match request_status {
+            ContactRequestStatus::None | ContactRequestStatus::RequestReceived => {
+                "icons/accept.svg"
+            }
+            ContactRequestStatus::RequestSent | ContactRequestStatus::RequestAccepted => {
+                "icons/reject.svg"
+            }
+        };
+        let button_style = if self.user_store.read(cx).is_contact_request_pending(&user) {
+            &theme.contact_finder.disabled_contact_button
+        } else {
+            &theme.contact_finder.contact_button
+        };
+        let style = theme.picker.item.style_for(mouse_state, selected);
+        Flex::row()
+            .with_children(user.avatar.clone().map(|avatar| {
+                Image::new(avatar)
+                    .with_style(theme.contact_finder.contact_avatar)
+                    .aligned()
+                    .left()
+                    .boxed()
+            }))
+            .with_child(
+                Label::new(user.github_login.clone(), style.label.clone())
+                    .contained()
+                    .with_style(theme.contact_finder.contact_username)
+                    .aligned()
+                    .left()
+                    .boxed(),
+            )
+            .with_child(
+                render_icon_button(button_style, icon_path)
+                    .aligned()
+                    .flex_float()
+                    .boxed(),
+            )
+            .contained()
+            .with_style(style.container)
+            .constrained()
+            .with_height(theme.contact_finder.row_height)
+            .boxed()
+    }
+}
+
+impl ContactFinder {
+    fn toggle(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext<Workspace>) {
+        workspace.toggle_modal(cx, |cx, workspace| {
+            let finder = cx.add_view(|cx| Self::new(workspace.user_store().clone(), cx));
+            cx.subscribe(&finder, Self::on_event).detach();
+            finder
+        });
+    }
+
+    pub fn new(user_store: ModelHandle<UserStore>, cx: &mut ViewContext<Self>) -> Self {
+        let this = cx.weak_handle();
+        Self {
+            picker: cx.add_view(|cx| Picker::new(this, cx)),
+            potential_contacts: Arc::from([]),
+            user_store,
+            selected_index: 0,
+        }
+    }
+
+    fn on_event(
+        workspace: &mut Workspace,
+        _: ViewHandle<Self>,
+        event: &Event,
+        cx: &mut ViewContext<Workspace>,
+    ) {
+        match event {
+            Event::Dismissed => {
+                workspace.dismiss_modal(cx);
+            }
+        }
+    }
+}

crates/contacts_panel/src/contacts_panel.rs 🔗

@@ -1,63 +1,149 @@
-use client::{Contact, UserStore};
+mod contact_finder;
+
+use client::{Contact, User, UserStore};
+use editor::{Cancel, Editor};
+use fuzzy::{match_strings, StringMatchCandidate};
 use gpui::{
     elements::*,
     geometry::{rect::RectF, vector::vec2f},
+    impl_actions,
     platform::CursorStyle,
-    Element, ElementBox, Entity, LayoutContext, ModelHandle, RenderContext, Subscription, View,
-    ViewContext,
+    Element, ElementBox, Entity, LayoutContext, ModelHandle, MutableAppContext, RenderContext,
+    Subscription, View, ViewContext, ViewHandle,
 };
+use serde::Deserialize;
 use settings::Settings;
 use std::sync::Arc;
+use theme::IconButton;
 use workspace::{AppState, JoinProject};
 
+impl_actions!(
+    contacts_panel,
+    [RequestContact, RemoveContact, RespondToContactRequest]
+);
+
+#[derive(Debug)]
+enum ContactEntry {
+    Header(&'static str),
+    IncomingRequest(Arc<User>),
+    OutgoingRequest(Arc<User>),
+    Contact(Arc<Contact>),
+}
+
 pub struct ContactsPanel {
-    contacts: ListState,
+    entries: Vec<ContactEntry>,
+    match_candidates: Vec<StringMatchCandidate>,
+    list_state: ListState,
     user_store: ModelHandle<UserStore>,
+    filter_editor: ViewHandle<Editor>,
     _maintain_contacts: Subscription,
 }
 
+#[derive(Clone, Deserialize)]
+pub struct RequestContact(pub u64);
+
+#[derive(Clone, Deserialize)]
+pub struct RemoveContact(pub u64);
+
+#[derive(Clone, Deserialize)]
+pub struct RespondToContactRequest {
+    pub user_id: u64,
+    pub accept: bool,
+}
+
+pub fn init(cx: &mut MutableAppContext) {
+    contact_finder::init(cx);
+    cx.add_action(ContactsPanel::request_contact);
+    cx.add_action(ContactsPanel::remove_contact);
+    cx.add_action(ContactsPanel::respond_to_contact_request);
+    cx.add_action(ContactsPanel::clear_filter);
+}
+
 impl ContactsPanel {
     pub fn new(app_state: Arc<AppState>, cx: &mut ViewContext<Self>) -> Self {
-        Self {
-            contacts: ListState::new(
-                app_state.user_store.read(cx).contacts().len(),
-                Orientation::Top,
-                1000.,
-                {
-                    let app_state = app_state.clone();
-                    move |ix, cx| {
-                        let user_store = app_state.user_store.read(cx);
-                        let contacts = user_store.contacts().clone();
-                        let current_user_id = user_store.current_user().map(|user| user.id);
-                        Self::render_collaborator(
-                            &contacts[ix],
+        let user_query_editor = cx.add_view(|cx| {
+            let mut editor = Editor::single_line(
+                Some(|theme| theme.contacts_panel.user_query_editor.clone()),
+                cx,
+            );
+            editor.set_placeholder_text("Filter contacts", cx);
+            editor
+        });
+
+        cx.subscribe(&user_query_editor, |this, _, event, cx| {
+            if let editor::Event::BufferEdited = event {
+                this.update_entries(cx)
+            }
+        })
+        .detach();
+
+        let mut this = Self {
+            list_state: ListState::new(0, Orientation::Top, 1000., {
+                let this = cx.weak_handle();
+                let app_state = app_state.clone();
+                move |ix, cx| {
+                    let this = this.upgrade(cx).unwrap();
+                    let this = this.read(cx);
+                    let theme = cx.global::<Settings>().theme.clone();
+                    let theme = &theme.contacts_panel;
+                    let current_user_id =
+                        this.user_store.read(cx).current_user().map(|user| user.id);
+
+                    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::IncomingRequest(user) => Self::render_contact_request(
+                            user.clone(),
+                            this.user_store.clone(),
+                            theme,
+                            true,
+                            cx,
+                        ),
+                        ContactEntry::OutgoingRequest(user) => Self::render_contact_request(
+                            user.clone(),
+                            this.user_store.clone(),
+                            theme,
+                            false,
+                            cx,
+                        ),
+                        ContactEntry::Contact(contact) => Self::render_contact(
+                            contact.clone(),
                             current_user_id,
                             app_state.clone(),
+                            theme,
                             cx,
-                        )
+                        ),
                     }
-                },
-            ),
-            _maintain_contacts: cx.observe(&app_state.user_store, Self::update_contacts),
+                }
+            }),
+            entries: Default::default(),
+            match_candidates: Default::default(),
+            filter_editor: user_query_editor,
+            _maintain_contacts: cx
+                .observe(&app_state.user_store, |this, _, cx| this.update_entries(cx)),
             user_store: app_state.user_store.clone(),
-        }
+        };
+        this.update_entries(cx);
+        this
     }
 
-    fn update_contacts(&mut self, _: ModelHandle<UserStore>, cx: &mut ViewContext<Self>) {
-        self.contacts
-            .reset(self.user_store.read(cx).contacts().len());
-        cx.notify();
-    }
-
-    fn render_collaborator(
-        collaborator: &Contact,
+    fn render_contact(
+        contact: Arc<Contact>,
         current_user_id: Option<u64>,
         app_state: Arc<AppState>,
+        theme: &theme::ContactsPanel,
         cx: &mut LayoutContext,
     ) -> ElementBox {
-        let theme = cx.global::<Settings>().theme.clone();
-        let theme = &theme.contacts_panel;
-        let project_count = collaborator.projects.len();
+        let project_count = contact.non_empty_projects().count();
         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);
@@ -66,44 +152,42 @@ impl ContactsPanel {
         let tree_branch_width = theme.tree_branch_width;
         let tree_branch_color = theme.tree_branch_color;
         let host_avatar_height = theme
-            .host_avatar
+            .contact_avatar
             .width
-            .or(theme.host_avatar.height)
+            .or(theme.contact_avatar.height)
             .unwrap_or(0.);
 
         Flex::column()
             .with_child(
                 Flex::row()
-                    .with_children(collaborator.user.avatar.clone().map(|avatar| {
+                    .with_children(contact.user.avatar.clone().map(|avatar| {
                         Image::new(avatar)
-                            .with_style(theme.host_avatar)
+                            .with_style(theme.contact_avatar)
                             .aligned()
                             .left()
                             .boxed()
                     }))
                     .with_child(
                         Label::new(
-                            collaborator.user.github_login.clone(),
-                            theme.host_username.text.clone(),
+                            contact.user.github_login.clone(),
+                            theme.contact_username.text.clone(),
                         )
                         .contained()
-                        .with_style(theme.host_username.container)
+                        .with_style(theme.contact_username.container)
                         .aligned()
                         .left()
                         .boxed(),
                     )
                     .constrained()
-                    .with_height(theme.host_row_height)
+                    .with_height(theme.row_height)
                     .boxed(),
             )
             .with_children(
-                collaborator
-                    .projects
-                    .iter()
+                contact
+                    .non_empty_projects()
                     .enumerate()
                     .map(|(ix, project)| {
                         let project_id = project.id;
-
                         Flex::row()
                             .with_child(
                                 Canvas::new(move |bounds, _, cx| {
@@ -145,7 +229,7 @@ impl ContactsPanel {
                                 .boxed(),
                             )
                             .with_child({
-                                let is_host = Some(collaborator.user.id) == current_user_id;
+                                let is_host = Some(contact.user.id) == current_user_id;
                                 let is_guest = !is_host
                                     && project
                                         .guests
@@ -199,7 +283,7 @@ impl ContactsPanel {
                                             .boxed()
                                     },
                                 )
-                                .with_cursor_style(if is_host || is_shared {
+                                .with_cursor_style(if !is_host && is_shared {
                                     CursorStyle::PointingHand
                                 } else {
                                     CursorStyle::Arrow
@@ -220,8 +304,281 @@ impl ContactsPanel {
                             .boxed()
                     }),
             )
+            .contained()
+            .with_style(theme.row.clone())
+            .boxed()
+    }
+
+    fn render_contact_request(
+        user: Arc<User>,
+        user_store: ModelHandle<UserStore>,
+        theme: &theme::ContactsPanel,
+        is_incoming: bool,
+        cx: &mut LayoutContext,
+    ) -> ElementBox {
+        enum Reject {}
+        enum Accept {}
+        enum Cancel {}
+
+        let mut row = Flex::row()
+            .with_children(user.avatar.clone().map(|avatar| {
+                Image::new(avatar)
+                    .with_style(theme.contact_avatar)
+                    .aligned()
+                    .left()
+                    .boxed()
+            }))
+            .with_child(
+                Label::new(
+                    user.github_login.clone(),
+                    theme.contact_username.text.clone(),
+                )
+                .contained()
+                .with_style(theme.contact_username.container)
+                .aligned()
+                .left()
+                .boxed(),
+            );
+
+        let user_id = user.id;
+        let is_contact_request_pending = user_store.read(cx).is_contact_request_pending(&user);
+
+        if is_incoming {
+            row.add_children([
+                MouseEventHandler::new::<Reject, _, _>(user.id as usize, cx, |mouse_state, _| {
+                    let button_style = if is_contact_request_pending {
+                        &theme.disabled_contact_button
+                    } else {
+                        &theme.contact_button.style_for(mouse_state, false)
+                    };
+                    render_icon_button(button_style, "icons/reject.svg")
+                        .aligned()
+                        .flex_float()
+                        .boxed()
+                })
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(move |_, cx| {
+                    cx.dispatch_action(RespondToContactRequest {
+                        user_id,
+                        accept: false,
+                    })
+                })
+                .flex_float()
+                .boxed(),
+                MouseEventHandler::new::<Accept, _, _>(user.id as usize, cx, |mouse_state, _| {
+                    let button_style = if is_contact_request_pending {
+                        &theme.disabled_contact_button
+                    } else {
+                        &theme.contact_button.style_for(mouse_state, false)
+                    };
+                    render_icon_button(button_style, "icons/accept.svg")
+                        .aligned()
+                        .flex_float()
+                        .boxed()
+                })
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(move |_, cx| {
+                    cx.dispatch_action(RespondToContactRequest {
+                        user_id,
+                        accept: true,
+                    })
+                })
+                .boxed(),
+            ]);
+        } else {
+            row.add_child(
+                MouseEventHandler::new::<Cancel, _, _>(user.id as usize, cx, |mouse_state, _| {
+                    let button_style = if is_contact_request_pending {
+                        &theme.disabled_contact_button
+                    } else {
+                        &theme.contact_button.style_for(mouse_state, false)
+                    };
+                    render_icon_button(button_style, "icons/reject.svg")
+                        .aligned()
+                        .flex_float()
+                        .boxed()
+                })
+                .with_padding(Padding::uniform(2.))
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(move |_, cx| cx.dispatch_action(RemoveContact(user_id)))
+                .flex_float()
+                .boxed(),
+            );
+        }
+
+        row.constrained()
+            .with_height(theme.row_height)
+            .contained()
+            .with_style(theme.row)
             .boxed()
     }
+
+    fn update_entries(&mut self, cx: &mut ViewContext<Self>) {
+        let user_store = self.user_store.read(cx);
+        let query = self.filter_editor.read(cx).text(cx);
+        let executor = cx.background().clone();
+
+        self.entries.clear();
+
+        let mut request_entries = Vec::new();
+        let incoming = user_store.incoming_contact_requests();
+        if !incoming.is_empty() {
+            self.match_candidates.clear();
+            self.match_candidates
+                .extend(
+                    incoming
+                        .iter()
+                        .enumerate()
+                        .map(|(ix, user)| StringMatchCandidate {
+                            id: ix,
+                            string: user.github_login.clone(),
+                            char_bag: user.github_login.chars().collect(),
+                        }),
+                );
+            let matches = executor.block(match_strings(
+                &self.match_candidates,
+                &query,
+                true,
+                usize::MAX,
+                &Default::default(),
+                executor.clone(),
+            ));
+            if !matches.is_empty() {
+                request_entries.extend(
+                    matches.iter().map(|mat| {
+                        ContactEntry::IncomingRequest(incoming[mat.candidate_id].clone())
+                    }),
+                );
+            }
+        }
+
+        let outgoing = user_store.outgoing_contact_requests();
+        if !outgoing.is_empty() {
+            self.match_candidates.clear();
+            self.match_candidates
+                .extend(
+                    outgoing
+                        .iter()
+                        .enumerate()
+                        .map(|(ix, user)| StringMatchCandidate {
+                            id: ix,
+                            string: user.github_login.clone(),
+                            char_bag: user.github_login.chars().collect(),
+                        }),
+                );
+            let matches = executor.block(match_strings(
+                &self.match_candidates,
+                &query,
+                true,
+                usize::MAX,
+                &Default::default(),
+                executor.clone(),
+            ));
+            if !matches.is_empty() {
+                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);
+        }
+
+        let contacts = user_store.contacts();
+        if !contacts.is_empty() {
+            self.match_candidates.clear();
+            self.match_candidates
+                .extend(
+                    contacts
+                        .iter()
+                        .enumerate()
+                        .map(|(ix, contact)| StringMatchCandidate {
+                            id: ix,
+                            string: contact.user.github_login.clone(),
+                            char_bag: contact.user.github_login.chars().collect(),
+                        }),
+                );
+            let matches = executor.block(match_strings(
+                &self.match_candidates,
+                &query,
+                true,
+                usize::MAX,
+                &Default::default(),
+                executor.clone(),
+            ));
+
+            let (online_contacts, offline_contacts) = matches
+                .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())),
+                );
+            }
+
+            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())),
+                );
+            }
+        }
+
+        self.list_state.reset(self.entries.len());
+        cx.notify();
+    }
+
+    fn request_contact(&mut self, request: &RequestContact, cx: &mut ViewContext<Self>) {
+        self.user_store
+            .update(cx, |store, cx| store.request_contact(request.0, cx))
+            .detach();
+    }
+
+    fn remove_contact(&mut self, request: &RemoveContact, cx: &mut ViewContext<Self>) {
+        self.user_store
+            .update(cx, |store, cx| store.remove_contact(request.0, cx))
+            .detach();
+    }
+
+    fn respond_to_contact_request(
+        &mut self,
+        action: &RespondToContactRequest,
+        cx: &mut ViewContext<Self>,
+    ) {
+        self.user_store
+            .update(cx, |store, cx| {
+                store.respond_to_contact_request(action.user_id, action.accept, cx)
+            })
+            .detach();
+    }
+
+    fn clear_filter(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
+        self.filter_editor
+            .update(cx, |editor, cx| editor.set_text("", cx));
+    }
+}
+
+fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element {
+    Svg::new(svg_path)
+        .with_color(style.color)
+        .constrained()
+        .with_width(style.icon_width)
+        .aligned()
+        .contained()
+        .with_style(style.container)
+        .constrained()
+        .with_width(style.button_width)
+        .with_height(style.button_width)
 }
 
 pub enum Event {}
@@ -236,9 +593,44 @@ impl View for ContactsPanel {
     }
 
     fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
-        let theme = &cx.global::<Settings>().theme.contacts_panel;
-        Container::new(List::new(self.contacts.clone()).boxed())
-            .with_style(theme.container)
-            .boxed()
+        enum AddContact {}
+
+        let theme = cx.global::<Settings>().theme.clone();
+        let theme = &theme.contacts_panel;
+        Container::new(
+            Flex::column()
+                .with_child(
+                    Flex::row()
+                        .with_child(
+                            ChildView::new(self.filter_editor.clone())
+                                .contained()
+                                .with_style(theme.user_query_editor.container)
+                                .flex(1., true)
+                                .boxed(),
+                        )
+                        .with_child(
+                            MouseEventHandler::new::<AddContact, _, _>(0, cx, |_, _| {
+                                Svg::new("icons/add-contact.svg")
+                                    .with_color(theme.add_contact_button.color)
+                                    .constrained()
+                                    .with_height(12.)
+                                    .contained()
+                                    .with_style(theme.add_contact_button.container)
+                                    .aligned()
+                                    .boxed()
+                            })
+                            .with_cursor_style(CursorStyle::PointingHand)
+                            .on_click(|_, cx| cx.dispatch_action(contact_finder::Toggle))
+                            .boxed(),
+                        )
+                        .constrained()
+                        .with_height(theme.user_query_editor_height)
+                        .boxed(),
+                )
+                .with_child(List::new(self.list_state.clone()).flex(1., false).boxed())
+                .boxed(),
+        )
+        .with_style(theme.container)
+        .boxed()
     }
 }

crates/fuzzy/src/fuzzy.rs 🔗

@@ -185,6 +185,18 @@ pub async fn match_strings(
         return Default::default();
     }
 
+    if query.is_empty() {
+        return candidates
+            .iter()
+            .map(|candidate| StringMatch {
+                candidate_id: candidate.id,
+                score: 0.,
+                positions: Default::default(),
+                string: candidate.string.clone(),
+            })
+            .collect();
+    }
+
     let lowercase_query = query.to_lowercase().chars().collect::<Vec<_>>();
     let query = query.chars().collect::<Vec<_>>();
 
@@ -195,7 +207,7 @@ pub async fn match_strings(
     let num_cpus = background.num_cpus().min(candidates.len());
     let segment_size = (candidates.len() + num_cpus - 1) / num_cpus;
     let mut segment_results = (0..num_cpus)
-        .map(|_| Vec::with_capacity(max_results))
+        .map(|_| Vec::with_capacity(max_results.min(candidates.len())))
         .collect::<Vec<_>>();
 
     background

crates/gpui/src/elements/flex.rs 🔗

@@ -117,14 +117,15 @@ impl Element for Flex {
     ) -> (Vector2F, Self::LayoutState) {
         let mut total_flex = None;
         let mut fixed_space = 0.0;
+        let mut contains_float = false;
 
         let cross_axis = self.axis.invert();
         let mut cross_axis_max: f32 = 0.0;
         for child in &mut self.children {
-            if let Some(flex) = child
-                .metadata::<FlexParentData>()
-                .and_then(|metadata| metadata.flex.map(|(flex, _)| flex))
-            {
+            let metadata = child.metadata::<FlexParentData>();
+            contains_float |= metadata.map_or(false, |metadata| metadata.float);
+
+            if let Some(flex) = metadata.and_then(|metadata| metadata.flex.map(|(flex, _)| flex)) {
                 *total_flex.get_or_insert(0.) += flex;
             } else {
                 let child_constraint = match self.axis {
@@ -177,6 +178,13 @@ impl Element for Flex {
             }
         };
 
+        if contains_float {
+            match self.axis {
+                Axis::Horizontal => size.set_x(size.x().max(constraint.max.x())),
+                Axis::Vertical => size.set_y(size.y().max(constraint.max.y())),
+            }
+        }
+
         if constraint.min.x().is_finite() {
             size.set_x(size.x().max(constraint.min.x()));
         }
@@ -225,7 +233,9 @@ impl Element for Flex {
         remaining_space: &mut Self::LayoutState,
         cx: &mut PaintContext,
     ) -> Self::PaintState {
-        let overflowing = *remaining_space < 0.;
+        let mut remaining_space = *remaining_space;
+
+        let overflowing = remaining_space < 0.;
         if overflowing {
             cx.scene.push_layer(Some(bounds));
         }
@@ -240,14 +250,14 @@ impl Element for Flex {
         }
 
         for child in &mut self.children {
-            if *remaining_space > 0. {
+            if remaining_space > 0. {
                 if let Some(metadata) = child.metadata::<FlexParentData>() {
                     if metadata.float {
                         match self.axis {
-                            Axis::Horizontal => child_origin += vec2f(*remaining_space, 0.0),
-                            Axis::Vertical => child_origin += vec2f(0.0, *remaining_space),
+                            Axis::Horizontal => child_origin += vec2f(remaining_space, 0.0),
+                            Axis::Vertical => child_origin += vec2f(0.0, remaining_space),
                         }
-                        *remaining_space = 0.;
+                        remaining_space = 0.;
                     }
                 }
             }
@@ -257,6 +267,7 @@ impl Element for Flex {
                 Axis::Vertical => child_origin += vec2f(0.0, child.size().y()),
             }
         }
+
         if overflowing {
             cx.scene.pop_layer();
         }

crates/gpui/src/platform/mac/atlas.rs 🔗

@@ -12,10 +12,10 @@ pub struct AtlasAllocator {
     device: Device,
     texture_descriptor: TextureDescriptor,
     atlases: Vec<Atlas>,
-    free_atlases: Vec<Atlas>,
+    last_used_atlas_id: usize,
 }
 
-#[derive(Copy, Clone)]
+#[derive(Copy, Clone, Debug)]
 pub struct AllocId {
     pub atlas_id: usize,
     alloc_id: etagere::AllocId,
@@ -23,15 +23,15 @@ pub struct AllocId {
 
 impl AtlasAllocator {
     pub fn new(device: Device, texture_descriptor: TextureDescriptor) -> Self {
-        let mut me = Self {
+        let mut this = Self {
             device,
             texture_descriptor,
-            atlases: Vec::new(),
-            free_atlases: Vec::new(),
+            atlases: vec![],
+            last_used_atlas_id: 0,
         };
-        let atlas = me.new_atlas(Vector2I::zero());
-        me.atlases.push(atlas);
-        me
+        let atlas = this.new_atlas(Vector2I::zero());
+        this.atlases.push(atlas);
+        this
     }
 
     pub fn default_atlas_size(&self) -> Vector2I {
@@ -42,17 +42,27 @@ impl AtlasAllocator {
     }
 
     pub fn allocate(&mut self, requested_size: Vector2I) -> Option<(AllocId, Vector2I)> {
-        let allocation = self
-            .atlases
-            .last_mut()
-            .unwrap()
+        let atlas_id = self.last_used_atlas_id;
+        if let Some((alloc_id, origin)) = self.atlases[atlas_id].allocate(requested_size) {
+            return Some((AllocId { atlas_id, alloc_id }, origin));
+        }
+
+        for (atlas_id, atlas) in self.atlases.iter_mut().enumerate() {
+            if atlas_id == self.last_used_atlas_id {
+                continue;
+            }
+            if let Some((alloc_id, origin)) = atlas.allocate(requested_size) {
+                self.last_used_atlas_id = atlas_id;
+                return Some((AllocId { atlas_id, alloc_id }, origin));
+            }
+        }
+
+        let atlas_id = self.atlases.len();
+        let mut atlas = self.new_atlas(requested_size);
+        let allocation = atlas
             .allocate(requested_size)
-            .or_else(|| {
-                let mut atlas = self.new_atlas(requested_size);
-                let (id, origin) = atlas.allocate(requested_size)?;
-                self.atlases.push(atlas);
-                Some((id, origin))
-            });
+            .map(|(alloc_id, origin)| (AllocId { atlas_id, alloc_id }, origin));
+        self.atlases.push(atlas);
 
         if allocation.is_none() {
             warn!(
@@ -61,13 +71,7 @@ impl AtlasAllocator {
             );
         }
 
-        let (alloc_id, origin) = allocation?;
-
-        let id = AllocId {
-            atlas_id: self.atlases.len() - 1,
-            alloc_id,
-        };
-        Some((id, origin))
+        allocation
     }
 
     pub fn upload(&mut self, size: Vector2I, bytes: &[u8]) -> Option<(AllocId, RectI)> {
@@ -80,9 +84,6 @@ impl AtlasAllocator {
     pub fn deallocate(&mut self, id: AllocId) {
         if let Some(atlas) = self.atlases.get_mut(id.atlas_id) {
             atlas.deallocate(id.alloc_id);
-            if atlas.is_empty() {
-                self.free_atlases.push(self.atlases.remove(id.atlas_id));
-            }
         }
     }
 
@@ -90,7 +91,6 @@ impl AtlasAllocator {
         for atlas in &mut self.atlases {
             atlas.clear();
         }
-        self.free_atlases.extend(self.atlases.drain(1..));
     }
 
     pub fn texture(&self, atlas_id: usize) -> Option<&metal::TextureRef> {
@@ -98,28 +98,22 @@ impl AtlasAllocator {
     }
 
     fn new_atlas(&mut self, required_size: Vector2I) -> Atlas {
-        if let Some(i) = self.free_atlases.iter().rposition(|atlas| {
-            atlas.size().x() >= required_size.x() && atlas.size().y() >= required_size.y()
-        }) {
-            self.free_atlases.remove(i)
-        } else {
-            let size = self.default_atlas_size().max(required_size);
-            let texture = if size.x() as u64 > self.texture_descriptor.width()
-                || size.y() as u64 > self.texture_descriptor.height()
-            {
-                let descriptor = unsafe {
-                    let descriptor_ptr: *mut metal::MTLTextureDescriptor =
-                        msg_send![self.texture_descriptor, copy];
-                    metal::TextureDescriptor::from_ptr(descriptor_ptr)
-                };
-                descriptor.set_width(size.x() as u64);
-                descriptor.set_height(size.y() as u64);
-                self.device.new_texture(&descriptor)
-            } else {
-                self.device.new_texture(&self.texture_descriptor)
+        let size = self.default_atlas_size().max(required_size);
+        let texture = if size.x() as u64 > self.texture_descriptor.width()
+            || size.y() as u64 > self.texture_descriptor.height()
+        {
+            let descriptor = unsafe {
+                let descriptor_ptr: *mut metal::MTLTextureDescriptor =
+                    msg_send![self.texture_descriptor, copy];
+                metal::TextureDescriptor::from_ptr(descriptor_ptr)
             };
-            Atlas::new(size, texture)
-        }
+            descriptor.set_width(size.x() as u64);
+            descriptor.set_height(size.y() as u64);
+            self.device.new_texture(&descriptor)
+        } else {
+            self.device.new_texture(&self.texture_descriptor)
+        };
+        Atlas::new(size, texture)
     }
 }
 
@@ -136,11 +130,6 @@ impl Atlas {
         }
     }
 
-    fn size(&self) -> Vector2I {
-        let size = self.allocator.size();
-        vec2i(size.width, size.height)
-    }
-
     fn allocate(&mut self, size: Vector2I) -> Option<(etagere::AllocId, Vector2I)> {
         let alloc = self
             .allocator
@@ -177,10 +166,6 @@ impl Atlas {
         self.allocator.deallocate(id);
     }
 
-    fn is_empty(&self) -> bool {
-        self.allocator.is_empty()
-    }
-
     fn clear(&mut self) {
         self.allocator.clear();
     }

crates/language/src/language.rs 🔗

@@ -289,10 +289,13 @@ impl LanguageRegistry {
                 let servers_tx = servers_tx.clone();
                 cx.background()
                     .spawn(async move {
-                        fake_server
-                            .receive_notification::<lsp::notification::Initialized>()
-                            .await;
-                        servers_tx.unbounded_send(fake_server).ok();
+                        if fake_server
+                            .try_receive_notification::<lsp::notification::Initialized>()
+                            .await
+                            .is_some()
+                        {
+                            servers_tx.unbounded_send(fake_server).ok();
+                        }
                     })
                     .detach();
                 Ok(server)

crates/lsp/src/lsp.rs 🔗

@@ -647,12 +647,18 @@ impl FakeLanguageServer {
     }
 
     pub async fn receive_notification<T: notification::Notification>(&mut self) -> T::Params {
+        self.try_receive_notification::<T>().await.unwrap()
+    }
+
+    pub async fn try_receive_notification<T: notification::Notification>(
+        &mut self,
+    ) -> Option<T::Params> {
         use futures::StreamExt as _;
 
         loop {
-            let (method, params) = self.notifications_rx.next().await.unwrap();
+            let (method, params) = self.notifications_rx.next().await?;
             if &method == T::METHOD {
-                return serde_json::from_str::<T::Params>(&params).unwrap();
+                return Some(serde_json::from_str::<T::Params>(&params).unwrap());
             } else {
                 log::info!("skipping message in fake language server {:?}", params);
             }

crates/project/src/fs.rs 🔗

@@ -376,6 +376,16 @@ impl FakeFs {
         .boxed()
     }
 
+    pub async fn files(&self) -> Vec<PathBuf> {
+        self.state
+            .lock()
+            .await
+            .entries
+            .iter()
+            .filter_map(|(path, entry)| entry.content.as_ref().map(|_| path.clone()))
+            .collect()
+    }
+
     async fn simulate_random_delay(&self) {
         self.executor
             .upgrade()

crates/project/src/project.rs 🔗

@@ -443,7 +443,7 @@ impl Project {
             .map(|peer| peer.user_id)
             .collect();
         user_store
-            .update(cx, |user_store, cx| user_store.load_users(user_ids, cx))
+            .update(cx, |user_store, cx| user_store.get_users(user_ids, cx))
             .await?;
         let mut collaborators = HashMap::default();
         for message in response.collaborators {
@@ -6550,7 +6550,7 @@ mod tests {
         assert!(results.is_empty());
     }
 
-    #[gpui::test]
+    #[gpui::test(iterations = 10)]
     async fn test_definition(cx: &mut gpui::TestAppContext) {
         let mut language = Language::new(
             LanguageConfig {

crates/project/src/worktree.rs 🔗

@@ -32,7 +32,6 @@ use postage::{
     prelude::{Sink as _, Stream as _},
     watch,
 };
-use serde::Deserialize;
 use smol::channel::{self, Sender};
 use std::{
     any::Any,
@@ -64,7 +63,6 @@ pub enum Worktree {
 
 pub struct LocalWorktree {
     snapshot: LocalSnapshot,
-    config: WorktreeConfig,
     background_snapshot: Arc<Mutex<LocalSnapshot>>,
     last_scan_state_rx: watch::Receiver<ScanState>,
     _background_scanner_task: Option<Task<()>>,
@@ -143,11 +141,6 @@ struct ShareState {
     _maintain_remote_snapshot: Option<Task<Option<()>>>,
 }
 
-#[derive(Default, Deserialize)]
-struct WorktreeConfig {
-    collaborators: Vec<String>,
-}
-
 pub enum Event {
     UpdatedEntries,
 }
@@ -460,13 +453,6 @@ impl LocalWorktree {
             .await
             .context("failed to stat worktree path")?;
 
-        let mut config = WorktreeConfig::default();
-        if let Ok(zed_toml) = fs.load(&abs_path.join(".zed.toml")).await {
-            if let Ok(parsed) = toml::from_str(&zed_toml) {
-                config = parsed;
-            }
-        }
-
         let (scan_states_tx, mut scan_states_rx) = mpsc::unbounded();
         let (mut last_scan_state_tx, last_scan_state_rx) = watch::channel_with(ScanState::Scanning);
         let tree = cx.add_model(move |cx: &mut ModelContext<Worktree>| {
@@ -496,7 +482,6 @@ impl LocalWorktree {
 
             let tree = Self {
                 snapshot: snapshot.clone(),
-                config,
                 background_snapshot: Arc::new(Mutex::new(snapshot)),
                 last_scan_state_rx,
                 _background_scanner_task: None,
@@ -544,10 +529,6 @@ impl LocalWorktree {
         }
     }
 
-    pub fn authorized_logins(&self) -> Vec<String> {
-        self.config.collaborators.clone()
-    }
-
     pub(crate) fn load_buffer(
         &mut self,
         path: &Path,
@@ -879,7 +860,6 @@ impl LocalWorktree {
             project_id,
             worktree_id: self.id().to_proto(),
             root_name: self.root_name().to_string(),
-            authorized_logins: self.authorized_logins(),
             visible: self.visible,
         };
         let request = client.request(register_message);

crates/project_symbols/src/project_symbols.rs 🔗

@@ -319,15 +319,20 @@ mod tests {
                         .into_iter()
                         .map(|name| StringMatchCandidate::new(0, name.into()))
                         .collect::<Vec<_>>();
-                    let matches = fuzzy::match_strings(
-                        &candidates,
-                        &params.query,
-                        true,
-                        100,
-                        &Default::default(),
-                        executor.clone(),
-                    )
-                    .await;
+                    let matches = if params.query.is_empty() {
+                        Vec::new()
+                    } else {
+                        fuzzy::match_strings(
+                            &candidates,
+                            &params.query,
+                            true,
+                            100,
+                            &Default::default(),
+                            executor.clone(),
+                        )
+                        .await
+                    };
+
                     Ok(Some(
                         matches.into_iter().map(|mat| symbol(&mat.string)).collect(),
                     ))

crates/rpc/proto/zed.proto 🔗

@@ -87,12 +87,16 @@ message Envelope {
         UpdateContacts update_contacts = 75;
 
         GetUsers get_users = 76;
-        GetUsersResponse get_users_response = 77;
-
-        Follow follow = 78;
-        FollowResponse follow_response = 79;
-        UpdateFollowers update_followers = 80;
-        Unfollow unfollow = 81;
+        FuzzySearchUsers fuzzy_search_users = 77;
+        UsersResponse users_response = 78;
+        RequestContact request_contact = 79;
+        RespondToContactRequest respond_to_contact_request = 80;
+        RemoveContact remove_contact = 81;
+
+        Follow follow = 82;
+        FollowResponse follow_response = 83;
+        UpdateFollowers update_followers = 84;
+        Unfollow unfollow = 85;
     }
 }
 
@@ -147,8 +151,7 @@ message RegisterWorktree {
     uint64 project_id = 1;
     uint64 worktree_id = 2;
     string root_name = 3;
-    repeated string authorized_logins = 4;
-    bool visible = 5;
+    bool visible = 4;
 }
 
 message UnregisterWorktree {
@@ -538,10 +541,33 @@ message GetUsers {
     repeated uint64 user_ids = 1;
 }
 
-message GetUsersResponse {
+message FuzzySearchUsers {
+    string query = 1;
+}
+
+message UsersResponse {
     repeated User users = 1;
 }
 
+message RequestContact {
+    uint64 responder_id = 1;
+}
+
+message RemoveContact {
+    uint64 user_id = 1;
+}
+
+message RespondToContactRequest {
+    uint64 requester_id = 1;
+    ContactRequestResponse response = 2;
+}
+
+enum ContactRequestResponse {
+    Accept = 0;
+    Reject = 1;
+    Block = 2;
+}
+
 message SendChannelMessage {
     uint64 channel_id = 1;
     string body = 2;
@@ -569,6 +595,16 @@ message GetChannelMessagesResponse {
 
 message UpdateContacts {
     repeated Contact contacts = 1;
+    repeated uint64 remove_contacts = 2;
+    repeated IncomingContactRequest incoming_requests = 3;
+    repeated uint64 remove_incoming_requests = 4;
+    repeated uint64 outgoing_requests = 5;
+    repeated uint64 remove_outgoing_requests = 6;
+}
+
+message IncomingContactRequest {
+    uint64 requester_id = 1;
+    bool should_notify = 2;
 }
 
 message UpdateDiagnostics {
@@ -839,6 +875,7 @@ message ChannelMessage {
 message Contact {
     uint64 user_id = 1;
     repeated ProjectMetadata projects = 2;
+    bool online = 3;
 }
 
 message ProjectMetadata {

crates/rpc/src/macros.rs 🔗

@@ -0,0 +1,67 @@
+#[macro_export]
+macro_rules! messages {
+    ($(($name:ident, $priority:ident)),* $(,)?) => {
+        pub fn build_typed_envelope(sender_id: ConnectionId, envelope: Envelope) -> Option<Box<dyn AnyTypedEnvelope>> {
+            match envelope.payload {
+                $(Some(envelope::Payload::$name(payload)) => {
+                    Some(Box::new(TypedEnvelope {
+                        sender_id,
+                        original_sender_id: envelope.original_sender_id.map(PeerId),
+                        message_id: envelope.id,
+                        payload,
+                    }))
+                }, )*
+                _ => None
+            }
+        }
+
+        $(
+            impl EnvelopedMessage for $name {
+                const NAME: &'static str = std::stringify!($name);
+                const PRIORITY: MessagePriority = MessagePriority::$priority;
+
+                fn into_envelope(
+                    self,
+                    id: u32,
+                    responding_to: Option<u32>,
+                    original_sender_id: Option<u32>,
+                ) -> Envelope {
+                    Envelope {
+                        id,
+                        responding_to,
+                        original_sender_id,
+                        payload: Some(envelope::Payload::$name(self)),
+                    }
+                }
+
+                fn from_envelope(envelope: Envelope) -> Option<Self> {
+                    if let Some(envelope::Payload::$name(msg)) = envelope.payload {
+                        Some(msg)
+                    } else {
+                        None
+                    }
+                }
+            }
+        )*
+    };
+}
+
+#[macro_export]
+macro_rules! request_messages {
+    ($(($request_name:ident, $response_name:ident)),* $(,)?) => {
+        $(impl RequestMessage for $request_name {
+            type Response = $response_name;
+        })*
+    };
+}
+
+#[macro_export]
+macro_rules! entity_messages {
+    ($id_field:ident, $($name:ident),* $(,)?) => {
+        $(impl EntityMessage for $name {
+            fn remote_entity_id(&self) -> u64 {
+                self.$id_field
+            }
+        })*
+    };
+}

crates/rpc/src/peer.rs 🔗

@@ -173,7 +173,10 @@ impl Peer {
                                     Err(anyhow!("timed out writing message"))?;
                                 }
                             }
-                            None => return Ok(()),
+                            None => {
+                                log::info!("outgoing channel closed");
+                                return Ok(())
+                            },
                         },
                         incoming = read_message => {
                             let incoming = incoming.context("received invalid RPC message")?;
@@ -181,7 +184,10 @@ impl Peer {
                             if let proto::Message::Envelope(incoming) = incoming {
                                 match incoming_tx.send(incoming).timeout(RECEIVE_TIMEOUT).await {
                                     Some(Ok(_)) => {},
-                                    Some(Err(_)) => return Ok(()),
+                                    Some(Err(_)) => {
+                                        log::info!("incoming channel closed");
+                                        return Ok(())
+                                    },
                                     None => Err(anyhow!("timed out processing incoming message"))?,
                                 }
                             }

crates/rpc/src/proto.rs 🔗

@@ -1,4 +1,4 @@
-use super::{ConnectionId, PeerId, TypedEnvelope};
+use super::{entity_messages, messages, request_messages, ConnectionId, PeerId, TypedEnvelope};
 use anyhow::{anyhow, Result};
 use async_tungstenite::tungstenite::Message as WebSocketMessage;
 use futures::{SinkExt as _, StreamExt as _};
@@ -73,71 +73,6 @@ impl<T: EnvelopedMessage> AnyTypedEnvelope for TypedEnvelope<T> {
     }
 }
 
-macro_rules! messages {
-    ($(($name:ident, $priority:ident)),* $(,)?) => {
-        pub fn build_typed_envelope(sender_id: ConnectionId, envelope: Envelope) -> Option<Box<dyn AnyTypedEnvelope>> {
-            match envelope.payload {
-                $(Some(envelope::Payload::$name(payload)) => {
-                    Some(Box::new(TypedEnvelope {
-                        sender_id,
-                        original_sender_id: envelope.original_sender_id.map(PeerId),
-                        message_id: envelope.id,
-                        payload,
-                    }))
-                }, )*
-                _ => None
-            }
-        }
-
-        $(
-            impl EnvelopedMessage for $name {
-                const NAME: &'static str = std::stringify!($name);
-                const PRIORITY: MessagePriority = MessagePriority::$priority;
-
-                fn into_envelope(
-                    self,
-                    id: u32,
-                    responding_to: Option<u32>,
-                    original_sender_id: Option<u32>,
-                ) -> Envelope {
-                    Envelope {
-                        id,
-                        responding_to,
-                        original_sender_id,
-                        payload: Some(envelope::Payload::$name(self)),
-                    }
-                }
-
-                fn from_envelope(envelope: Envelope) -> Option<Self> {
-                    if let Some(envelope::Payload::$name(msg)) = envelope.payload {
-                        Some(msg)
-                    } else {
-                        None
-                    }
-                }
-            }
-        )*
-    };
-}
-
-macro_rules! request_messages {
-    ($(($request_name:ident, $response_name:ident)),* $(,)?) => {
-        $(impl RequestMessage for $request_name {
-            type Response = $response_name;
-        })*
-    };
-}
-
-macro_rules! entity_messages {
-    ($id_field:ident, $($name:ident),* $(,)?) => {
-        $(impl EntityMessage for $name {
-            fn remote_entity_id(&self) -> u64 {
-                self.$id_field
-            }
-        })*
-    };
-}
-
 messages!(
     (Ack, Foreground),
     (AddProjectCollaborator, Foreground),
@@ -147,6 +82,7 @@ messages!(
     (ApplyCompletionAdditionalEditsResponse, Background),
     (BufferReloaded, Foreground),
     (BufferSaved, Foreground),
+    (RemoveContact, Foreground),
     (ChannelMessageSent, Foreground),
     (CreateProjectEntry, Foreground),
     (DeleteProjectEntry, Foreground),
@@ -155,6 +91,7 @@ messages!(
     (FollowResponse, Foreground),
     (FormatBuffers, Foreground),
     (FormatBuffersResponse, Foreground),
+    (FuzzySearchUsers, Foreground),
     (GetChannelMessages, Foreground),
     (GetChannelMessagesResponse, Foreground),
     (GetChannels, Foreground),
@@ -172,7 +109,7 @@ messages!(
     (GetProjectSymbols, Background),
     (GetProjectSymbolsResponse, Background),
     (GetUsers, Foreground),
-    (GetUsersResponse, Foreground),
+    (UsersResponse, Foreground),
     (JoinChannel, Foreground),
     (JoinChannelResponse, Foreground),
     (JoinProject, Foreground),
@@ -197,6 +134,8 @@ messages!(
     (ReloadBuffersResponse, Foreground),
     (RemoveProjectCollaborator, Foreground),
     (RenameProjectEntry, Foreground),
+    (RequestContact, Foreground),
+    (RespondToContactRequest, Foreground),
     (SaveBuffer, Foreground),
     (SearchProject, Background),
     (SearchProjectResponse, Background),
@@ -236,7 +175,8 @@ request_messages!(
     (GetDocumentHighlights, GetDocumentHighlightsResponse),
     (GetReferences, GetReferencesResponse),
     (GetProjectSymbols, GetProjectSymbolsResponse),
-    (GetUsers, GetUsersResponse),
+    (FuzzySearchUsers, UsersResponse),
+    (GetUsers, UsersResponse),
     (JoinChannel, JoinChannelResponse),
     (JoinProject, JoinProjectResponse),
     (OpenBufferById, OpenBufferResponse),
@@ -248,6 +188,9 @@ request_messages!(
     (RegisterProject, RegisterProjectResponse),
     (RegisterWorktree, Ack),
     (ReloadBuffers, ReloadBuffersResponse),
+    (RequestContact, Ack),
+    (RemoveContact, Ack),
+    (RespondToContactRequest, Ack),
     (RenameProjectEntry, ProjectEntryResponse),
     (SaveBuffer, BufferSaved),
     (SearchProject, SearchProjectResponse),

crates/rpc/src/rpc.rs 🔗

@@ -4,5 +4,6 @@ mod peer;
 pub mod proto;
 pub use conn::Connection;
 pub use peer::*;
+mod macros;
 
 pub const PROTOCOL_VERSION: u32 = 16;

crates/theme/src/theme.rs 🔗

@@ -21,6 +21,7 @@ pub struct Theme {
     pub workspace: Workspace,
     pub chat_panel: ChatPanel,
     pub contacts_panel: ContactsPanel,
+    pub contact_finder: ContactFinder,
     pub project_panel: ProjectPanel,
     pub command_palette: CommandPalette,
     pub picker: Picker,
@@ -234,19 +235,44 @@ pub struct CommandPalette {
 pub struct ContactsPanel {
     #[serde(flatten)]
     pub container: ContainerStyle,
-    pub host_row_height: f32,
-    pub host_avatar: ImageStyle,
-    pub host_username: ContainedText,
+    pub header: ContainedText,
+    pub user_query_editor: FieldEditor,
+    pub user_query_editor_height: f32,
+    pub add_contact_button: IconButton,
+    pub row: ContainerStyle,
+    pub row_height: f32,
+    pub contact_avatar: ImageStyle,
+    pub contact_username: ContainedText,
+    pub contact_button: Interactive<IconButton>,
+    pub disabled_contact_button: IconButton,
     pub tree_branch_width: f32,
     pub tree_branch_color: Color,
-    pub shared_project: WorktreeRow,
-    pub hovered_shared_project: WorktreeRow,
-    pub unshared_project: WorktreeRow,
-    pub hovered_unshared_project: WorktreeRow,
+    pub shared_project: ProjectRow,
+    pub hovered_shared_project: ProjectRow,
+    pub unshared_project: ProjectRow,
+    pub hovered_unshared_project: ProjectRow,
 }
 
 #[derive(Deserialize, Default)]
-pub struct WorktreeRow {
+pub struct ContactFinder {
+    pub row_height: f32,
+    pub contact_avatar: ImageStyle,
+    pub contact_username: ContainerStyle,
+    pub contact_button: IconButton,
+    pub disabled_contact_button: IconButton,
+}
+
+#[derive(Deserialize, Default)]
+pub struct IconButton {
+    #[serde(flatten)]
+    pub container: ContainerStyle,
+    pub color: Color,
+    pub icon_width: f32,
+    pub button_width: f32,
+}
+
+#[derive(Deserialize, Default)]
+pub struct ProjectRow {
     #[serde(flatten)]
     pub container: ContainerStyle,
     pub height: f32,

crates/workspace/src/sidebar.rs 🔗

@@ -106,10 +106,12 @@ impl Sidebar {
         .with_cursor_style(CursorStyle::ResizeLeftRight)
         .on_drag(move |delta, cx| {
             let prev_width = *actual_width.borrow();
-            match side {
-                Side::Left => *custom_width.borrow_mut() = 0f32.max(prev_width + delta.x()),
-                Side::Right => *custom_width.borrow_mut() = 0f32.max(prev_width - delta.x()),
-            }
+            *custom_width.borrow_mut() = 0f32
+                .max(match side {
+                    Side::Left => prev_width + delta.x(),
+                    Side::Right => prev_width - delta.x(),
+                })
+                .round();
 
             cx.notify();
         })

crates/workspace/src/workspace.rs 🔗

@@ -824,6 +824,10 @@ impl Workspace {
         &self.status_bar
     }
 
+    pub fn user_store(&self) -> &ModelHandle<UserStore> {
+        &self.user_store
+    }
+
     pub fn project(&self) -> &ModelHandle<Project> {
         &self.project
     }
@@ -931,7 +935,7 @@ impl Workspace {
         })
     }
 
-    // Returns the model that was toggled closed if it was open
+    /// Returns the modal that was toggled closed if it was open.
     pub fn toggle_modal<V, F>(
         &mut self,
         cx: &mut ViewContext<Self>,

crates/zed/src/main.rs 🔗

@@ -146,6 +146,7 @@ fn main() {
         go_to_line::init(cx);
         file_finder::init(cx);
         chat_panel::init(cx);
+        contacts_panel::init(cx);
         outline::init(cx);
         project_symbols::init(cx);
         project_panel::init(cx);

script/seed-db 🔗

@@ -6,4 +6,4 @@ cd crates/collab
 # Export contents of .env.toml
 eval "$(cargo run --bin dotenv)"
 
-cargo run --package=collab --features seed-support --bin seed
+cargo run --package=collab --features seed-support --bin seed -- $@

styles/src/styleTree/app.ts 🔗

@@ -1,6 +1,7 @@
 import Theme from "../themes/theme";
 import chatPanel from "./chatPanel";
 import { text } from "./components";
+import contactFinder from "./contactFinder";
 import contactsPanel from "./contactsPanel";
 import commandPalette from "./commandPalette";
 import editor from "./editor";
@@ -24,6 +25,7 @@ export default function app(theme: Theme): Object {
     projectPanel: projectPanel(theme),
     chatPanel: chatPanel(theme),
     contactsPanel: contactsPanel(theme),
+    contactFinder: contactFinder(theme),
     search: search(theme),
     breadcrumbs: {
       ...text(theme, "sans", "secondary"),

styles/src/styleTree/contactFinder.ts 🔗

@@ -0,0 +1,38 @@
+import Theme from "../themes/theme";
+import picker from "./picker";
+import { backgroundColor, iconColor } from "./components";
+
+export default function contactFinder(theme: Theme) {
+  const contactButton = {
+    background: backgroundColor(theme, 100),
+    color: iconColor(theme, "primary"),
+    iconWidth: 8,
+    buttonWidth: 16,
+    cornerRadius: 8,
+  };
+
+  return {
+    ...picker(theme),
+    rowHeight: 28,
+    contactAvatar: {
+      cornerRadius: 10,
+      width: 18,
+    },
+    contactUsername: {
+      padding: {
+        left: 8,
+      },
+    },
+    contactButton: {
+      ...contactButton,
+      hover: {
+        background: backgroundColor(theme, 100, "hovered")
+      }
+    },
+    disabledContactButton: {
+      ...contactButton,
+      background: backgroundColor(theme, 100),
+      color: iconColor(theme, "muted"),
+    },
+  }
+}

styles/src/styleTree/contactsPanel.ts 🔗

@@ -1,8 +1,8 @@
 import Theme from "../themes/theme";
 import { panel } from "./app";
-import { backgroundColor, borderColor, text } from "./components";
+import { backgroundColor, border, borderColor, iconColor, player, text } from "./components";
 
-export default function(theme: Theme) {
+export default function contactsPanel(theme: Theme) {
   const project = {
     guestAvatarSpacing: 4,
     height: 24,
@@ -31,21 +31,68 @@ export default function(theme: Theme) {
     },
   };
 
+  const contactButton = {
+    background: backgroundColor(theme, 100),
+    color: iconColor(theme, "primary"),
+    iconWidth: 8,
+    buttonWidth: 16,
+    cornerRadius: 8,
+  };
+
   return {
     ...panel,
-    hostRowHeight: 28,
+    userQueryEditor: {
+      background: backgroundColor(theme, 500),
+      cornerRadius: 6,
+      text: text(theme, "mono", "primary"),
+      placeholderText: text(theme, "mono", "placeholder", { size: "sm" }),
+      selection: player(theme, 1).selection,
+      border: border(theme, "secondary"),
+      padding: {
+        bottom: 4,
+        left: 8,
+        right: 8,
+        top: 4,
+      },
+    },
+    userQueryEditorHeight: 32,
+    addContactButton: {
+      margin: { left: 6 },
+      color: iconColor(theme, "primary"),
+      buttonWidth: 8,
+      iconWidth: 8,
+    },
+    row: {
+      padding: { left: 8 },
+    },
+    rowHeight: 28,
+    header: {
+      ...text(theme, "mono", "secondary", { size: "sm" }),
+      margin: { top: 8 },
+    },
     treeBranchColor: borderColor(theme, "muted"),
     treeBranchWidth: 1,
-    hostAvatar: {
+    contactAvatar: {
       cornerRadius: 10,
       width: 18,
     },
-    hostUsername: {
+    contactUsername: {
       ...text(theme, "mono", "primary", { size: "sm" }),
       padding: {
         left: 8,
       },
     },
+    contactButton: {
+      ...contactButton,
+      hover: {
+        background: backgroundColor(theme, 100, "hovered"),
+      },
+    },
+    disabledContactButton: {
+      ...contactButton,
+      background: backgroundColor(theme, 100),
+      color: iconColor(theme, "muted"),
+    },
     project,
     sharedProject,
     hoveredSharedProject: {