Introduce HttpClient trait, use it to fetch avatars in UserStore

Max Brunsfeld created

* Add a FakeHttpClient for tests

Change summary

gpui/src/image_data.rs | 18 +++++++-
server/src/rpc.rs      |  2 
zed/src/channel.rs     |  8 ++-
zed/src/http.rs        | 26 ++++++++++++
zed/src/lib.rs         |  1 
zed/src/main.rs        |  5 +
zed/src/test.rs        | 36 ++++++++++++++++
zed/src/user.rs        | 94 +++++++++++++++++++++++--------------------
zed/src/workspace.rs   | 10 +++
9 files changed, 146 insertions(+), 54 deletions(-)

Detailed changes

gpui/src/image_data.rs 🔗

@@ -1,8 +1,11 @@
 use crate::geometry::vector::{vec2i, Vector2I};
 use image::{Bgra, ImageBuffer};
-use std::sync::{
-    atomic::{AtomicUsize, Ordering::SeqCst},
-    Arc,
+use std::{
+    fmt,
+    sync::{
+        atomic::{AtomicUsize, Ordering::SeqCst},
+        Arc,
+    },
 };
 
 pub struct ImageData {
@@ -29,3 +32,12 @@ impl ImageData {
         vec2i(width as i32, height as i32)
     }
 }
+
+impl fmt::Debug for ImageData {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("ImageData")
+            .field("id", &self.id)
+            .field("size", &self.data.dimensions())
+            .finish()
+    }
+}

server/src/rpc.rs 🔗

@@ -1025,6 +1025,7 @@ mod tests {
         language::LanguageRegistry,
         rpc::{self, Client},
         settings,
+        test::FakeHttpClient,
         user::UserStore,
         worktree::Worktree,
     };
