Introduce a new `/tabs` command (#12382)

Antonio Scandurra and Nathan created

This inserts the content of the open tabs sorted by recency.

Release Notes:

- N/A

---------

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

Change summary

crates/assistant/src/assistant_panel.rs              |  13 
crates/assistant/src/slash_command.rs                |   1 
crates/assistant/src/slash_command/active_command.rs | 119 +++++--------
crates/assistant/src/slash_command/tabs_command.rs   | 116 +++++++++++++
4 files changed, 173 insertions(+), 76 deletions(-)

Detailed changes

crates/assistant/src/assistant_panel.rs 🔗

@@ -1,5 +1,5 @@
 use crate::prompts::{generate_content_prompt, PromptLibrary, PromptManager};
-use crate::slash_command::search_command;
+use crate::slash_command::{search_command, tabs_command};
 use crate::{
     assistant_settings::{AssistantDockPosition, AssistantSettings, ZedDotDevModel},
     codegen::{self, Codegen, CodegenKind},
@@ -210,6 +210,7 @@ impl AssistantPanel {
                         prompt_command::PromptSlashCommand::new(prompt_library.clone()),
                     );
                     slash_command_registry.register_command(active_command::ActiveSlashCommand);
+                    slash_command_registry.register_command(tabs_command::TabsSlashCommand);
                     slash_command_registry.register_command(project_command::ProjectSlashCommand);
                     slash_command_registry.register_command(search_command::SearchSlashCommand);
 
@@ -1883,15 +1884,15 @@ impl Conversation {
             async move {
                 let output = output.await;
                 this.update(&mut cx, |this, cx| match output {
-                    Ok(output) => {
+                    Ok(mut output) => {
+                        if !output.text.ends_with('\n') {
+                            output.text.push('\n');
+                        }
+
                         let sections = this.buffer.update(cx, |buffer, cx| {
                             let start = command_range.start.to_offset(buffer);
                             let old_end = command_range.end.to_offset(buffer);
-                            let new_end = start + output.text.len();
                             buffer.edit([(start..old_end, output.text)], None, cx);
-                            if buffer.chars_at(new_end).next() != Some('\n') {
-                                buffer.edit([(new_end..new_end, "\n")], None, cx);
-                            }
 
                             let mut sections = output
                                 .sections

crates/assistant/src/slash_command.rs 🔗

@@ -21,6 +21,7 @@ pub mod file_command;
 pub mod project_command;
 pub mod prompt_command;
 pub mod search_command;
+pub mod tabs_command;
 
 pub(crate) struct SlashCommandCompletionProvider {
     editor: WeakView<ConversationEditor>,

crates/assistant/src/slash_command/active_command.rs 🔗

@@ -1,9 +1,8 @@
 use super::{file_command::FilePlaceholder, SlashCommand, SlashCommandOutput};
 use anyhow::{anyhow, Result};
 use assistant_slash_command::SlashCommandOutputSection;
-use collections::HashMap;
 use editor::Editor;
-use gpui::{AppContext, Entity, Task, WeakView};
+use gpui::{AppContext, Task, WeakView};
 use language::LspAdapterDelegate;
 use std::{borrow::Cow, sync::Arc};
 use ui::{IntoElement, WindowContext};
@@ -45,79 +44,59 @@ impl SlashCommand for ActiveSlashCommand {
         cx: &mut WindowContext,
     ) -> Task<Result<SlashCommandOutput>> {
         let output = workspace.update(cx, |workspace, cx| {
-            let mut timestamps_by_entity_id = HashMap::default();
-            for pane in workspace.panes() {
-                let pane = pane.read(cx);
-                for entry in pane.activation_history() {
-                    timestamps_by_entity_id.insert(entry.entity_id, entry.timestamp);
-                }
-            }
-
-            let mut most_recent_buffer = None;
-            for editor in workspace.items_of_type::<Editor>(cx) {
-                let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton() else {
-                    continue;
-                };
-
-                let timestamp = timestamps_by_entity_id
-                    .get(&editor.entity_id())
-                    .copied()
-                    .unwrap_or_default();
-                if most_recent_buffer
-                    .as_ref()
-                    .map_or(true, |(_, prev_timestamp)| timestamp > *prev_timestamp)
-                {
-                    most_recent_buffer = Some((buffer, timestamp));
-                }
-            }
+            let Some(active_item) = workspace.active_item(cx) else {
+                return Task::ready(Err(anyhow!("no active tab")));
+            };
+            let Some(buffer) = active_item
+                .downcast::<Editor>()
+                .and_then(|editor| editor.read(cx).buffer().read(cx).as_singleton())
+            else {
+                return Task::ready(Err(anyhow!("active tab is not an editor")));
+            };
 
-            if let Some((buffer, _)) = most_recent_buffer {
-                let snapshot = buffer.read(cx).snapshot();
-                let path = snapshot.resolve_file_path(cx, true);
-                let text = cx.background_executor().spawn({
-                    let path = path.clone();
-                    async move {
-                        let path = path
-                            .as_ref()
-                            .map(|path| path.to_string_lossy())
-                            .unwrap_or_else(|| Cow::Borrowed("untitled"));
+            let snapshot = buffer.read(cx).snapshot();
+            let path = snapshot.resolve_file_path(cx, true);
+            let text = cx.background_executor().spawn({
+                let path = path.clone();
+                async move {
+                    let path = path
+                        .as_ref()
+                        .map(|path| path.to_string_lossy())
+                        .unwrap_or_else(|| Cow::Borrowed("untitled"));
 
-                        let mut output = String::with_capacity(path.len() + snapshot.len() + 9);
-                        output.push_str("```");
-                        output.push_str(&path);
+                    let mut output = String::with_capacity(path.len() + snapshot.len() + 9);
+                    output.push_str("```");
+                    output.push_str(&path);
+                    output.push('\n');
+                    for chunk in snapshot.as_rope().chunks() {
+                        output.push_str(chunk);
+                    }
+                    if !output.ends_with('\n') {
                         output.push('\n');
-                        for chunk in snapshot.as_rope().chunks() {
-                            output.push_str(chunk);
-                        }
-                        if !output.ends_with('\n') {
-                            output.push('\n');
-                        }
-                        output.push_str("```");
-                        output
                     }
-                });
-                cx.foreground_executor().spawn(async move {
-                    let text = text.await;
-                    let range = 0..text.len();
-                    Ok(SlashCommandOutput {
-                        text,
-                        sections: vec![SlashCommandOutputSection {
-                            range,
-                            render_placeholder: Arc::new(move |id, unfold, _| {
-                                FilePlaceholder {
-                                    id,
-                                    path: path.clone(),
-                                    line_range: None,
-                                    unfold,
-                                }
-                                .into_any_element()
-                            }),
-                        }],
-                    })
+                    output.push_str("```");
+                    output
+                }
+            });
+            cx.foreground_executor().spawn(async move {
+                let text = text.await;
+                let range = 0..text.len();
+                Ok(SlashCommandOutput {
+                    text,
+                    sections: vec![SlashCommandOutputSection {
+                        range,
+                        render_placeholder: Arc::new(move |id, unfold, _| {
+                            FilePlaceholder {
+                                id,
+                                path: path.clone(),
+                                line_range: None,
+                                unfold,
+                            }
+                            .into_any_element()
+                        }),
+                    }],
                 })
-            } else {
-                Task::ready(Err(anyhow!("no recent buffer found")))
-            }
+            })
         });
         output.unwrap_or_else(|error| Task::ready(Err(error)))
     }

crates/assistant/src/slash_command/tabs_command.rs 🔗

@@ -0,0 +1,116 @@
+use super::{file_command::FilePlaceholder, SlashCommand, SlashCommandOutput};
+use anyhow::{anyhow, Result};
+use assistant_slash_command::SlashCommandOutputSection;
+use collections::HashMap;
+use editor::Editor;
+use gpui::{AppContext, Entity, Task, WeakView};
+use language::LspAdapterDelegate;
+use std::{fmt::Write, path::Path, sync::Arc};
+use ui::{IntoElement, WindowContext};
+use workspace::Workspace;
+
+pub(crate) struct TabsSlashCommand;
+
+impl SlashCommand for TabsSlashCommand {
+    fn name(&self) -> String {
+        "tabs".into()
+    }
+
+    fn description(&self) -> String {
+        "insert content from open tabs".into()
+    }
+
+    fn tooltip_text(&self) -> String {
+        "insert open tabs".into()
+    }
+
+    fn requires_argument(&self) -> bool {
+        false
+    }
+
+    fn complete_argument(
+        &self,
+        _query: String,
+        _cancel: Arc<std::sync::atomic::AtomicBool>,
+        _cx: &mut AppContext,
+    ) -> Task<Result<Vec<String>>> {
+        Task::ready(Err(anyhow!("this command does not require argument")))
+    }
+
+    fn run(
+        self: Arc<Self>,
+        _argument: Option<&str>,
+        workspace: WeakView<Workspace>,
+        _delegate: Arc<dyn LspAdapterDelegate>,
+        cx: &mut WindowContext,
+    ) -> Task<Result<SlashCommandOutput>> {
+        let open_buffers = workspace.update(cx, |workspace, cx| {
+            let mut timestamps_by_entity_id = HashMap::default();
+            let mut open_buffers = Vec::new();
+
+            for pane in workspace.panes() {
+                let pane = pane.read(cx);
+                for entry in pane.activation_history() {
+                    timestamps_by_entity_id.insert(entry.entity_id, entry.timestamp);
+                }
+            }
+
+            for editor in workspace.items_of_type::<Editor>(cx) {
+                if let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton() {
+                    if let Some(timestamp) = timestamps_by_entity_id.get(&editor.entity_id()) {
+                        let snapshot = buffer.read(cx).snapshot();
+                        let full_path = snapshot.resolve_file_path(cx, true);
+                        open_buffers.push((full_path, snapshot, *timestamp));
+                    }
+                }
+            }
+
+            open_buffers
+        });
+
+        match open_buffers {
+            Ok(mut open_buffers) => cx.background_executor().spawn(async move {
+                open_buffers.sort_by_key(|(_, _, timestamp)| *timestamp);
+
+                let mut sections = Vec::new();
+                let mut text = String::new();
+                for (full_path, buffer, _) in open_buffers {
+                    let section_start_ix = text.len();
+                    writeln!(
+                        text,
+                        "```{}\n",
+                        full_path
+                            .as_deref()
+                            .unwrap_or(Path::new("untitled"))
+                            .display()
+                    )
+                    .unwrap();
+                    for chunk in buffer.as_rope().chunks() {
+                        text.push_str(chunk);
+                    }
+                    if !text.ends_with('\n') {
+                        text.push('\n');
+                    }
+                    writeln!(text, "```\n").unwrap();
+                    let section_end_ix = text.len() - 1;
+
+                    sections.push(SlashCommandOutputSection {
+                        range: section_start_ix..section_end_ix,
+                        render_placeholder: Arc::new(move |id, unfold, _| {
+                            FilePlaceholder {
+                                id,
+                                path: full_path.clone(),
+                                line_range: None,
+                                unfold,
+                            }
+                            .into_any_element()
+                        }),
+                    });
+                }
+
+                Ok(SlashCommandOutput { text, sections })
+            }),
+            Err(error) => Task::ready(Err(error)),
+        }
+    }
+}