Fix more bugs in files (#16241)

Fixes:
- [x] an issue where directories would only match by prefix, causing
both a directory and a file to be matched if in the same directory
- [x] An issue where you could not continue a file completion when
selecting a directory, as `tab` on a file would always run the command.
This effectively disabled directory sub queries.
- [x] Inconsistent rendering of files and directories in the slash
command

Release Notes:

- N/A

---------

Co-authored-by: max <max@zed.dev>
This commit is contained in:
Mikayla Maki 2024-08-16 10:09:38 -07:00 committed by GitHub
parent a3a6ebcf31
commit 455850505f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 415 additions and 47 deletions

1
Cargo.lock generated
View File

@ -367,6 +367,7 @@ dependencies = [
"fs",
"futures 0.3.30",
"fuzzy",
"globset",
"gpui",
"handlebars",
"heed",

View File

@ -39,6 +39,7 @@ feature_flags.workspace = true
fs.workspace = true
futures.workspace = true
fuzzy.workspace = true
globset.workspace = true
gpui.workspace = true
handlebars.workspace = true
heed.workspace = true

View File

@ -1,5 +1,6 @@
use crate::assistant_panel::ContextEditor;
use anyhow::Result;
use assistant_slash_command::AfterCompletion;
pub use assistant_slash_command::{SlashCommand, SlashCommandOutput, SlashCommandRegistry};
use editor::{CompletionProvider, Editor};
use fuzzy::{match_strings, StringMatchCandidate};
@ -197,7 +198,9 @@ impl SlashCommandCompletionProvider {
let command_range = command_range.clone();
let command_name = command_name.clone();
move |intent: CompletionIntent, cx: &mut WindowContext| {
if new_argument.run_command || intent.is_complete() {
if new_argument.after_completion.run()
|| intent.is_complete()
{
editor
.update(cx, |editor, cx| {
editor.run_command(
@ -212,14 +215,14 @@ impl SlashCommandCompletionProvider {
.ok();
false
} else {
!new_argument.run_command
!new_argument.after_completion.run()
}
}
}) as Arc<_>
});
let mut new_text = new_argument.new_text.clone();
if !new_argument.run_command {
if new_argument.after_completion == AfterCompletion::Continue {
new_text.push(' ');
}

View File

@ -153,7 +153,7 @@ impl SlashCommand for DiagnosticsSlashCommand {
.map(|completion| ArgumentCompletion {
label: completion.clone().into(),
new_text: completion,
run_command: true,
after_completion: assistant_slash_command::AfterCompletion::Run,
replace_previous_arguments: false,
})
.collect())

View File

@ -181,7 +181,7 @@ impl SlashCommand for DocsSlashCommand {
.map(|item| ArgumentCompletion {
label: item.clone().into(),
new_text: item.to_string(),
run_command: true,
after_completion: assistant_slash_command::AfterCompletion::Run,
replace_previous_arguments: false,
})
.collect()
@ -194,7 +194,7 @@ impl SlashCommand for DocsSlashCommand {
return Ok(vec![ArgumentCompletion {
label: "No available docs providers.".into(),
new_text: String::new(),
run_command: false,
after_completion: false.into(),
replace_previous_arguments: false,
}]);
}
@ -204,7 +204,7 @@ impl SlashCommand for DocsSlashCommand {
.map(|provider| ArgumentCompletion {
label: provider.to_string().into(),
new_text: provider.to_string(),
run_command: false,
after_completion: false.into(),
replace_previous_arguments: false,
})
.collect())
@ -236,7 +236,7 @@ impl SlashCommand for DocsSlashCommand {
.map(|package_name| ArgumentCompletion {
label: format!("{package_name} (unindexed)").into(),
new_text: format!("{package_name}"),
run_command: true,
after_completion: true.into(),
replace_previous_arguments: false,
})
.collect::<Vec<_>>();
@ -250,7 +250,7 @@ impl SlashCommand for DocsSlashCommand {
)
.into(),
new_text: provider.to_string(),
run_command: false,
after_completion: false.into(),
replace_previous_arguments: false,
}]);
}

View File

@ -1,6 +1,6 @@
use super::{diagnostics_command::write_single_file_diagnostics, SlashCommand, SlashCommandOutput};
use anyhow::{anyhow, Context as _, Result};
use assistant_slash_command::{ArgumentCompletion, SlashCommandOutputSection};
use assistant_slash_command::{AfterCompletion, ArgumentCompletion, SlashCommandOutputSection};
use fuzzy::PathMatch;
use gpui::{AppContext, Model, Task, View, WeakView};
use language::{BufferSnapshot, CodeLabel, HighlightId, LineEnding, LspAdapterDelegate};
@ -12,7 +12,7 @@ use std::{
sync::{atomic::AtomicBool, Arc},
};
use ui::prelude::*;
use util::{paths::PathMatcher, ResultExt};
use util::ResultExt;
use workspace::Workspace;
pub(crate) struct FileSlashCommand;
@ -164,7 +164,11 @@ impl SlashCommand for FileSlashCommand {
Some(ArgumentCompletion {
label,
new_text: text,
run_command: true,
after_completion: if path_match.is_dir {
AfterCompletion::Compose
} else {
AfterCompletion::Run
},
replace_previous_arguments: false,
})
})
@ -190,16 +194,17 @@ impl SlashCommand for FileSlashCommand {
let task = collect_files(workspace.read(cx).project().clone(), arguments, cx);
cx.foreground_executor().spawn(async move {
let (text, ranges) = task.await?;
let output = task.await?;
Ok(SlashCommandOutput {
text,
sections: ranges
text: output.completion_text,
sections: output
.files
.into_iter()
.map(|(range, path, entry_type)| {
.map(|file| {
build_entry_output_section(
range,
Some(&path),
entry_type == EntryType::Directory,
file.range_in_text,
Some(&file.path),
file.entry_type == EntryType::Directory,
None,
)
})
@ -210,24 +215,37 @@ impl SlashCommand for FileSlashCommand {
}
}
#[derive(Clone, Copy, PartialEq)]
#[derive(Clone, Copy, PartialEq, Debug)]
enum EntryType {
File,
Directory,
}
#[derive(Clone, PartialEq, Debug)]
struct FileCommandOutput {
completion_text: String,
files: Vec<OutputFile>,
}
#[derive(Clone, PartialEq, Debug)]
struct OutputFile {
range_in_text: Range<usize>,
path: PathBuf,
entry_type: EntryType,
}
fn collect_files(
project: Model<Project>,
glob_inputs: &[String],
cx: &mut AppContext,
) -> Task<Result<(String, Vec<(Range<usize>, PathBuf, EntryType)>)>> {
) -> Task<Result<FileCommandOutput>> {
let Ok(matchers) = glob_inputs
.into_iter()
.map(|glob_input| {
PathMatcher::new(&[glob_input.to_owned()])
custom_path_matcher::PathMatcher::new(&[glob_input.to_owned()])
.with_context(|| format!("invalid path {glob_input}"))
})
.collect::<anyhow::Result<Vec<PathMatcher>>>()
.collect::<anyhow::Result<Vec<custom_path_matcher::PathMatcher>>>()
else {
return Task::ready(Err(anyhow!("invalid path")));
};
@ -238,6 +256,7 @@ fn collect_files(
.worktrees(cx)
.map(|worktree| worktree.read(cx).snapshot())
.collect::<Vec<_>>();
cx.spawn(|mut cx| async move {
let mut text = String::new();
let mut ranges = Vec::new();
@ -246,10 +265,12 @@ fn collect_files(
let mut directory_stack: Vec<(Arc<Path>, String, usize)> = Vec::new();
let mut folded_directory_names_stack = Vec::new();
let mut is_top_level_directory = true;
for entry in snapshot.entries(false, 0) {
let mut path_including_worktree_name = PathBuf::new();
path_including_worktree_name.push(snapshot.root_name());
path_including_worktree_name.push(&entry.path);
if !matchers
.iter()
.any(|matcher| matcher.is_match(&path_including_worktree_name))
@ -262,11 +283,11 @@ fn collect_files(
break;
}
let (_, entry_name, start) = directory_stack.pop().unwrap();
ranges.push((
start..text.len().saturating_sub(1),
PathBuf::from(entry_name),
EntryType::Directory,
));
ranges.push(OutputFile {
range_in_text: start..text.len().saturating_sub(1),
path: PathBuf::from(entry_name),
entry_type: EntryType::Directory,
});
}
let filename = entry
@ -339,24 +360,39 @@ fn collect_files(
) {
text.pop();
}
ranges.push((
prev_len..text.len(),
path_including_worktree_name,
EntryType::File,
));
ranges.push(OutputFile {
range_in_text: prev_len..text.len(),
path: path_including_worktree_name,
entry_type: EntryType::File,
});
text.push('\n');
}
}
}
while let Some((dir, _, start)) = directory_stack.pop() {
let mut root_path = PathBuf::new();
root_path.push(snapshot.root_name());
root_path.push(&dir);
ranges.push((start..text.len(), root_path, EntryType::Directory));
while let Some((dir, entry, start)) = directory_stack.pop() {
if directory_stack.is_empty() {
let mut root_path = PathBuf::new();
root_path.push(snapshot.root_name());
root_path.push(&dir);
ranges.push(OutputFile {
range_in_text: start..text.len(),
path: root_path,
entry_type: EntryType::Directory,
});
} else {
ranges.push(OutputFile {
range_in_text: start..text.len(),
path: PathBuf::from(entry.as_str()),
entry_type: EntryType::Directory,
});
}
}
}
Ok((text, ranges))
Ok(FileCommandOutput {
completion_text: text,
files: ranges,
})
})
}
@ -424,3 +460,300 @@ pub fn build_entry_output_section(
label: label.into(),
}
}
/// This contains a small fork of the util::paths::PathMatcher, that is stricter about the prefix
/// check. Only subpaths pass the prefix check, rather than any prefix.
mod custom_path_matcher {
use std::{fmt::Debug as _, path::Path};
use globset::{Glob, GlobSet, GlobSetBuilder};
#[derive(Clone, Debug, Default)]
pub struct PathMatcher {
sources: Vec<String>,
sources_with_trailing_slash: Vec<String>,
glob: GlobSet,
}
impl std::fmt::Display for PathMatcher {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.sources.fmt(f)
}
}
impl PartialEq for PathMatcher {
fn eq(&self, other: &Self) -> bool {
self.sources.eq(&other.sources)
}
}
impl Eq for PathMatcher {}
impl PathMatcher {
pub fn new(globs: &[String]) -> Result<Self, globset::Error> {
let globs = globs
.into_iter()
.map(|glob| Glob::new(&glob))
.collect::<Result<Vec<_>, _>>()?;
let sources = globs.iter().map(|glob| glob.glob().to_owned()).collect();
let sources_with_trailing_slash = globs
.iter()
.map(|glob| glob.glob().to_string() + std::path::MAIN_SEPARATOR_STR)
.collect();
let mut glob_builder = GlobSetBuilder::new();
for single_glob in globs {
glob_builder.add(single_glob);
}
let glob = glob_builder.build()?;
Ok(PathMatcher {
glob,
sources,
sources_with_trailing_slash,
})
}
pub fn sources(&self) -> &[String] {
&self.sources
}
pub fn is_match<P: AsRef<Path>>(&self, other: P) -> bool {
let other_path = other.as_ref();
self.sources
.iter()
.zip(self.sources_with_trailing_slash.iter())
.any(|(source, with_slash)| {
let as_bytes = other_path.as_os_str().as_encoded_bytes();
let with_slash = if source.ends_with("/") {
source.as_bytes()
} else {
with_slash.as_bytes()
};
as_bytes.starts_with(with_slash) || as_bytes.ends_with(source.as_bytes())
})
|| self.glob.is_match(other_path)
|| self.check_with_end_separator(other_path)
}
fn check_with_end_separator(&self, path: &Path) -> bool {
let path_str = path.to_string_lossy();
let separator = std::path::MAIN_SEPARATOR_STR;
if path_str.ends_with(separator) {
return false;
} else {
self.glob.is_match(path_str.to_string() + separator)
}
}
}
}
#[cfg(test)]
mod test {
use fs::FakeFs;
use gpui::TestAppContext;
use project::Project;
use serde_json::json;
use settings::SettingsStore;
use crate::slash_command::file_command::collect_files;
pub fn init_test(cx: &mut gpui::TestAppContext) {
if std::env::var("RUST_LOG").is_ok() {
env_logger::try_init().ok();
}
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
// release_channel::init(SemanticVersion::default(), cx);
language::init(cx);
Project::init_settings(cx);
});
}
#[gpui::test]
async fn test_file_exact_matching(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/root",
json!({
"dir": {
"subdir": {
"file_0": "0"
},
"file_1": "1",
"file_2": "2",
"file_3": "3",
},
"dir.rs": "4"
}),
)
.await;
let project = Project::test(fs, ["/root".as_ref()], cx).await;
let result_1 = cx
.update(|cx| collect_files(project.clone(), &["root/dir".to_string()], cx))
.await
.unwrap();
assert!(result_1.completion_text.starts_with("root/dir"));
// 4 files + 2 directories
assert_eq!(6, result_1.files.len());
let result_2 = cx
.update(|cx| collect_files(project.clone(), &["root/dir/".to_string()], cx))
.await
.unwrap();
assert_eq!(result_1, result_2);
let result = cx
.update(|cx| collect_files(project.clone(), &["root/dir*".to_string()], cx))
.await
.unwrap();
assert!(result.completion_text.starts_with("root/dir"));
// 5 files + 2 directories
assert_eq!(7, result.files.len());
// Ensure that the project lasts until after the last await
drop(project);
}
#[gpui::test]
async fn test_file_sub_directory_rendering(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/zed",
json!({
"assets": {
"dir1": {
".gitkeep": ""
},
"dir2": {
".gitkeep": ""
},
"themes": {
"ayu": {
"LICENSE": "1",
},
"andromeda": {
"LICENSE": "2",
},
"summercamp": {
"LICENSE": "3",
},
},
},
}),
)
.await;
let project = Project::test(fs, ["/zed".as_ref()], cx).await;
let result = cx
.update(|cx| collect_files(project.clone(), &["zed/assets/themes".to_string()], cx))
.await
.unwrap();
// Sanity check
assert!(result.completion_text.starts_with("zed/assets/themes\n"));
assert_eq!(7, result.files.len());
// Ensure that full file paths are included in the real output
assert!(result
.completion_text
.contains("zed/assets/themes/andromeda/LICENSE"));
assert!(result
.completion_text
.contains("zed/assets/themes/ayu/LICENSE"));
assert!(result
.completion_text
.contains("zed/assets/themes/summercamp/LICENSE"));
assert_eq!("summercamp", result.files[5].path.to_string_lossy());
// Ensure that things are in descending order, with properly relativized paths
assert_eq!(
"zed/assets/themes/andromeda/LICENSE",
result.files[0].path.to_string_lossy()
);
assert_eq!("andromeda", result.files[1].path.to_string_lossy());
assert_eq!(
"zed/assets/themes/ayu/LICENSE",
result.files[2].path.to_string_lossy()
);
assert_eq!("ayu", result.files[3].path.to_string_lossy());
assert_eq!(
"zed/assets/themes/summercamp/LICENSE",
result.files[4].path.to_string_lossy()
);
// Ensure that the project lasts until after the last await
drop(project);
}
#[gpui::test]
async fn test_file_deep_sub_directory_rendering(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/zed",
json!({
"assets": {
"themes": {
"LICENSE": "1",
"summercamp": {
"LICENSE": "1",
"subdir": {
"LICENSE": "1",
"subsubdir": {
"LICENSE": "3",
}
}
},
},
},
}),
)
.await;
let project = Project::test(fs, ["/zed".as_ref()], cx).await;
let result = cx
.update(|cx| collect_files(project.clone(), &["zed/assets/themes".to_string()], cx))
.await
.unwrap();
assert!(result.completion_text.starts_with("zed/assets/themes\n"));
assert_eq!(
"zed/assets/themes/LICENSE",
result.files[0].path.to_string_lossy()
);
assert_eq!(
"zed/assets/themes/summercamp/LICENSE",
result.files[1].path.to_string_lossy()
);
assert_eq!(
"zed/assets/themes/summercamp/subdir/LICENSE",
result.files[2].path.to_string_lossy()
);
assert_eq!(
"zed/assets/themes/summercamp/subdir/subsubdir/LICENSE",
result.files[3].path.to_string_lossy()
);
assert_eq!("subsubdir", result.files[4].path.to_string_lossy());
assert_eq!("subdir", result.files[5].path.to_string_lossy());
assert_eq!("summercamp", result.files[6].path.to_string_lossy());
assert_eq!("zed/assets/themes", result.files[7].path.to_string_lossy());
// Ensure that the project lasts until after the last await
drop(project);
}
}