@@ -1486,6 +1487,7 @@ mod tests {
 
         // Connect to a server as 2 clients.
         let mut server = TestServer::start().await;
+        let mut http = FakeHttpClient::new(|_| async move { Ok(Response::new(404)) });
         let (user_id_a, client_a) = server.create_client(&mut cx_a, "user_a").await;
         let (user_id_b, client_b) = server.create_client(&mut cx_b, "user_b").await;
 

zed/src/channel.rs 🔗

@@ -46,7 +46,7 @@ pub struct Channel {
     _subscription: rpc::Subscription,
 }
 
-#[derive(Clone, Debug, PartialEq)]
+#[derive(Clone, Debug)]
 pub struct ChannelMessage {
     pub id: u64,
     pub body: String,
@@ -495,15 +495,17 @@ impl<'a> sum_tree::SeekDimension<'a, ChannelMessageSummary> for Count {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::test::FakeServer;
+    use crate::test::{FakeHttpClient, FakeServer};
     use gpui::TestAppContext;
+    use surf::http::Response;
 
     #[gpui::test]
     async fn test_channel_messages(mut cx: TestAppContext) {
         let user_id = 5;
         let mut client = Client::new();
+        let http_client = FakeHttpClient::new(|_| async move { Ok(Response::new(404)) });
         let server = FakeServer::for_client(user_id, &mut client, &cx).await;
-        let user_store = UserStore::new(client.clone(), cx.background().as_ref());
+        let user_store = UserStore::new(client.clone(), http_client, cx.background().as_ref());
 
         let channel_list = cx.add_model(|cx| ChannelList::new(user_store, client.clone(), cx));
         channel_list.read_with(&cx, |list, _| assert_eq!(list.available_channels(), None));

zed/src/http.rs 🔗

@@ -0,0 +1,26 @@
+pub use anyhow::{anyhow, Result};
+use futures::future::BoxFuture;
+use std::sync::Arc;
+pub use surf::{
+    http::{Method, Request, Response as ServerResponse},
+    Response, Url,
+};
+
+pub trait HttpClient: Send + Sync {
+    fn send<'a>(&'a self, req: Request) -> BoxFuture<'a, Result<Response>>;
+}
+
+pub fn client() -> Arc<dyn HttpClient> {
+    Arc::new(surf::client())
+}
+
+impl HttpClient for surf::Client {
+    fn send<'a>(&'a self, req: Request) -> BoxFuture<'a, Result<Response>> {
+        Box::pin(async move {
+            Ok(self
+                .send(req)
+                .await
+                .map_err(|e| anyhow!("http request failed: {}", e))?)
+        })
+    }
+}

zed/src/lib.rs 🔗

@@ -5,6 +5,7 @@ pub mod editor;
 pub mod file_finder;
 pub mod fs;
 mod fuzzy;
+pub mod http;
 pub mod language;
 pub mod menus;
 pub mod project_browser;

zed/src/main.rs 🔗

@@ -13,7 +13,7 @@ use zed::{
     channel::ChannelList,
     chat_panel, editor, file_finder,
     fs::RealFs,
-    language, menus, rpc, settings, theme_selector,
+    http, language, menus, rpc, settings, theme_selector,
     user::UserStore,
     workspace::{self, OpenNew, OpenParams, OpenPaths},
     AppState,
@@ -37,7 +37,8 @@ fn main() {
 
     app.run(move |cx| {
         let rpc = rpc::Client::new();
-        let user_store = UserStore::new(rpc.clone(), cx.background());
+        let http = http::client();
+        let user_store = UserStore::new(rpc.clone(), http.clone(), cx.background());
         let app_state = Arc::new(AppState {
             languages: languages.clone(),
             settings_tx: Arc::new(Mutex::new(settings_tx)),

zed/src/test.rs 🔗

@@ -2,6 +2,7 @@ use crate::{
     assets::Assets,
     channel::ChannelList,
     fs::RealFs,
+    http::{HttpClient, Request, Response, ServerResponse},
     language::LanguageRegistry,
     rpc::{self, Client},
     settings::{self, ThemeRegistry},
@@ -10,11 +11,13 @@ use crate::{
     AppState,
 };
 use anyhow::{anyhow, Result};
+use futures::{future::BoxFuture, Future};
 use gpui::{AsyncAppContext, Entity, ModelHandle, MutableAppContext, TestAppContext};
 use parking_lot::Mutex;
 use postage::{mpsc, prelude::Stream as _};
 use smol::channel;
 use std::{
+    fmt,
     marker::PhantomData,
     path::{Path, PathBuf},
     sync::{
@@ -164,7 +167,8 @@ pub fn test_app_state(cx: &mut MutableAppContext) -> Arc<AppState> {
     let languages = Arc::new(LanguageRegistry::new());
     let themes = ThemeRegistry::new(Assets, cx.font_cache().clone());
     let rpc = rpc::Client::new();
-    let user_store = UserStore::new(rpc.clone(), cx.background());
+    let http = FakeHttpClient::new(|_| async move { Ok(ServerResponse::new(404)) });
+    let user_store = UserStore::new(rpc.clone(), http, cx.background());
     Arc::new(AppState {
         settings_tx: Arc::new(Mutex::new(settings_tx)),
         settings,
@@ -313,3 +317,33 @@ impl FakeServer {
         self.connection_id.lock().expect("not connected")
     }
 }
+
+pub struct FakeHttpClient {
+    handler:
+        Box<dyn 'static + Send + Sync + Fn(Request) -> BoxFuture<'static, Result<ServerResponse>>>,
+}
+
+impl FakeHttpClient {
+    pub fn new<Fut, F>(handler: F) -> Arc<dyn HttpClient>
+    where
+        Fut: 'static + Send + Future<Output = Result<ServerResponse>>,
+        F: 'static + Send + Sync + Fn(Request) -> Fut,
+    {
+        Arc::new(Self {
+            handler: Box::new(move |req| Box::pin(handler(req))),
+        })
+    }
+}
+
+impl fmt::Debug for FakeHttpClient {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("FakeHttpClient").finish()
+    }
+}
+
+impl HttpClient for FakeHttpClient {
+    fn send<'a>(&'a self, req: Request) -> BoxFuture<'a, Result<Response>> {
+        let future = (self.handler)(req);
+        Box::pin(async move { future.await.map(Into::into) })
+    }
+}

zed/src/user.rs 🔗

@@ -1,22 +1,24 @@
 use crate::{
+    http::{HttpClient, Method, Request, Url},
     rpc::{Client, Status},
     util::TryFutureExt,
 };
-use anyhow::{anyhow, Result};
-use gpui::{elements::Image, executor, ImageData, Task};
+use anyhow::{anyhow, Context, Result};
+use futures::future;
+use gpui::{executor, ImageData, Task};
 use parking_lot::Mutex;
-use postage::{prelude::Stream, sink::Sink, watch};
-use std::{collections::HashMap, sync::Arc};
-use surf::{
-    http::{Method, Request},
-    HttpClient, Url,
+use postage::{oneshot, prelude::Stream, sink::Sink, watch};
+use std::{
+    collections::HashMap,
+    sync::{Arc, Weak},
 };
 use zrpc::proto;
 
+#[derive(Debug)]
 pub struct User {
-    id: u64,
-    github_login: String,
-    avatar: Option<ImageData>,
+    pub id: u64,
+    pub github_login: String,
+    pub avatar: Option<Arc<ImageData>>,
 }
 
 pub struct UserStore {
@@ -24,7 +26,7 @@ pub struct UserStore {
     current_user: watch::Receiver<Option<Arc<User>>>,
     rpc: Arc<Client>,
     http: Arc<dyn HttpClient>,
-    _maintain_current_user: Option<Task<()>>,
+    _maintain_current_user: Task<()>,
 }
 
 impl UserStore {
@@ -34,18 +36,18 @@ impl UserStore {
         executor: &executor::Background,
     ) -> Arc<Self> {
         let (mut current_user_tx, current_user_rx) = watch::channel();
-
-        let mut this = Arc::new(Self {
+        let (mut this_tx, mut this_rx) = oneshot::channel::<Weak<Self>>();
+        let this = Arc::new(Self {
             users: Default::default(),
             current_user: current_user_rx,
             rpc: rpc.clone(),
             http,
-            _maintain_current_user: None,
-        });
-
-        let task = {
-            let this = Arc::downgrade(&this);
-            executor.spawn(async move {
+            _maintain_current_user: executor.spawn(async move {
+                let this = if let Some(this) = this_rx.recv().await {
+                    this
+                } else {
+                    return;
+                };
                 let mut status = rpc.status();
                 while let Some(status) = status.recv().await {
                     match status {
@@ -63,10 +65,12 @@ impl UserStore {
                         _ => {}
                     }
                 }
-            })
-        };
-        Arc::get_mut(&mut this).unwrap()._maintain_current_user = Some(task);
-
+            }),
+        });
+        let weak = Arc::downgrade(&this);
+        executor
+            .spawn(async move { this_tx.send(weak).await })
+            .detach();
         this
     }
 
@@ -78,8 +82,15 @@ impl UserStore {
 
         if !user_ids.is_empty() {
             let response = self.rpc.request(proto::GetUsers { user_ids }).await?;
+            let new_users = future::join_all(
+                response
+                    .users
+                    .into_iter()
+                    .map(|user| User::new(user, self.http.as_ref())),
+            )
+            .await;
             let mut users = self.users.lock();
-            for user in response.users {
+            for user in new_users {
                 users.insert(user.id, Arc::new(user));
             }
         }
@@ -92,20 +103,12 @@ impl UserStore {
             return Ok(user);
         }
 
-        let response = self
-            .rpc
-            .request(proto::GetUsers {
-                user_ids: vec![user_id],
-            })
-            .await?;
-
-        if let Some(user) = response.users.into_iter().next() {
-            let user = Arc::new(user);
-            self.users.lock().insert(user_id, user.clone());
-            Ok(user)
-        } else {
-            Err(anyhow!("server responded with no users"))
-        }
+        self.load_users(vec![user_id]).await?;
+        self.users
+            .lock()
+            .get(&user_id)
+            .cloned()
+            .ok_or_else(|| anyhow!("server responded with no users"))
     }
 
     pub fn current_user(&self) -> &watch::Receiver<Option<Arc<User>>> {
@@ -115,20 +118,25 @@ impl UserStore {
 
 impl User {
     async fn new(message: proto::User, http: &dyn HttpClient) -> Self {
-        let avatar = fetch_avatar(http, &message.avatar_url).await.log_err();
         User {
             id: message.id,
             github_login: message.github_login,
-            avatar,
+            avatar: fetch_avatar(http, &message.avatar_url).log_err().await,
         }
     }
 }
 
 async fn fetch_avatar(http: &dyn HttpClient, url: &str) -> Result<Arc<ImageData>> {
-    let url = Url::parse(url)?;
+    let url = Url::parse(url).with_context(|| format!("failed to parse avatar url {:?}", url))?;
     let request = Request::new(Method::Get, url);
-    let response = http.send(request).await?;
-    let bytes = response.body_bytes().await?;
+    let mut response = http
+        .send(request)
+        .await
+        .map_err(|e| anyhow!("failed to send user avatar request: {}", e))?;
+    let bytes = response
+        .body_bytes()
+        .await
+        .map_err(|e| anyhow!("failed to read user avatar response body: {}", e))?;
     let format = image::guess_format(&bytes)?;
     let image = image::load_from_memory_with_format(&bytes, format)?.into_bgra8();
     Ok(ImageData::new(image))

zed/src/workspace.rs 🔗

@@ -956,8 +956,14 @@ impl Workspace {
 
     fn render_current_user(&self, cx: &mut RenderContext<Self>) -> ElementBox {
         let theme = &self.settings.borrow().theme;
-        let avatar = if let Some(current_user) = self.user_store.current_user().borrow().as_ref() {
-            todo!()
+        let avatar = if let Some(avatar) = self
+            .user_store
+            .current_user()
+            .borrow()
+            .as_ref()
+            .and_then(|user| user.avatar.clone())
+        {
+            Image::new(avatar).boxed()
         } else {
             Svg::new("icons/signed-out-12.svg")
                 .with_color(theme.workspace.titlebar.icon_signed_out)