File context for assistant panel (#9712)

Kyle Kelley , Conrad Irwin , Nathan , Antonio Scandurra , and Mikayla Maki created

Introducing the Active File Context portion of #9705. When someone is in
the assistant panel it now includes the active file as a system message
on send while showing them a nice little display in the lower right:


![image](https://github.com/zed-industries/zed/assets/836375/9abc56e0-e8f2-45ee-9e7e-b83b28b483ea)

For this iteration, I'd love to see the following before we land this:

* [x] Toggle-able context - user should be able to disable sending this
context
* [x] Show nothing if there is no context coming in
* [x] Update token count as we change items
* [x] Listen for a more finely scoped event for when the active item
changes
* [x] Create a global for pulling a file icon based on a path. Zed's
main way to do this is nested within project panel's `FileAssociation`s.
* [x] Get the code fence name for a Language for the system prompt
* [x] Update the token count when the buffer content changes

I'm seeing this PR as the foundation for providing other kinds of
context -- diagnostic summaries, failing tests, additional files, etc.

Release Notes:

- Added file context to assistant chat panel
([#9705](https://github.com/zed-industries/zed/issues/9705)).

<img width="1558" alt="image"
src="https://github.com/zed-industries/zed/assets/836375/86eb7e50-3e28-4754-9c3f-895be588616d">

---------

Co-authored-by: Conrad Irwin <conrad@zed.dev>
Co-authored-by: Nathan <nathan@zed.dev>
Co-authored-by: Antonio Scandurra <me@as-cii.com>
Co-authored-by: Mikayla Maki <mikayla@zed.dev>

Change summary

Cargo.lock                                       |  15 +
Cargo.toml                                       |   2 
crates/assistant/Cargo.toml                      |   1 
crates/assistant/src/assistant.rs                |   2 
crates/assistant/src/assistant_panel.rs          | 243 +++++++++++++++--
crates/assistant/src/embedded_scope.rs           |  91 ++++++
crates/file_icons/Cargo.toml                     |  21 +
crates/file_icons/src/file_icons.rs              |  12 
crates/language/src/language.rs                  |  10 
crates/languages/src/bash/config.toml            |   1 
crates/languages/src/gomod/config.toml           |   1 
crates/languages/src/gowork/config.toml          |   1 
crates/languages/src/ocaml-interface/config.toml |   1 
crates/languages/src/vue/config.toml             |   1 
crates/project_panel/Cargo.toml                  |   1 
crates/project_panel/src/project_panel.rs        |  13 
crates/workspace/src/workspace.rs                |   6 
crates/zed/Cargo.toml                            |   1 
crates/zed/src/main.rs                           |   2 
extensions/csharp/languages/csharp/config.toml   |   1 
20 files changed, 377 insertions(+), 49 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -328,6 +328,7 @@ dependencies = [
  "ctor",
  "editor",
  "env_logger",
+ "file_icons",
  "fs",
  "futures 0.3.28",
  "gpui",
@@ -3751,6 +3752,18 @@ dependencies = [
  "workspace",
 ]
 
+[[package]]
+name = "file_icons"
+version = "0.1.0"
+dependencies = [
+ "collections",
+ "gpui",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "util",
+]
+
 [[package]]
 name = "filetime"
 version = "0.2.22"
@@ -7235,6 +7248,7 @@ dependencies = [
  "collections",
  "db",
  "editor",
+ "file_icons",
  "gpui",
  "language",
  "menu",
@@ -12591,6 +12605,7 @@ dependencies = [
  "extensions_ui",
  "feedback",
  "file_finder",
+ "file_icons",
  "fs",
  "futures 0.3.28",
  "go_to_line",

Cargo.toml 🔗

@@ -28,6 +28,7 @@ members = [
     "crates/feature_flags",
     "crates/feedback",
     "crates/file_finder",
+    "crates/file_icons",
     "crates/fs",
     "crates/fsevent",
     "crates/fuzzy",
@@ -144,6 +145,7 @@ extensions_ui = { path = "crates/extensions_ui" }
 feature_flags = { path = "crates/feature_flags" }
 feedback = { path = "crates/feedback" }
 file_finder = { path = "crates/file_finder" }
+file_icons = { path = "crates/file_icons" }
 fs = { path = "crates/fs" }
 fsevent = { path = "crates/fsevent" }
 fuzzy = { path = "crates/fuzzy" }

crates/assistant/Cargo.toml 🔗

@@ -16,6 +16,7 @@ client.workspace = true
 collections.workspace = true
 command_palette_hooks.workspace = true
 editor.workspace = true
+file_icons.workspace = true
 fs.workspace = true
 futures.workspace = true
 gpui.workspace = true

crates/assistant/src/assistant.rs 🔗

@@ -6,6 +6,8 @@ mod prompts;
 mod saved_conversation;
 mod streaming_diff;
 
+mod embedded_scope;
+
 pub use assistant_panel::AssistantPanel;
 use assistant_settings::{AssistantSettings, OpenAiModel, ZedDotDevModel};
 use chrono::{DateTime, Local};

crates/assistant/src/assistant_panel.rs 🔗

@@ -1,13 +1,14 @@
 use crate::{
     assistant_settings::{AssistantDockPosition, AssistantSettings, ZedDotDevModel},
     codegen::{self, Codegen, CodegenKind},
+    embedded_scope::EmbeddedScope,
     prompts::generate_content_prompt,
     Assist, CompletionProvider, CycleMessageRole, InlineAssist, LanguageModel,
     LanguageModelRequest, LanguageModelRequestMessage, MessageId, MessageMetadata, MessageStatus,
     NewConversation, QuoteSelection, ResetKey, Role, SavedConversation, SavedConversationMetadata,
     SavedMessage, Split, ToggleFocus, ToggleIncludeConversation,
 };
-use anyhow::Result;
+use anyhow::{anyhow, Result};
 use chrono::{DateTime, Local};
 use collections::{hash_map, HashMap, HashSet, VecDeque};
 use editor::{
@@ -16,9 +17,10 @@ use editor::{
         BlockContext, BlockDisposition, BlockId, BlockProperties, BlockStyle, ToDisplayPoint,
     },
     scroll::{Autoscroll, AutoscrollStrategy},
-    Anchor, Editor, EditorElement, EditorEvent, EditorStyle, MultiBufferSnapshot, ToOffset as _,
-    ToPoint,
+    Anchor, Editor, EditorElement, EditorEvent, EditorStyle, MultiBuffer, MultiBufferSnapshot,
+    ToOffset as _, ToPoint,
 };
+use file_icons::FileIcons;
 use fs::Fs;
 use futures::StreamExt;
 use gpui::{
@@ -47,7 +49,7 @@ use uuid::Uuid;
 use workspace::{
     dock::{DockPosition, Panel, PanelEvent},
     searchable::Direction,
-    Save, Toast, ToggleZoom, Toolbar, Workspace,
+    Event as WorkspaceEvent, Save, Toast, ToggleZoom, Toolbar, Workspace,
 };
 
 pub fn init(cx: &mut AppContext) {
@@ -160,6 +162,11 @@ impl AssistantPanel {
                     ];
                     let model = CompletionProvider::global(cx).default_model();
 
+                    cx.observe_global::<FileIcons>(|_, cx| {
+                        cx.notify();
+                    })
+                    .detach();
+
                     Self {
                         workspace: workspace_handle,
                         active_conversation_editor: None,
@@ -709,18 +716,20 @@ impl AssistantPanel {
         });
     }
 
-    fn new_conversation(&mut self, cx: &mut ViewContext<Self>) -> View<ConversationEditor> {
+    fn new_conversation(&mut self, cx: &mut ViewContext<Self>) -> Option<View<ConversationEditor>> {
+        let workspace = self.workspace.upgrade()?;
+
         let editor = cx.new_view(|cx| {
             ConversationEditor::new(
                 self.model.clone(),
                 self.languages.clone(),
                 self.fs.clone(),
-                self.workspace.clone(),
+                workspace,
                 cx,
             )
         });
         self.show_conversation(editor.clone(), cx);
-        editor
+        Some(editor)
     }
 
     fn show_conversation(
@@ -989,11 +998,15 @@ impl AssistantPanel {
             .await?;
 
             this.update(&mut cx, |this, cx| {
+                let workspace = workspace
+                    .upgrade()
+                    .ok_or_else(|| anyhow!("workspace dropped"))?;
                 let editor = cx.new_view(|cx| {
                     ConversationEditor::for_conversation(conversation, fs, workspace, cx)
                 });
                 this.show_conversation(editor, cx);
-            })?;
+                anyhow::Ok(())
+            })??;
             Ok(())
         })
     }
@@ -1264,9 +1277,10 @@ struct Summary {
     done: bool,
 }
 
-struct Conversation {
+pub struct Conversation {
     id: Option<String>,
     buffer: Model<Buffer>,
+    embedded_scope: EmbeddedScope,
     message_anchors: Vec<MessageAnchor>,
     messages_metadata: HashMap<MessageId, MessageMetadata>,
     next_message_id: MessageId,
@@ -1288,6 +1302,7 @@ impl Conversation {
     fn new(
         model: LanguageModel,
         language_registry: Arc<LanguageRegistry>,
+        embedded_scope: EmbeddedScope,
         cx: &mut ModelContext<Self>,
     ) -> Self {
         let markdown = language_registry.language_for_name("Markdown");
@@ -1321,7 +1336,9 @@ impl Conversation {
             pending_save: Task::ready(Ok(())),
             path: None,
             buffer,
+            embedded_scope,
         };
+
         let message = MessageAnchor {
             id: MessageId(post_inc(&mut this.next_message_id.0)),
             start: language::Anchor::MIN,
@@ -1422,6 +1439,7 @@ impl Conversation {
                 pending_save: Task::ready(Ok(())),
                 path: Some(path),
                 buffer,
+                embedded_scope: EmbeddedScope::new(),
             };
             this.count_remaining_tokens(cx);
             this
@@ -1440,7 +1458,7 @@ impl Conversation {
         }
     }
 
-    fn count_remaining_tokens(&mut self, cx: &mut ModelContext<Self>) {
+    pub(crate) fn count_remaining_tokens(&mut self, cx: &mut ModelContext<Self>) {
         let request = self.to_completion_request(cx);
         self.pending_token_count = cx.spawn(|this, mut cx| {
             async move {
@@ -1603,7 +1621,7 @@ impl Conversation {
     }
 
     fn to_completion_request(&self, cx: &mut ModelContext<Conversation>) -> LanguageModelRequest {
-        let request = LanguageModelRequest {
+        let mut request = LanguageModelRequest {
             model: self.model.clone(),
             messages: self
                 .messages(cx)
@@ -1613,6 +1631,9 @@ impl Conversation {
             stop: vec![],
             temperature: 1.0,
         };
+
+        let context_message = self.embedded_scope.message(cx);
+        request.messages.extend(context_message);
         request
     }
 
@@ -2002,17 +2023,18 @@ impl ConversationEditor {
         model: LanguageModel,
         language_registry: Arc<LanguageRegistry>,
         fs: Arc<dyn Fs>,
-        workspace: WeakView<Workspace>,
+        workspace: View<Workspace>,
         cx: &mut ViewContext<Self>,
     ) -> Self {
-        let conversation = cx.new_model(|cx| Conversation::new(model, language_registry, cx));
+        let conversation = cx
+            .new_model(|cx| Conversation::new(model, language_registry, EmbeddedScope::new(), cx));
         Self::for_conversation(conversation, fs, workspace, cx)
     }
 
     fn for_conversation(
         conversation: Model<Conversation>,
         fs: Arc<dyn Fs>,
-        workspace: WeakView<Workspace>,
+        workspace: View<Workspace>,
         cx: &mut ViewContext<Self>,
     ) -> Self {
         let editor = cx.new_view(|cx| {
@@ -2027,6 +2049,7 @@ impl ConversationEditor {
             cx.observe(&conversation, |_, _, cx| cx.notify()),
             cx.subscribe(&conversation, Self::handle_conversation_event),
             cx.subscribe(&editor, Self::handle_editor_event),
+            cx.subscribe(&workspace, Self::handle_workspace_event),
         ];
 
         let mut this = Self {
@@ -2035,9 +2058,10 @@ impl ConversationEditor {
             blocks: Default::default(),
             scroll_position: None,
             fs,
-            workspace,
+            workspace: workspace.downgrade(),
             _subscriptions,
         };
+        this.update_active_buffer(workspace, cx);
         this.update_message_headers(cx);
         this
     }
@@ -2171,6 +2195,37 @@ impl ConversationEditor {
         }
     }
 
+    fn handle_workspace_event(
+        &mut self,
+        workspace: View<Workspace>,
+        event: &WorkspaceEvent,
+        cx: &mut ViewContext<Self>,
+    ) {
+        if let WorkspaceEvent::ActiveItemChanged = event {
+            self.update_active_buffer(workspace, cx);
+        }
+    }
+
+    fn update_active_buffer(
+        &mut self,
+        workspace: View<Workspace>,
+        cx: &mut ViewContext<'_, ConversationEditor>,
+    ) {
+        let active_buffer = workspace
+            .read(cx)
+            .active_item(cx)
+            .and_then(|item| Some(item.act_as::<Editor>(cx)?.read(cx).buffer().clone()));
+
+        self.conversation.update(cx, |conversation, cx| {
+            conversation
+                .embedded_scope
+                .set_active_buffer(active_buffer.clone(), cx);
+
+            conversation.count_remaining_tokens(cx);
+            cx.notify();
+        });
+    }
+
     fn cursor_scroll_position(&self, cx: &mut ViewContext<Self>) -> Option<ScrollPosition> {
         self.editor.update(cx, |editor, cx| {
             let snapshot = editor.snapshot(cx);
@@ -2304,11 +2359,11 @@ impl ConversationEditor {
         let start_language = buffer.language_at(range.start);
         let end_language = buffer.language_at(range.end);
         let language_name = if start_language == end_language {
-            start_language.map(|language| language.name())
+            start_language.map(|language| language.code_fence_block_name())
         } else {
             None
         };
-        let language_name = language_name.as_deref().unwrap_or("").to_lowercase();
+        let language_name = language_name.as_deref().unwrap_or("");
 
         let selected_text = buffer.text_for_range(range).collect::<String>();
         let text = if selected_text.is_empty() {
@@ -2332,15 +2387,17 @@ impl ConversationEditor {
 
         if let Some(text) = text {
             panel.update(cx, |panel, cx| {
-                let conversation = panel
+                if let Some(conversation) = panel
                     .active_conversation_editor()
                     .cloned()
-                    .unwrap_or_else(|| panel.new_conversation(cx));
-                conversation.update(cx, |conversation, cx| {
-                    conversation
-                        .editor
-                        .update(cx, |editor, cx| editor.insert(&text, cx))
-                });
+                    .or_else(|| panel.new_conversation(cx))
+                {
+                    conversation.update(cx, |conversation, cx| {
+                        conversation
+                            .editor
+                            .update(cx, |editor, cx| editor.insert(&text, cx))
+                    });
+                };
             });
         }
     }
@@ -2405,12 +2462,120 @@ impl ConversationEditor {
             .map(|summary| summary.text.clone())
             .unwrap_or_else(|| "New Conversation".into())
     }
+
+    fn render_embedded_scope(&self, cx: &mut ViewContext<Self>) -> Option<impl Element> {
+        let active_buffer = self
+            .conversation
+            .read(cx)
+            .embedded_scope
+            .active_buffer()?
+            .clone();
+
+        Some(
+            div()
+                .p_4()
+                .v_flex()
+                .child(
+                    div()
+                        .h_flex()
+                        .items_center()
+                        .child(Icon::new(IconName::File))
+                        .child(
+                            div()
+                                .h_6()
+                                .child(Label::new("File Contexts"))
+                                .ml_1()
+                                .font_weight(FontWeight::SEMIBOLD),
+                        ),
+                )
+                .child(
+                    div()
+                        .ml_4()
+                        .child(self.render_active_buffer(active_buffer, cx)),
+                ),
+        )
+    }
+
+    fn render_active_buffer(
+        &self,
+        buffer: Model<MultiBuffer>,
+        cx: &mut ViewContext<Self>,
+    ) -> impl Element {
+        let buffer = buffer.read(cx);
+        let icon_path;
+        let path;
+        if let Some(singleton) = buffer.as_singleton() {
+            let singleton = singleton.read(cx);
+
+            path = singleton.file().map(|file| file.full_path(cx));
+
+            icon_path = path
+                .as_ref()
+                .and_then(|path| FileIcons::get_icon(path.as_path(), cx))
+                .map(SharedString::from)
+                .unwrap_or_else(|| SharedString::from("icons/file_icons/file.svg"));
+        } else {
+            icon_path = SharedString::from("icons/file_icons/file.svg");
+            path = None;
+        }
+
+        let file_name = path.map_or("Untitled".to_string(), |path| {
+            path.to_string_lossy().to_string()
+        });
+
+        let enabled = self
+            .conversation
+            .read(cx)
+            .embedded_scope
+            .active_buffer_enabled();
+
+        let file_name_text_color = if enabled {
+            Color::Default
+        } else {
+            Color::Disabled
+        };
+
+        div()
+            .id("active-buffer")
+            .h_flex()
+            .cursor_pointer()
+            .child(Icon::from_path(icon_path).color(file_name_text_color))
+            .child(
+                div()
+                    .h_6()
+                    .child(Label::new(file_name).color(file_name_text_color))
+                    .ml_1(),
+            )
+            .children(enabled.then(|| {
+                div()
+                    .child(Icon::new(IconName::Check).color(file_name_text_color))
+                    .ml_1()
+            }))
+            .on_click(cx.listener(move |this, _, cx| {
+                this.conversation.update(cx, |conversation, cx| {
+                    conversation
+                        .embedded_scope
+                        .set_active_buffer_enabled(!enabled);
+                    cx.notify();
+                })
+            }))
+    }
 }
 
 impl EventEmitter<ConversationEditorEvent> for ConversationEditor {}
 
 impl Render for ConversationEditor {
     fn render(&mut self, cx: &mut ViewContext<Self>) -> impl Element {
+        //
+        // The ConversationEditor has two main segments
+        //
+        // 1. Messages Editor
+        // 2. Context
+        //   - File Context (currently only the active file)
+        //   - Project Diagnostics (Planned)
+        //   - Deep Code Context (Planned, for query and other tools for the model)
+        //
+
         div()
             .key_context("ConversationEditor")
             .capture_action(cx.listener(ConversationEditor::cancel_last_assist))
@@ -2420,14 +2585,15 @@ impl Render for ConversationEditor {
             .on_action(cx.listener(ConversationEditor::assist))
             .on_action(cx.listener(ConversationEditor::split))
             .size_full()
-            .relative()
+            .v_flex()
             .child(
                 div()
-                    .size_full()
+                    .flex_grow()
                     .pl_4()
                     .bg(cx.theme().colors().editor_background)
                     .child(self.editor.clone()),
             )
+            .child(div().flex_shrink().children(self.render_embedded_scope(cx)))
     }
 }
 
@@ -2799,8 +2965,9 @@ mod tests {
         init(cx);
         let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone()));
 
-        let conversation =
-            cx.new_model(|cx| Conversation::new(LanguageModel::default(), registry, cx));
+        let conversation = cx.new_model(|cx| {
+            Conversation::new(LanguageModel::default(), registry, EmbeddedScope::new(), cx)
+        });
         let buffer = conversation.read(cx).buffer.clone();
 
         let message_1 = conversation.read(cx).message_anchors[0].clone();
@@ -2931,8 +3098,9 @@ mod tests {
         init(cx);
         let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone()));
 
-        let conversation =
-            cx.new_model(|cx| Conversation::new(LanguageModel::default(), registry, cx));
+        let conversation = cx.new_model(|cx| {
+            Conversation::new(LanguageModel::default(), registry, EmbeddedScope::new(), cx)
+        });
         let buffer = conversation.read(cx).buffer.clone();
 
         let message_1 = conversation.read(cx).message_anchors[0].clone();
@@ -3030,8 +3198,9 @@ mod tests {
         cx.set_global(settings_store);
         init(cx);
         let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone()));
-        let conversation =
-            cx.new_model(|cx| Conversation::new(LanguageModel::default(), registry, cx));
+        let conversation = cx.new_model(|cx| {
+            Conversation::new(LanguageModel::default(), registry, EmbeddedScope::new(), cx)
+        });
         let buffer = conversation.read(cx).buffer.clone();
 
         let message_1 = conversation.read(cx).message_anchors[0].clone();
@@ -3115,8 +3284,14 @@ mod tests {
         cx.set_global(CompletionProvider::Fake(FakeCompletionProvider::default()));
         cx.update(init);
         let registry = Arc::new(LanguageRegistry::test(cx.executor()));
-        let conversation =
-            cx.new_model(|cx| Conversation::new(LanguageModel::default(), registry.clone(), cx));
+        let conversation = cx.new_model(|cx| {
+            Conversation::new(
+                LanguageModel::default(),
+                registry.clone(),
+                EmbeddedScope::new(),
+                cx,
+            )
+        });
         let buffer = conversation.read_with(cx, |conversation, _| conversation.buffer.clone());
         let message_0 =
             conversation.read_with(cx, |conversation, _| conversation.message_anchors[0].id);

crates/assistant/src/embedded_scope.rs 🔗

@@ -0,0 +1,91 @@
+use editor::MultiBuffer;
+use gpui::{AppContext, Model, ModelContext, Subscription};
+
+use crate::{assistant_panel::Conversation, LanguageModelRequestMessage, Role};
+
+#[derive(Default)]
+pub struct EmbeddedScope {
+    active_buffer: Option<Model<MultiBuffer>>,
+    active_buffer_enabled: bool,
+    active_buffer_subscription: Option<Subscription>,
+}
+
+impl EmbeddedScope {
+    pub fn new() -> Self {
+        Self {
+            active_buffer: None,
+            active_buffer_enabled: true,
+            active_buffer_subscription: None,
+        }
+    }
+
+    pub fn set_active_buffer(
+        &mut self,
+        buffer: Option<Model<MultiBuffer>>,
+        cx: &mut ModelContext<Conversation>,
+    ) {
+        self.active_buffer_subscription.take();
+
+        if let Some(active_buffer) = buffer.clone() {
+            self.active_buffer_subscription =
+                Some(cx.subscribe(&active_buffer, |conversation, _, e, cx| {
+                    if let multi_buffer::Event::Edited { .. } = e {
+                        conversation.count_remaining_tokens(cx)
+                    }
+                }));
+        }
+
+        self.active_buffer = buffer;
+    }
+
+    pub fn active_buffer(&self) -> Option<&Model<MultiBuffer>> {
+        self.active_buffer.as_ref()
+    }
+
+    pub fn active_buffer_enabled(&self) -> bool {
+        self.active_buffer_enabled
+    }
+
+    pub fn set_active_buffer_enabled(&mut self, enabled: bool) {
+        self.active_buffer_enabled = enabled;
+    }
+
+    /// Provide a message for the language model based on the active buffer.
+    pub fn message(&self, cx: &AppContext) -> Option<LanguageModelRequestMessage> {
+        if !self.active_buffer_enabled {
+            return None;
+        }
+
+        let active_buffer = self.active_buffer.as_ref()?;
+        let buffer = active_buffer.read(cx);
+
+        if let Some(singleton) = buffer.as_singleton() {
+            let singleton = singleton.read(cx);
+
+            let filename = singleton
+                .file()
+                .map(|file| file.path().to_string_lossy())
+                .unwrap_or("Untitled".into());
+
+            let text = singleton.text();
+
+            let language = singleton
+                .language()
+                .map(|l| {
+                    let name = l.code_fence_block_name();
+                    name.to_string()
+                })
+                .unwrap_or_default();
+
+            let markdown =
+                format!("User's active file `{filename}`:\n\n```{language}\n{text}```\n\n");
+
+            return Some(LanguageModelRequestMessage {
+                role: Role::System,
+                content: markdown,
+            });
+        }
+
+        None
+    }
+}

crates/file_icons/Cargo.toml 🔗

@@ -0,0 +1,21 @@
+[package]
+name = "file_icons"
+version = "0.1.0"
+edition = "2021"
+publish = false
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/file_icons.rs"
+doctest = false
+
+[dependencies]
+gpui.workspace = true
+util.workspace = true
+serde.workspace = true
+serde_derive.workspace = true
+serde_json.workspace = true
+collections.workspace = true

crates/project_panel/src/file_associations.rs → crates/file_icons/src/file_icons.rs 🔗

@@ -12,13 +12,13 @@ struct TypeConfig {
 }
 
 #[derive(Deserialize, Debug)]
-pub struct FileAssociations {
+pub struct FileIcons {
     stems: HashMap<String, String>,
     suffixes: HashMap<String, String>,
     types: HashMap<String, TypeConfig>,
 }
 
-impl Global for FileAssociations {}
+impl Global for FileIcons {}
 
 const COLLAPSED_DIRECTORY_TYPE: &str = "collapsed_folder";
 const EXPANDED_DIRECTORY_TYPE: &str = "expanded_folder";
@@ -27,18 +27,18 @@ const EXPANDED_CHEVRON_TYPE: &str = "expanded_chevron";
 pub const FILE_TYPES_ASSET: &str = "icons/file_icons/file_types.json";
 
 pub fn init(assets: impl AssetSource, cx: &mut AppContext) {
-    cx.set_global(FileAssociations::new(assets))
+    cx.set_global(FileIcons::new(assets))
 }
 
-impl FileAssociations {
+impl FileIcons {
     pub fn new(assets: impl AssetSource) -> Self {
         assets
             .load("icons/file_icons/file_types.json")
             .and_then(|file| {
-                serde_json::from_str::<FileAssociations>(str::from_utf8(&file).unwrap())
+                serde_json::from_str::<FileIcons>(str::from_utf8(&file).unwrap())
                     .map_err(Into::into)
             })
-            .unwrap_or_else(|_| FileAssociations {
+            .unwrap_or_else(|_| FileIcons {
                 stems: HashMap::default(),
                 suffixes: HashMap::default(),
                 types: HashMap::default(),

crates/language/src/language.rs 🔗

@@ -486,6 +486,8 @@ pub struct CodeLabel {
 pub struct LanguageConfig {
     /// Human-readable name of the language.
     pub name: Arc<str>,
+    /// The name of this language for a Markdown code fence block
+    pub code_fence_block_name: Option<Arc<str>>,
     // The name of the grammar in a WASM bundle (experimental).
     pub grammar: Option<Arc<str>>,
     /// The criteria for matching this language to a given file.
@@ -609,6 +611,7 @@ impl Default for LanguageConfig {
     fn default() -> Self {
         Self {
             name: "".into(),
+            code_fence_block_name: None,
             grammar: None,
             matcher: LanguageMatcher::default(),
             brackets: Default::default(),
@@ -1185,6 +1188,13 @@ impl Language {
         self.config.name.clone()
     }
 
+    pub fn code_fence_block_name(&self) -> Arc<str> {
+        self.config
+            .code_fence_block_name
+            .clone()
+            .unwrap_or_else(|| self.config.name.to_lowercase().into())
+    }
+
     pub fn context_provider(&self) -> Option<Arc<dyn ContextProvider>> {
         self.context_provider.clone()
     }

crates/languages/src/bash/config.toml 🔗

@@ -1,4 +1,5 @@
 name = "Shell Script"
+code_fence_block_name = "bash"
 grammar = "bash"
 path_suffixes = ["sh", "bash", "bashrc", "bash_profile", "bash_aliases", "bash_logout", "profile", "zsh", "zshrc", "zshenv", "zsh_profile", "zsh_aliases", "zsh_histfile", "zlogin", "zprofile", ".env"]
 line_comments = ["# "]

crates/project_panel/Cargo.toml 🔗

@@ -17,6 +17,7 @@ anyhow.workspace = true
 collections.workspace = true
 db.workspace = true
 editor.workspace = true
+file_icons.workspace = true
 gpui.workspace = true
 menu.workspace = true
 pretty_assertions.workspace = true

crates/project_panel/src/project_panel.rs 🔗

@@ -1,11 +1,10 @@
-pub mod file_associations;
 mod project_panel_settings;
 use client::{ErrorCode, ErrorExt};
 use settings::Settings;
 
 use db::kvp::KEY_VALUE_STORE;
 use editor::{actions::Cancel, items::entry_git_aware_label_color, scroll::Autoscroll, Editor};
-use file_associations::FileAssociations;
+use file_icons::FileIcons;
 
 use anyhow::{anyhow, Result};
 use collections::{hash_map, HashMap};
@@ -142,7 +141,7 @@ pub fn init_settings(cx: &mut AppContext) {
 
 pub fn init(assets: impl AssetSource, cx: &mut AppContext) {
     init_settings(cx);
-    file_associations::init(assets, cx);
+    file_icons::init(assets, cx);
 
     cx.observe_new_views(|workspace: &mut Workspace, _| {
         workspace.register_action(|workspace, _: &ToggleFocus, cx| {
@@ -229,7 +228,7 @@ impl ProjectPanel {
             })
             .detach();
 
-            cx.observe_global::<FileAssociations>(|_, cx| {
+            cx.observe_global::<FileIcons>(|_, cx| {
                 cx.notify();
             })
             .detach();
@@ -1329,16 +1328,16 @@ impl ProjectPanel {
                     let icon = match entry.kind {
                         EntryKind::File(_) => {
                             if show_file_icons {
-                                FileAssociations::get_icon(&entry.path, cx)
+                                FileIcons::get_icon(&entry.path, cx)
                             } else {
                                 None
                             }
                         }
                         _ => {
                             if show_folder_icons {
-                                FileAssociations::get_folder_icon(is_expanded, cx)
+                                FileIcons::get_folder_icon(is_expanded, cx)
                             } else {
-                                FileAssociations::get_chevron_icon(is_expanded, cx)
+                                FileIcons::get_chevron_icon(is_expanded, cx)
                             }
                         }
                     };

crates/workspace/src/workspace.rs 🔗

@@ -517,6 +517,7 @@ impl DelayedDebouncedEditAction {
 
 pub enum Event {
     PaneAdded(View<Pane>),
+    ActiveItemChanged,
     ContactRequestedJoin(u64),
     WorkspaceCreated(WeakView<Workspace>),
     SpawnTask(SpawnInTerminal),
@@ -2377,6 +2378,7 @@ impl Workspace {
                 self.update_window_edited(cx);
             }
             pane::Event::RemoveItem { item_id } => {
+                cx.emit(Event::ActiveItemChanged);
                 self.update_window_edited(cx);
                 if let hash_map::Entry::Occupied(entry) = self.panes_by_item.entry(*item_id) {
                     if entry.get().entity_id() == pane.entity_id() {
@@ -2747,10 +2749,12 @@ impl Workspace {
             .any(|state| state.leader_id == peer_id)
     }
 
-    fn active_item_path_changed(&mut self, cx: &mut WindowContext) {
+    fn active_item_path_changed(&mut self, cx: &mut ViewContext<Self>) {
+        cx.emit(Event::ActiveItemChanged);
         let active_entry = self.active_project_path(cx);
         self.project
             .update(cx, |project, cx| project.set_active_path(active_entry, cx));
+
         self.update_window_title(cx);
     }
 

crates/zed/Cargo.toml 🔗

@@ -42,6 +42,7 @@ env_logger.workspace = true
 extension.workspace = true
 extensions_ui.workspace = true
 feedback.workspace = true
+file_icons.workspace = true
 file_finder.workspace = true
 fs.workspace = true
 futures.workspace = true

crates/zed/src/main.rs 🔗

@@ -1082,7 +1082,7 @@ fn watch_file_types(fs: Arc<dyn fs::Fs>, cx: &mut AppContext) {
         while (events.next().await).is_some() {
             cx.update(|cx| {
                 cx.update_global(|file_types, _| {
-                    *file_types = project_panel::file_associations::FileAssociations::new(Assets);
+                    *file_types = file_icons::FileIcons::new(Assets);
                 });
             })
             .ok();