View File

@ -45,7 +45,7 @@ impl SlashCommand for PromptSlashCommand {
Some(ArgumentCompletion {
label: prompt_title.clone().into(),
new_text: prompt_title,
run_command: true,
after_completion: true.into(),
replace_previous_arguments: true,
})
})

View File

@ -94,7 +94,7 @@ impl SlashCommand for TabSlashCommand {
label: path_string.clone().into(),
new_text: path_string,
replace_previous_arguments: false,
run_command,
after_completion: run_command.into(),
})
});
@ -106,7 +106,7 @@ impl SlashCommand for TabSlashCommand {
label: path_string.clone().into(),
new_text: path_string,
replace_previous_arguments: false,
run_command,
after_completion: run_command.into(),
});
Ok(active_item_completion
@ -115,7 +115,7 @@ impl SlashCommand for TabSlashCommand {
label: ALL_TABS_COMPLETION_ITEM.into(),
new_text: ALL_TABS_COMPLETION_ITEM.to_owned(),
replace_previous_arguments: false,
run_command: true,
after_completion: true.into(),
}))
.chain(tab_completion_items)
.collect())

View File

@ -15,6 +15,35 @@ pub fn init(cx: &mut AppContext) {
SlashCommandRegistry::default_global(cx);
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AfterCompletion {
/// Run the command
Run,
/// Continue composing the current argument, doesn't add a space
Compose,
/// Continue the command composition, adds a space
Continue,
}
impl From<bool> for AfterCompletion {
fn from(value: bool) -> Self {
if value {
AfterCompletion::Run
} else {
AfterCompletion::Continue
}
}
}
impl AfterCompletion {
pub fn run(&self) -> bool {
match self {
AfterCompletion::Run => true,
AfterCompletion::Compose | AfterCompletion::Continue => false,
}
}
}
#[derive(Debug)]
pub struct ArgumentCompletion {
/// The label to display for this completion.
@ -22,7 +51,7 @@ pub struct ArgumentCompletion {
/// The new text that should be inserted into the command when this completion is accepted.
pub new_text: String,
/// Whether the command should be run when accepting this completion.
pub run_command: bool,
pub after_completion: AfterCompletion,
/// Whether to replace the all arguments, or whether to treat this as an independent argument.
pub replace_previous_arguments: bool,
}

View File

@ -67,7 +67,7 @@ impl SlashCommand for ExtensionSlashCommand {
label: completion.label.into(),
new_text: completion.new_text,
replace_previous_arguments: false,
run_command: completion.run_command,
after_completion: completion.run_command.into(),
})
.collect(),
)

View File

@ -12,6 +12,7 @@ pub mod worktree_store;
#[cfg(test)]
mod project_tests;
pub mod search_history;
mod yarn;

View File

@ -5204,7 +5204,7 @@ async fn search(
.collect())
}
fn init_test(cx: &mut gpui::TestAppContext) {
pub fn init_test(cx: &mut gpui::TestAppContext) {
if std::env::var("RUST_LOG").is_ok() {
env_logger::try_init().ok();
}

View File

@ -263,7 +263,7 @@ impl PathMatcher {
let path_str = path.to_string_lossy();
let separator = std::path::MAIN_SEPARATOR_STR;
if path_str.ends_with(separator) {
self.glob.is_match(path)
return false;
} else {
self.glob.is_match(path_str.to_string() + separator)
}