search_command.rs

  1use super::{file_command::FilePlaceholder, SlashCommand, SlashCommandOutput};
  2use anyhow::Result;
  3use assistant_slash_command::SlashCommandOutputSection;
  4use gpui::{AppContext, Task, WeakView};
  5use language::{CodeLabel, HighlightId, LineEnding, LspAdapterDelegate};
  6use semantic_index::SemanticIndex;
  7use std::{
  8    fmt::Write,
  9    path::PathBuf,
 10    sync::{atomic::AtomicBool, Arc},
 11};
 12use ui::{prelude::*, ButtonLike, ElevationIndex, Icon, IconName};
 13use util::ResultExt;
 14use workspace::Workspace;
 15
 16pub(crate) struct SearchSlashCommand;
 17
 18impl SlashCommand for SearchSlashCommand {
 19    fn name(&self) -> String {
 20        "search".into()
 21    }
 22
 23    fn label(&self, cx: &AppContext) -> CodeLabel {
 24        let mut label = CodeLabel::default();
 25        label.push_str("search ", None);
 26        label.push_str(
 27            "--n",
 28            cx.theme().syntax().highlight_id("comment").map(HighlightId),
 29        );
 30        label.filter_range = 0.."search".len();
 31        label
 32    }
 33
 34    fn description(&self) -> String {
 35        "semantically search files".into()
 36    }
 37
 38    fn tooltip_text(&self) -> String {
 39        "search".into()
 40    }
 41
 42    fn requires_argument(&self) -> bool {
 43        true
 44    }
 45
 46    fn complete_argument(
 47        &self,
 48        _query: String,
 49        _cancel: Arc<AtomicBool>,
 50        _cx: &mut AppContext,
 51    ) -> Task<Result<Vec<String>>> {
 52        Task::ready(Ok(Vec::new()))
 53    }
 54
 55    fn run(
 56        self: Arc<Self>,
 57        argument: Option<&str>,
 58        workspace: WeakView<Workspace>,
 59        _delegate: Arc<dyn LspAdapterDelegate>,
 60        cx: &mut WindowContext,
 61    ) -> Task<Result<SlashCommandOutput>> {
 62        let Some(workspace) = workspace.upgrade() else {
 63            return Task::ready(Err(anyhow::anyhow!("workspace was dropped")));
 64        };
 65        let Some(argument) = argument else {
 66            return Task::ready(Err(anyhow::anyhow!("missing search query")));
 67        };
 68
 69        let mut limit = None;
 70        let mut query = String::new();
 71        for part in argument.split(' ') {
 72            if let Some(parameter) = part.strip_prefix("--") {
 73                if let Ok(count) = parameter.parse::<usize>() {
 74                    limit = Some(count);
 75                    continue;
 76                }
 77            }
 78
 79            query.push_str(part);
 80            query.push(' ');
 81        }
 82        query.pop();
 83
 84        if query.is_empty() {
 85            return Task::ready(Err(anyhow::anyhow!("missing search query")));
 86        }
 87
 88        let project = workspace.read(cx).project().clone();
 89        let fs = project.read(cx).fs().clone();
 90        let project_index =
 91            cx.update_global(|index: &mut SemanticIndex, cx| index.project_index(project, cx));
 92
 93        cx.spawn(|cx| async move {
 94            let results = project_index
 95                .read_with(&cx, |project_index, cx| {
 96                    project_index.search(query.clone(), limit.unwrap_or(5), cx)
 97                })?
 98                .await?;
 99
100            let mut loaded_results = Vec::new();
101            for result in results {
102                let (full_path, file_content) =
103                    result.worktree.read_with(&cx, |worktree, _cx| {
104                        let entry_abs_path = worktree.abs_path().join(&result.path);
105                        let mut entry_full_path = PathBuf::from(worktree.root_name());
106                        entry_full_path.push(&result.path);
107                        let file_content = async {
108                            let entry_abs_path = entry_abs_path;
109                            fs.load(&entry_abs_path).await
110                        };
111                        (entry_full_path, file_content)
112                    })?;
113                if let Some(file_content) = file_content.await.log_err() {
114                    loaded_results.push((result, full_path, file_content));
115                }
116            }
117
118            let output = cx
119                .background_executor()
120                .spawn(async move {
121                    let mut text = format!("Search results for {query}:\n");
122                    let mut sections = Vec::new();
123                    for (result, full_path, file_content) in loaded_results {
124                        let range_start = result.range.start.min(file_content.len());
125                        let range_end = result.range.end.min(file_content.len());
126
127                        let start_line =
128                            file_content[0..range_start].matches('\n').count() as u32 + 1;
129                        let end_line = file_content[0..range_end].matches('\n').count() as u32 + 1;
130                        let start_line_byte_offset = file_content[0..range_start]
131                            .rfind('\n')
132                            .map(|pos| pos + 1)
133                            .unwrap_or_default();
134                        let end_line_byte_offset = file_content[range_end..]
135                            .find('\n')
136                            .map(|pos| range_end + pos)
137                            .unwrap_or_else(|| file_content.len());
138
139                        let section_start_ix = text.len();
140                        writeln!(
141                            text,
142                            "```{}:{}-{}",
143                            result.path.display(),
144                            start_line,
145                            end_line,
146                        )
147                        .unwrap();
148                        let mut excerpt =
149                            file_content[start_line_byte_offset..end_line_byte_offset].to_string();
150                        LineEnding::normalize(&mut excerpt);
151                        text.push_str(&excerpt);
152                        writeln!(text, "\n```\n").unwrap();
153                        let section_end_ix = text.len() - 1;
154
155                        sections.push(SlashCommandOutputSection {
156                            range: section_start_ix..section_end_ix,
157                            render_placeholder: Arc::new(move |id, unfold, _| {
158                                FilePlaceholder {
159                                    id,
160                                    path: Some(full_path.clone()),
161                                    line_range: Some(start_line..end_line),
162                                    unfold,
163                                }
164                                .into_any_element()
165                            }),
166                        });
167                    }
168
169                    let query = SharedString::from(query);
170                    sections.push(SlashCommandOutputSection {
171                        range: 0..text.len(),
172                        render_placeholder: Arc::new(move |id, unfold, _cx| {
173                            ButtonLike::new(id)
174                                .style(ButtonStyle::Filled)
175                                .layer(ElevationIndex::ElevatedSurface)
176                                .child(Icon::new(IconName::MagnifyingGlass))
177                                .child(Label::new(query.clone()))
178                                .on_click(move |_, cx| unfold(cx))
179                                .into_any_element()
180                        }),
181                    });
182
183                    SlashCommandOutput { text, sections }
184                })
185                .await;
186
187            Ok(output)
188        })
189    }
190}