assistant2: Show file icons for context entries (#22928)

Agus Zubiaga , Nathan , and Michael created

https://github.com/user-attachments/assets/d3d6f5f1-23ec-449b-a762-9869b9d4b5a5


Release Notes:

- N/A

---------

Co-authored-by: Nathan <nathan@zed.dev>
Co-authored-by: Michael <michael@zed.dev>

Change summary

Cargo.lock                                                  |  1 
crates/assistant2/Cargo.toml                                |  7 
crates/assistant2/src/context.rs                            | 19 +++
crates/assistant2/src/context_picker/file_context_picker.rs |  6 +
crates/assistant2/src/context_store.rs                      |  1 
crates/assistant2/src/context_strip.rs                      | 19 +++
crates/assistant2/src/ui/context_pill.rs                    | 42 ++++--
7 files changed, 78 insertions(+), 17 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -469,6 +469,7 @@ dependencies = [
  "db",
  "editor",
  "feature_flags",
+ "file_icons",
  "fs",
  "futures 0.3.31",
  "fuzzy",

crates/assistant2/Cargo.toml 🔗

@@ -18,15 +18,16 @@ anyhow.workspace = true
 assets.workspace = true
 assistant_tool.workspace = true
 async-watch.workspace = true
+chrono.workspace = true
 client.workspace = true
 clock.workspace = true
-chrono.workspace = true
 collections.workspace = true
 command_palette_hooks.workspace = true
 context_server.workspace = true
 db.workspace = true
 editor.workspace = true
 feature_flags.workspace = true
+file_icons.workspace = true
 fs.workspace = true
 futures.workspace = true
 fuzzy.workspace = true
@@ -47,8 +48,8 @@ multi_buffer.workspace = true
 ollama = { workspace = true, features = ["schemars"] }
 open_ai = { workspace = true, features = ["schemars"] }
 ordered-float.workspace = true
-paths.workspace = true
 parking_lot.workspace = true
+paths.workspace = true
 picker.workspace = true
 project.workspace = true
 proto.workspace = true
@@ -61,9 +62,9 @@ settings.workspace = true
 similar.workspace = true
 smol.workspace = true
 telemetry_events.workspace = true
+terminal.workspace = true
 terminal_view.workspace = true
 text.workspace = true
-terminal.workspace = true
 theme.workspace = true
 time.workspace = true
 time_format.workspace = true

crates/assistant2/src/context.rs 🔗

@@ -2,11 +2,13 @@ use std::path::Path;
 use std::rc::Rc;
 use std::sync::Arc;
 
+use file_icons::FileIcons;
 use gpui::{AppContext, Model, SharedString};
 use language::Buffer;
 use language_model::{LanguageModelRequestMessage, MessageContent};
 use serde::{Deserialize, Serialize};
 use text::BufferId;
+use ui::IconName;
 use util::post_inc;
 
 use crate::thread::Thread;
@@ -27,6 +29,7 @@ pub struct ContextSnapshot {
     pub name: SharedString,
     pub parent: Option<SharedString>,
     pub tooltip: Option<SharedString>,
+    pub icon_path: Option<SharedString>,
     pub kind: ContextKind,
     /// Concatenating these strings yields text to send to the model. Not refreshed by `snapshot`.
     pub text: Box<[SharedString]>,
@@ -40,6 +43,17 @@ pub enum ContextKind {
     Thread,
 }
 
+impl ContextKind {
+    pub fn icon(&self) -> IconName {
+        match self {
+            ContextKind::File => IconName::File,
+            ContextKind::Directory => IconName::Folder,
+            ContextKind::FetchedUrl => IconName::Globe,
+            ContextKind::Thread => IconName::MessageCircle,
+        }
+    }
+}
+
 #[derive(Debug)]
 pub enum Context {
     File(FileContext),
@@ -138,11 +152,14 @@ impl FileContext {
             .and_then(|p| p.file_name())
             .map(|p| p.to_string_lossy().into_owned().into());
 
+        let icon_path = FileIcons::get_icon(&path, cx);
+
         Some(ContextSnapshot {
             id: self.id,
             name,
             parent,
             tooltip: Some(full_path),
+            icon_path,
             kind: ContextKind::File,
             text: Box::new([self.buffer.text.clone()]),
         })
@@ -162,6 +179,7 @@ impl FetchedUrlContext {
             name: self.url.clone(),
             parent: None,
             tooltip: None,
+            icon_path: None,
             kind: ContextKind::FetchedUrl,
             text: Box::new([self.text.clone()]),
         }
@@ -176,6 +194,7 @@ impl ThreadContext {
             name: thread.summary().unwrap_or("New thread".into()),
             parent: None,
             tooltip: None,
+            icon_path: None,
             kind: ContextKind::Thread,
             text: Box::new([self.text.clone()]),
         }

crates/assistant2/src/context_picker/file_context_picker.rs 🔗

@@ -2,6 +2,7 @@ use std::path::Path;
 use std::sync::atomic::AtomicBool;
 use std::sync::Arc;
 
+use file_icons::FileIcons;
 use fuzzy::PathMatch;
 use gpui::{AppContext, DismissEvent, FocusHandle, FocusableView, Task, View, WeakModel, WeakView};
 use picker::{Picker, PickerDelegate};
@@ -281,6 +282,10 @@ impl PickerDelegate for FileContextPickerDelegate {
                 .will_include_file_path(&path_match.path, cx)
         });
 
+        let file_icon = FileIcons::get_icon(&path_match.path.clone(), cx)
+            .map(Icon::from_path)
+            .unwrap_or_else(|| Icon::new(IconName::File));
+
         Some(
             ListItem::new(ix)
                 .inset(true)
@@ -288,6 +293,7 @@ impl PickerDelegate for FileContextPickerDelegate {
                 .child(
                     h_flex()
                         .gap_2()
+                        .child(file_icon.size(IconSize::Small))
                         .child(Label::new(file_name))
                         .children(directory.map(|directory| {
                             Label::new(directory)

crates/assistant2/src/context_strip.rs 🔗

@@ -3,6 +3,7 @@ use std::rc::Rc;
 use anyhow::Result;
 use collections::HashSet;
 use editor::Editor;
+use file_icons::FileIcons;
 use gpui::{
     DismissEvent, EventEmitter, FocusHandle, Model, ModelContext, Subscription, Task, View,
     WeakModel, WeakView,
@@ -95,9 +96,12 @@ impl ContextStrip {
             None => path.to_string_lossy().into_owned().into(),
         };
 
+        let icon_path = FileIcons::get_icon(path, cx);
+
         Some(SuggestedContext::File {
             name,
             buffer: active_buffer_model.downgrade(),
+            icon_path,
         })
     }
 
@@ -228,6 +232,7 @@ impl Render for ContextStrip {
             .when_some(suggested_context, |el, suggested| {
                 el.child(ContextPill::new_suggested(
                     suggested.name().clone(),
+                    suggested.icon_path(),
                     suggested.kind(),
                     {
                         let context_store = self.context_store.clone();
@@ -304,6 +309,7 @@ pub enum SuggestContextKind {
 pub enum SuggestedContext {
     File {
         name: SharedString,
+        icon_path: Option<SharedString>,
         buffer: WeakModel<Buffer>,
     },
     Thread {
@@ -320,13 +326,24 @@ impl SuggestedContext {
         }
     }
 
+    pub fn icon_path(&self) -> Option<SharedString> {
+        match self {
+            Self::File { icon_path, .. } => icon_path.clone(),
+            Self::Thread { .. } => None,
+        }
+    }
+
     pub fn accept(
         &self,
         context_store: &mut ContextStore,
         cx: &mut ModelContext<ContextStore>,
     ) -> Task<Result<()>> {
         match self {
-            Self::File { buffer, name: _ } => {
+            Self::File {
+                buffer,
+                icon_path: _,
+                name: _,
+            } => {
                 if let Some(buffer) = buffer.upgrade() {
                     return context_store.add_file_from_buffer(buffer, cx);
                 };

crates/assistant2/src/ui/context_pill.rs 🔗

@@ -14,6 +14,7 @@ pub enum ContextPill {
     },
     Suggested {
         name: SharedString,
+        icon_path: Option<SharedString>,
         kind: ContextKind,
         on_add: Rc<dyn Fn(&ClickEvent, &mut WindowContext)>,
     },
@@ -34,10 +35,16 @@ impl ContextPill {
 
     pub fn new_suggested(
         name: SharedString,
+        icon_path: Option<SharedString>,
         kind: ContextKind,
         on_add: Rc<dyn Fn(&ClickEvent, &mut WindowContext)>,
     ) -> Self {
-        Self::Suggested { name, kind, on_add }
+        Self::Suggested {
+            name,
+            icon_path,
+            kind,
+            on_add,
+        }
     }
 
     pub fn id(&self) -> ElementId {
@@ -49,23 +56,27 @@ impl ContextPill {
         }
     }
 
-    pub fn kind(&self) -> ContextKind {
+    pub fn icon(&self) -> Icon {
         match self {
-            Self::Added { context, .. } => context.kind,
-            Self::Suggested { kind, .. } => *kind,
+            Self::Added { context, .. } => match &context.icon_path {
+                Some(icon_path) => Icon::from_path(icon_path),
+                None => Icon::new(context.kind.icon()),
+            },
+            Self::Suggested {
+                icon_path: Some(icon_path),
+                ..
+            } => Icon::from_path(icon_path),
+            Self::Suggested {
+                kind,
+                icon_path: None,
+                ..
+            } => Icon::new(kind.icon()),
         }
     }
 }
 
 impl RenderOnce for ContextPill {
     fn render(self, cx: &mut WindowContext) -> impl IntoElement {
-        let icon = match &self.kind() {
-            ContextKind::File => IconName::File,
-            ContextKind::Directory => IconName::Folder,
-            ContextKind::FetchedUrl => IconName::Globe,
-            ContextKind::Thread => IconName::MessageCircle,
-        };
-
         let color = cx.theme().colors();
 
         let base_pill = h_flex()
@@ -75,7 +86,7 @@ impl RenderOnce for ContextPill {
             .border_1()
             .rounded_md()
             .gap_1()
-            .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted));
+            .child(self.icon().size(IconSize::XSmall).color(Color::Muted));
 
         match &self {
             ContextPill::Added {
@@ -118,7 +129,12 @@ impl RenderOnce for ContextPill {
                             }),
                     )
                 }),
-            ContextPill::Suggested { name, kind, on_add } => base_pill
+            ContextPill::Suggested {
+                name,
+                icon_path: _,
+                kind,
+                on_add,
+            } => base_pill
                 .cursor_pointer()
                 .pr_1()
                 .border_color(color.border_variant.opacity(0.5))