use anyhow::{Context as _, Result, anyhow};use assistant_slash_command::{ AfterCompletion, ArgumentCompletion, SlashCommand, SlashCommandContent, SlashCommandEvent, SlashCommandOutput, SlashCommandOutputSection, SlashCommandResult,};use futures::Stream;use futures::channel::mpsc;use fuzzy::PathMatch;use gpui::{App, Entity, Task, WeakEntity};use language::{BufferSnapshot, CodeLabelBuilder, HighlightId, LineEnding, LspAdapterDelegate};use project::{PathMatchCandidateSet, Project};use serde::{Deserialize, Serialize};use smol::stream::StreamExt;use std::{ fmt::Write, ops::{Range, RangeInclusive}, path::Path, sync::{Arc, atomic::AtomicBool},};use ui::prelude::*;use util::{ResultExt, rel_path::RelPath};use workspace::Workspace;use worktree::ChildEntriesOptions; pub struct FileSlashCommand; impl FileSlashCommand { fn search_paths( &self, query: String, cancellation_flag: Arc<AtomicBool>, workspace: &Entity<Workspace>, cx: &mut App, ) -> Task<Vec<PathMatch>> { if query.is_empty() { let workspace = workspace.read(cx); let project = workspace.project().read(cx); let entries = workspace.recent_navigation_history(Some(10), cx); let entries = entries .into_iter() .map(|entries| (entries.0, false)) .chain(project.worktrees(cx).flat_map(|worktree| { let worktree = worktree.read(cx); let id = worktree.id(); let options = ChildEntriesOptions { include_files: true, include_dirs: true, include_ignored: false, }; let entries = worktree.child_entries_with_options(RelPath::empty(), options); entries.map(move |entry| { ( project::ProjectPath { worktree_id: id, path: entry.path.clone(), }, entry.kind.is_dir(), ) }) })) .collect::<Vec<_>>(); let path_prefix: Arc<RelPath> = RelPath::empty().into(); Task::ready( entries .into_iter() .filter_map(|(entry, is_dir)| { let worktree = project.worktree_for_id(entry.worktree_id, cx)?; let full_path = worktree.read(cx).root_name().join(&entry.path); Some(PathMatch { score: 0., positions: Vec::new(), worktree_id: entry.worktree_id.to_usize(), path: full_path, path_prefix: path_prefix.clone(), distance_to_relative_ancestor: 0, is_dir, }) }) .collect(), ) } else { let worktrees = workspace.read(cx).visible_worktrees(cx).collect::<Vec<_>>(); let candidate_sets = worktrees .into_iter() .map(|worktree| { let worktree = worktree.read(cx); PathMatchCandidateSet { snapshot: worktree.snapshot(), include_ignored: worktree .root_entry() .is_some_and(|entry| entry.is_ignored), include_root_name: true, candidates: project::Candidates::Entries, } }) .collect::<Vec<_>>(); let executor = cx.background_executor().clone(); cx.foreground_executor().spawn(async move { fuzzy::match_path_sets( candidate_sets.as_slice(), query.as_str(), &None, false, 100, &cancellation_flag, executor, ) .await }) } }} impl SlashCommand for FileSlashCommand { fn name(&self) -> String { "file".into() } fn description(&self) -> String { "Insert file and/or directory".into() } fn menu_text(&self) -> String { self.description() } fn requires_argument(&self) -> bool { true } fn icon(&self) -> IconName { IconName::File } fn complete_argument( self: Arc<Self>, arguments: &[String], cancellation_flag: Arc<AtomicBool>, workspace: Option<WeakEntity<Workspace>>, _: &mut Window, cx: &mut App, ) -> Task<Result<Vec<ArgumentCompletion>>> { let Some(workspace) = workspace.and_then(|workspace| workspace.upgrade()) else { return Task::ready(Err(anyhow!("workspace was dropped"))); }; let path_style = workspace.read(cx).path_style(cx); let paths = self.search_paths( arguments.last().cloned().unwrap_or_default(), cancellation_flag, &workspace, cx, ); let comment_id = cx.theme().syntax().highlight_id("comment").map(HighlightId); cx.background_spawn(async move { Ok(paths .await .into_iter() .filter_map(|path_match| { let text = path_match .path_prefix .join(&path_match.path) .display(path_style) .to_string(); let mut label = CodeLabelBuilder::default(); let file_name = path_match.path.file_name()?; let label_text = if path_match.is_dir { format!("{}/ ", file_name) } else { format!("{} ", file_name) }; label.push_str(label_text.as_str(), None); label.push_str(&text, comment_id); label.respan_filter_range(Some(file_name)); Some(ArgumentCompletion { label: label.build(), new_text: text, after_completion: AfterCompletion::Compose, replace_previous_arguments: false, }) }) .collect()) }) } fn run( self: Arc<Self>, arguments: &[String], _context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>], _context_buffer: BufferSnapshot, workspace: WeakEntity<Workspace>, _delegate: Option<Arc<dyn LspAdapterDelegate>>, _: &mut Window, cx: &mut App, ) -> Task<SlashCommandResult> { let Some(workspace) = workspace.upgrade() else { return Task::ready(Err(anyhow!("workspace was dropped"))); }; if arguments.is_empty() { return Task::ready(Err(anyhow!("missing path"))); }; Task::ready(Ok(collect_files( workspace.read(cx).project().clone(), arguments, cx, ) .boxed())) }} fn collect_files( project: Entity<Project>, glob_inputs: &[String], cx: &mut App,) -> impl Stream<Item = Result<SlashCommandEvent>> + use<> { let Ok(matchers) = glob_inputs .iter() .map(|glob_input| { util::paths::PathMatcher::new(&[glob_input.to_owned()], project.read(cx).path_style(cx)) .with_context(|| format!("invalid path {glob_input}")) }) .collect::<anyhow::Result<Vec<util::paths::PathMatcher>>>() else { return futures::stream::once(async { anyhow::bail!("invalid path"); }) .boxed(); }; let project_handle = project.downgrade(); let snapshots = project .read(cx) .worktrees(cx) .map(|worktree| worktree.read(cx).snapshot()) .collect::<Vec<_>>(); let (events_tx, events_rx) = mpsc::unbounded(); cx.spawn(async move |cx| { for snapshot in snapshots { let worktree_id = snapshot.id(); let path_style = snapshot.path_style(); let mut directory_stack: Vec<Arc<RelPath>> = Vec::new(); let mut folded_directory_path: Option<Arc<RelPath>> = None; let mut folded_directory_names: Arc<RelPath> = RelPath::empty().into(); let mut is_top_level_directory = true; for entry in snapshot.entries(false, 0) { let path_including_worktree_name = snapshot.root_name().join(&entry.path); if !matchers .iter() .any(|matcher| matcher.is_match(&path_including_worktree_name)) { continue; } while let Some(dir) = directory_stack.last() { if entry.path.starts_with(dir) { break; } directory_stack.pop().unwrap(); events_tx.unbounded_send(Ok(SlashCommandEvent::EndSection))?; events_tx.unbounded_send(Ok(SlashCommandEvent::Content( SlashCommandContent::Text { text: "\n".into(), run_commands_in_text: false, }, )))?; } if let Some(folded_path) = &folded_directory_path { if !entry.path.starts_with(folded_path) { folded_directory_names = RelPath::empty().into(); folded_directory_path = None; if directory_stack.is_empty() { is_top_level_directory = true; } } } let filename = entry.path.file_name().unwrap_or_default().to_string(); if entry.is_dir() { // Auto-fold directories that contain no files let mut child_entries = snapshot.child_entries(&entry.path); if let Some(child) = child_entries.next() { if child_entries.next().is_none() && child.kind.is_dir() { if is_top_level_directory { is_top_level_directory = false; folded_directory_names = folded_directory_names.join(&path_including_worktree_name); } else { folded_directory_names = folded_directory_names.join(RelPath::unix(&filename).unwrap()); } folded_directory_path = Some(entry.path.clone()); continue; } } else { // Skip empty directories folded_directory_names = RelPath::empty().into(); folded_directory_path = None; continue; } // Render the directory (either folded or normal) if folded_directory_names.is_empty() { let label = if is_top_level_directory { is_top_level_directory = false; path_including_worktree_name.display(path_style).to_string() } else { filename }; events_tx.unbounded_send(Ok(SlashCommandEvent::StartSection { icon: IconName::Folder, label: label.clone().into(), metadata: None, }))?; events_tx.unbounded_send(Ok(SlashCommandEvent::Content( SlashCommandContent::Text { text: label.to_string(), run_commands_in_text: false, }, )))?; directory_stack.push(entry.path.clone()); } else { let entry_name = folded_directory_names.join(RelPath::unix(&filename).unwrap()); let entry_name = entry_name.display(path_style); events_tx.unbounded_send(Ok(SlashCommandEvent::StartSection { icon: IconName::Folder, label: entry_name.to_string().into(), metadata: None, }))?; events_tx.unbounded_send(Ok(SlashCommandEvent::Content( SlashCommandContent::Text { text: entry_name.to_string(), run_commands_in_text: false, }, )))?; directory_stack.push(entry.path.clone()); folded_directory_names = RelPath::empty().into(); folded_directory_path = None; } events_tx.unbounded_send(Ok(SlashCommandEvent::Content( SlashCommandContent::Text { text: "\n".into(), run_commands_in_text: false, }, )))?; } else if entry.is_file() { let Some(open_buffer_task) = project_handle .update(cx, |project, cx| { project.open_buffer((worktree_id, entry.path.clone()), cx) }) .ok() else { continue; }; if let Some(buffer) = open_buffer_task.await.log_err() { let mut output = SlashCommandOutput::default(); let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); append_buffer_to_output( &snapshot, Some(path_including_worktree_name.display(path_style).as_ref()), &mut output, ) .log_err(); let mut buffer_events = output.into_event_stream(); while let Some(event) = buffer_events.next().await { events_tx.unbounded_send(event)?; } } } } while directory_stack.pop().is_some() { events_tx.unbounded_send(Ok(SlashCommandEvent::EndSection))?; } } anyhow::Ok(()) }) .detach_and_log_err(cx); events_rx.boxed()} pub fn codeblock_fence_for_path( path: Option<&str>, row_range: Option<RangeInclusive<u32>>,) -> String { let mut text = String::new(); write!(text, "```").unwrap(); if let Some(path) = path { if let Some(extension) = Path::new(path).extension().and_then(|ext| ext.to_str()) { write!(text, "{} ", extension).unwrap(); } write!(text, "{path}").unwrap(); } else { write!(text, "untitled").unwrap(); } if let Some(row_range) = row_range { write!(text, ":{}-{}", row_range.start() + 1, row_range.end() + 1).unwrap(); } text.push('\n'); text} #[derive(Serialize, Deserialize)]pub struct FileCommandMetadata { pub path: String,} pub fn build_entry_output_section( range: Range<usize>, path: Option<&str>, is_directory: bool, line_range: Option<Range<u32>>,) -> SlashCommandOutputSection<usize> { let mut label = if let Some(path) = path { path.to_string() } else { "untitled".to_string() }; if let Some(line_range) = line_range { write!(label, ":{}-{}", line_range.start, line_range.end).unwrap(); } let icon = if is_directory { IconName::Folder } else { IconName::File }; SlashCommandOutputSection { range, icon, label: label.into(), metadata: if is_directory { None } else { path.and_then(|path| { serde_json::to_value(FileCommandMetadata { path: path.to_string(), }) .ok() }) }, }} pub fn append_buffer_to_output( buffer: &BufferSnapshot, path: Option<&str>, output: &mut SlashCommandOutput,) -> Result<()> { let prev_len = output.text.len(); let mut content = buffer.text(); LineEnding::normalize(&mut content); output.text.push_str(&codeblock_fence_for_path(path, None)); output.text.push_str(&content); if !output.text.ends_with('\n') { output.text.push('\n'); } output.text.push_str("```"); output.text.push('\n'); let section_ix = output.sections.len(); output.sections.insert( section_ix, build_entry_output_section(prev_len..output.text.len(), path, false, None), ); output.text.push('\n'); Ok(())} #[cfg(test)]mod test { use assistant_slash_command::SlashCommandOutput; use fs::FakeFs; use gpui::TestAppContext; use pretty_assertions::assert_eq;Lines 1–500 of 713. Load more
use anyhow::{Context as _, Result, anyhow}; use assistant_slash_command::{ AfterCompletion, ArgumentCompletion, SlashCommand, SlashCommandContent, SlashCommandEvent, SlashCommandOutput, SlashCommandOutputSection, SlashCommandResult, }; use futures::Stream; use futures::channel::mpsc; use fuzzy::PathMatch; use gpui::{App, Entity, Task, WeakEntity}; use language::{BufferSnapshot, CodeLabelBuilder, HighlightId, LineEnding, LspAdapterDelegate}; use project::{PathMatchCandidateSet, Project}; use serde::{Deserialize, Serialize}; use smol::stream::StreamExt; use std::{ fmt::Write, ops::{Range, RangeInclusive}, path::Path, sync::{Arc, atomic::AtomicBool}, }; use ui::prelude::*; use util::{ResultExt, rel_path::RelPath}; use workspace::Workspace; use worktree::ChildEntriesOptions; pub struct FileSlashCommand; impl FileSlashCommand { fn search_paths( &self, query: String, cancellation_flag: Arc<AtomicBool>, workspace: &Entity<Workspace>, cx: &mut App, ) -> Task<Vec<PathMatch>> { if query.is_empty() { let workspace = workspace.read(cx); let project = workspace.project().read(cx); let entries = workspace.recent_navigation_history(Some(10), cx); let entries = entries .into_iter() .map(|entries| (entries.0, false)) .chain(project.worktrees(cx).flat_map(|worktree| { let worktree = worktree.read(cx); let id = worktree.id(); let options = ChildEntriesOptions { include_files: true, include_dirs: true, include_ignored: false, }; let entries = worktree.child_entries_with_options(RelPath::empty(), options); entries.map(move |entry| { ( project::ProjectPath { worktree_id: id, path: entry.path.clone(), }, entry.kind.is_dir(), ) }) })) .collect::<Vec<_>>(); let path_prefix: Arc<RelPath> = RelPath::empty().into(); Task::ready( entries .into_iter() .filter_map(|(entry, is_dir)| { let worktree = project.worktree_for_id(entry.worktree_id, cx)?; let full_path = worktree.read(cx).root_name().join(&entry.path); Some(PathMatch { score: 0., positions: Vec::new(), worktree_id: entry.worktree_id.to_usize(), path: full_path, path_prefix: path_prefix.clone(), distance_to_relative_ancestor: 0, is_dir, }) }) .collect(), ) } else { let worktrees = workspace.read(cx).visible_worktrees(cx).collect::<Vec<_>>(); let candidate_sets = worktrees .into_iter() .map(|worktree| { let worktree = worktree.read(cx); PathMatchCandidateSet { snapshot: worktree.snapshot(), include_ignored: worktree .root_entry() .is_some_and(|entry| entry.is_ignored), include_root_name: true, candidates: project::Candidates::Entries, } }) .collect::<Vec<_>>(); let executor = cx.background_executor().clone(); cx.foreground_executor().spawn(async move { fuzzy::match_path_sets( candidate_sets.as_slice(), query.as_str(), &None, false, 100, &cancellation_flag, executor, ) .await }) } } } impl SlashCommand for FileSlashCommand { fn name(&self) -> String { "file".into() } fn description(&self) -> String { "Insert file and/or directory".into() } fn menu_text(&self) -> String { self.description() } fn requires_argument(&self) -> bool { true } fn icon(&self) -> IconName { IconName::File } fn complete_argument( self: Arc<Self>, arguments: &[String], cancellation_flag: Arc<AtomicBool>, workspace: Option<WeakEntity<Workspace>>, _: &mut Window, cx: &mut App, ) -> Task<Result<Vec<ArgumentCompletion>>> { let Some(workspace) = workspace.and_then(|workspace| workspace.upgrade()) else { return Task::ready(Err(anyhow!("workspace was dropped"))); }; let path_style = workspace.read(cx).path_style(cx); let paths = self.search_paths( arguments.last().cloned().unwrap_or_default(), cancellation_flag, &workspace, cx, ); let comment_id = cx.theme().syntax().highlight_id("comment").map(HighlightId); cx.background_spawn(async move { Ok(paths .await .into_iter() .filter_map(|path_match| { let text = path_match .path_prefix .join(&path_match.path) .display(path_style) .to_string(); let mut label = CodeLabelBuilder::default(); let file_name = path_match.path.file_name()?; let label_text = if path_match.is_dir { format!("{}/ ", file_name) } else { format!("{} ", file_name) }; label.push_str(label_text.as_str(), None); label.push_str(&text, comment_id); label.respan_filter_range(Some(file_name)); Some(ArgumentCompletion { label: label.build(), new_text: text, after_completion: AfterCompletion::Compose, replace_previous_arguments: false, }) }) .collect()) }) } fn run( self: Arc<Self>, arguments: &[String], _context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>], _context_buffer: BufferSnapshot, workspace: WeakEntity<Workspace>, _delegate: Option<Arc<dyn LspAdapterDelegate>>, _: &mut Window, cx: &mut App, ) -> Task<SlashCommandResult> { let Some(workspace) = workspace.upgrade() else { return Task::ready(Err(anyhow!("workspace was dropped"))); }; if arguments.is_empty() { return Task::ready(Err(anyhow!("missing path"))); }; Task::ready(Ok(collect_files( workspace.read(cx).project().clone(), arguments, cx, ) .boxed())) } } fn collect_files( project: Entity<Project>, glob_inputs: &[String], cx: &mut App, ) -> impl Stream<Item = Result<SlashCommandEvent>> + use<> { let Ok(matchers) = glob_inputs .iter() .map(|glob_input| { util::paths::PathMatcher::new(&[glob_input.to_owned()], project.read(cx).path_style(cx)) .with_context(|| format!("invalid path {glob_input}")) }) .collect::<anyhow::Result<Vec<util::paths::PathMatcher>>>() else { return futures::stream::once(async { anyhow::bail!("invalid path"); }) .boxed(); }; let project_handle = project.downgrade(); let snapshots = project .read(cx) .worktrees(cx) .map(|worktree| worktree.read(cx).snapshot()) .collect::<Vec<_>>(); let (events_tx, events_rx) = mpsc::unbounded(); cx.spawn(async move |cx| { for snapshot in snapshots { let worktree_id = snapshot.id(); let path_style = snapshot.path_style(); let mut directory_stack: Vec<Arc<RelPath>> = Vec::new(); let mut folded_directory_path: Option<Arc<RelPath>> = None; let mut folded_directory_names: Arc<RelPath> = RelPath::empty().into(); let mut is_top_level_directory = true; for entry in snapshot.entries(false, 0) { let path_including_worktree_name = snapshot.root_name().join(&entry.path); if !matchers .iter() .any(|matcher| matcher.is_match(&path_including_worktree_name)) { continue; } while let Some(dir) = directory_stack.last() { if entry.path.starts_with(dir) { break; } directory_stack.pop().unwrap(); events_tx.unbounded_send(Ok(SlashCommandEvent::EndSection))?; events_tx.unbounded_send(Ok(SlashCommandEvent::Content( SlashCommandContent::Text { text: "\n".into(), run_commands_in_text: false, }, )))?; } if let Some(folded_path) = &folded_directory_path { if !entry.path.starts_with(folded_path) { folded_directory_names = RelPath::empty().into(); folded_directory_path = None; if directory_stack.is_empty() { is_top_level_directory = true; } } } let filename = entry.path.file_name().unwrap_or_default().to_string(); if entry.is_dir() { // Auto-fold directories that contain no files let mut child_entries = snapshot.child_entries(&entry.path); if let Some(child) = child_entries.next() { if child_entries.next().is_none() && child.kind.is_dir() { if is_top_level_directory { is_top_level_directory = false; folded_directory_names = folded_directory_names.join(&path_including_worktree_name); } else { folded_directory_names = folded_directory_names.join(RelPath::unix(&filename).unwrap()); } folded_directory_path = Some(entry.path.clone()); continue; } } else { // Skip empty directories folded_directory_names = RelPath::empty().into(); folded_directory_path = None; continue; } // Render the directory (either folded or normal) if folded_directory_names.is_empty() { let label = if is_top_level_directory { is_top_level_directory = false; path_including_worktree_name.display(path_style).to_string() } else { filename }; events_tx.unbounded_send(Ok(SlashCommandEvent::StartSection { icon: IconName::Folder, label: label.clone().into(), metadata: None, }))?; events_tx.unbounded_send(Ok(SlashCommandEvent::Content( SlashCommandContent::Text { text: label.to_string(), run_commands_in_text: false, }, )))?; directory_stack.push(entry.path.clone()); } else { let entry_name = folded_directory_names.join(RelPath::unix(&filename).unwrap()); let entry_name = entry_name.display(path_style); events_tx.unbounded_send(Ok(SlashCommandEvent::StartSection { icon: IconName::Folder, label: entry_name.to_string().into(), metadata: None, }))?; events_tx.unbounded_send(Ok(SlashCommandEvent::Content( SlashCommandContent::Text { text: entry_name.to_string(), run_commands_in_text: false, }, )))?; directory_stack.push(entry.path.clone()); folded_directory_names = RelPath::empty().into(); folded_directory_path = None; } events_tx.unbounded_send(Ok(SlashCommandEvent::Content( SlashCommandContent::Text { text: "\n".into(), run_commands_in_text: false, }, )))?; } else if entry.is_file() { let Some(open_buffer_task) = project_handle .update(cx, |project, cx| { project.open_buffer((worktree_id, entry.path.clone()), cx) }) .ok() else { continue; }; if let Some(buffer) = open_buffer_task.await.log_err() { let mut output = SlashCommandOutput::default(); let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); append_buffer_to_output( &snapshot, Some(path_including_worktree_name.display(path_style).as_ref()), &mut output, ) .log_err(); let mut buffer_events = output.into_event_stream(); while let Some(event) = buffer_events.next().await { events_tx.unbounded_send(event)?; } } } } while directory_stack.pop().is_some() { events_tx.unbounded_send(Ok(SlashCommandEvent::EndSection))?; } } anyhow::Ok(()) }) .detach_and_log_err(cx); events_rx.boxed() } pub fn codeblock_fence_for_path( path: Option<&str>, row_range: Option<RangeInclusive<u32>>, ) -> String { let mut text = String::new(); write!(text, "```").unwrap(); if let Some(path) = path { if let Some(extension) = Path::new(path).extension().and_then(|ext| ext.to_str()) { write!(text, "{} ", extension).unwrap(); } write!(text, "{path}").unwrap(); } else { write!(text, "untitled").unwrap(); } if let Some(row_range) = row_range { write!(text, ":{}-{}", row_range.start() + 1, row_range.end() + 1).unwrap(); } text.push('\n'); text } #[derive(Serialize, Deserialize)] pub struct FileCommandMetadata { pub path: String, } pub fn build_entry_output_section( range: Range<usize>, path: Option<&str>, is_directory: bool, line_range: Option<Range<u32>>, ) -> SlashCommandOutputSection<usize> { let mut label = if let Some(path) = path { path.to_string() } else { "untitled".to_string() }; if let Some(line_range) = line_range { write!(label, ":{}-{}", line_range.start, line_range.end).unwrap(); } let icon = if is_directory { IconName::Folder } else { IconName::File }; SlashCommandOutputSection { range, icon, label: label.into(), metadata: if is_directory { None } else { path.and_then(|path| { serde_json::to_value(FileCommandMetadata { path: path.to_string(), }) .ok() }) }, } } pub fn append_buffer_to_output( buffer: &BufferSnapshot, path: Option<&str>, output: &mut SlashCommandOutput, ) -> Result<()> { let prev_len = output.text.len(); let mut content = buffer.text(); LineEnding::normalize(&mut content); output.text.push_str(&codeblock_fence_for_path(path, None)); output.text.push_str(&content); if !output.text.ends_with('\n') { output.text.push('\n'); } output.text.push_str("```"); output.text.push('\n'); let section_ix = output.sections.len(); output.sections.insert( section_ix, build_entry_output_section(prev_len..output.text.len(), path, false, None), ); output.text.push('\n'); Ok(()) } #[cfg(test)] mod test { use assistant_slash_command::SlashCommandOutput; use fs::FakeFs; use gpui::TestAppContext; use pretty_assertions::assert_eq;