Rebrand to Galaxy, major improvements to Bedrock support, still needs some TLC though
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::fs::DirEntry;
|
||||
|
||||
use itertools::{iproduct, Itertools};
|
||||
use lazy_static::lazy_static;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use typed_path::{TypedPath, TypedPathBuf};
|
||||
use warp_command_signatures::{IconType, PathSuggestionType};
|
||||
use galaxy_util::path::HOME_DIR_ENV_VAR_PREFIX;
|
||||
|
||||
use crate::completer::suggest::Priority;
|
||||
use crate::completer::{
|
||||
context::PathCompletionContext,
|
||||
matchers::MatchStrategy,
|
||||
suggest::{MatchedSuggestion, Suggestion, SuggestionType},
|
||||
};
|
||||
use crate::parsers::ParsedToken;
|
||||
|
||||
/// TODO(CORE-3074): This only applies to Unix.
|
||||
const ROOT_DIR_STR: &str = "/";
|
||||
|
||||
lazy_static! {
|
||||
pub static ref CURR_DIRECTORY_ENTRY: EngineDirEntry = EngineDirEntry {
|
||||
file_name: ".".to_owned(),
|
||||
file_type: EngineFileType::Directory,
|
||||
};
|
||||
pub static ref PARENT_DIRECTORY_ENTRY: EngineDirEntry = EngineDirEntry {
|
||||
file_name: "..".to_owned(),
|
||||
file_type: EngineFileType::Directory,
|
||||
};
|
||||
}
|
||||
|
||||
/// A `DirEntry` for the completions engine that abstracts whether the contents
|
||||
/// come from a remote or local filesystem.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
|
||||
pub struct EngineDirEntry {
|
||||
pub file_name: String,
|
||||
pub file_type: EngineFileType,
|
||||
}
|
||||
|
||||
impl EngineDirEntry {
|
||||
pub fn is_dir(&self) -> bool {
|
||||
self.file_type == EngineFileType::Directory
|
||||
}
|
||||
|
||||
pub fn file_name(&self) -> &str {
|
||||
self.file_name.as_str()
|
||||
}
|
||||
|
||||
pub fn is_hidden(&self) -> bool {
|
||||
self.file_name.starts_with('.')
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<DirEntry> for EngineDirEntry {
|
||||
type Error = std::io::Error;
|
||||
|
||||
fn try_from(value: DirEntry) -> Result<Self, Self::Error> {
|
||||
let file_type = value.file_type()?;
|
||||
let is_dir = if file_type.is_dir() {
|
||||
true
|
||||
} else if file_type.is_symlink() {
|
||||
// If the file is a symlink, follow the symlink and check if the target is a directory.
|
||||
value
|
||||
.path()
|
||||
.metadata()
|
||||
.map(|metadata| metadata.is_dir())
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let file_type = if is_dir {
|
||||
EngineFileType::Directory
|
||||
} else {
|
||||
EngineFileType::File
|
||||
};
|
||||
Ok(Self {
|
||||
file_name: value.file_name().to_string_lossy().to_string(),
|
||||
file_type,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
|
||||
pub enum EngineFileType {
|
||||
Directory,
|
||||
File,
|
||||
}
|
||||
|
||||
impl Display for EngineFileType {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}",
|
||||
match self {
|
||||
EngineFileType::Directory => "Directory",
|
||||
EngineFileType::File => "File",
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<EngineFileType> for PathSuggestionType {
|
||||
fn from(path_type: EngineFileType) -> Self {
|
||||
match path_type {
|
||||
EngineFileType::Directory => Self::Folder,
|
||||
EngineFileType::File => Self::File,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the sorted directories relative to the provided path and filter.
|
||||
///
|
||||
/// Note we are returning a Vector instead of iterator here because Rust currently doesn't support
|
||||
/// returning opaque types (impl) in traits. This should have minimum impact on the memory allocation
|
||||
/// since we are already calling `sort_by` before collecting which allocates memory.
|
||||
pub(crate) async fn sorted_directories_relative_to(
|
||||
path: &ParsedToken,
|
||||
matcher: MatchStrategy,
|
||||
ctx: &dyn PathCompletionContext,
|
||||
) -> Vec<MatchedSuggestion> {
|
||||
list_directory_contents(path, matcher, ctx)
|
||||
.await
|
||||
.into_iter()
|
||||
.filter(|path_suggestion| {
|
||||
path_suggestion
|
||||
.suggestion
|
||||
.file_type
|
||||
.is_some_and(|file_type| file_type == EngineFileType::Directory)
|
||||
})
|
||||
.sorted_by(|suggestion_a, suggestion_b| {
|
||||
suggestion_a
|
||||
.suggestion
|
||||
.cmp_by_display(&suggestion_b.suggestion)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn sorted_paths_relative_to(
|
||||
path: &ParsedToken,
|
||||
matcher: MatchStrategy,
|
||||
ctx: &dyn PathCompletionContext,
|
||||
) -> Vec<MatchedSuggestion> {
|
||||
list_directory_contents(path, matcher, ctx)
|
||||
.await
|
||||
.into_iter()
|
||||
.sorted_by(|suggestion_a, suggestion_b| {
|
||||
suggestion_a
|
||||
.suggestion
|
||||
.cmp_by_display(&suggestion_b.suggestion)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Lists all directory contents within the directory identified by the parent directory of
|
||||
/// `relative_to`.
|
||||
/// If `relative_to` is `foo/bar/`, directory contents beanth `bar/` will be returned.
|
||||
/// If `relative_to` is `foo/bar/a`, directory contents relative to `/bar` are returned, while
|
||||
/// ensuring they match the trailing `a`.
|
||||
/// `relative_to` can contain backslash escaped tildes so we can distinguish between tildes that
|
||||
/// should be expanded into the home directory and a literal tilde.
|
||||
/// NOTE: The resulting suggestion replacements are shell-escaped; display values are unescaped.
|
||||
async fn list_directory_contents(
|
||||
relative_to: &ParsedToken,
|
||||
matcher: MatchStrategy,
|
||||
ctx: &dyn PathCompletionContext,
|
||||
) -> Vec<MatchedSuggestion> {
|
||||
let home_dir = ctx.home_directory();
|
||||
|
||||
let path_separators = ctx.path_separators();
|
||||
let split_path = SplitPath::new(
|
||||
ctx.pwd(),
|
||||
relative_to.as_str(),
|
||||
home_dir,
|
||||
path_separators.all,
|
||||
);
|
||||
|
||||
let dir_entries = ctx
|
||||
.list_directory_entries(split_path.directory_absolute_path.clone())
|
||||
.await;
|
||||
|
||||
let root_dir_entry =
|
||||
(split_path.directory_absolute_path.to_str() == Some(ROOT_DIR_STR)).then(|| {
|
||||
EngineDirEntry {
|
||||
file_name: ROOT_DIR_STR.to_owned(),
|
||||
file_type: EngineFileType::Directory,
|
||||
}
|
||||
});
|
||||
|
||||
dir_entries
|
||||
.iter()
|
||||
.chain(root_dir_entry.iter())
|
||||
.chain([&*CURR_DIRECTORY_ENTRY, &*PARENT_DIRECTORY_ENTRY])
|
||||
.filter_map(move |entry| {
|
||||
let mut file_name = entry.file_name().to_string();
|
||||
|
||||
let match_type = matcher.get_match_type(&split_path.file_name, file_name.as_str())?;
|
||||
|
||||
let path = if entry.file_name() == ROOT_DIR_STR {
|
||||
ROOT_DIR_STR.to_owned()
|
||||
} else {
|
||||
if entry.is_dir() {
|
||||
file_name.push(path_separators.main);
|
||||
}
|
||||
// We use `shell_escape()` instead of `escape()` on the relative path name to allow
|
||||
// home directory expansion if needed.
|
||||
format!(
|
||||
"{}{}",
|
||||
if split_path.directory_relative_path_name.is_empty() {
|
||||
"".to_owned()
|
||||
} else {
|
||||
// `directory_relative_path_name` may have escaped tildes which we use to
|
||||
// distinguish between a tilde representing the home directory and a literal
|
||||
// tilde. `shell_escape()` will doubly escape an escaped tilde which is
|
||||
// incorrect so we correct that behavior here.
|
||||
ctx.shell_family()
|
||||
.shell_escape(split_path.directory_relative_path_name.as_str())
|
||||
.replace(r"\\\~", r"\~")
|
||||
},
|
||||
// Home directory expansion is never needed on file names, so we use the
|
||||
// standard `escape()`.
|
||||
ctx.shell_family().escape(file_name.as_str())
|
||||
)
|
||||
};
|
||||
|
||||
(!entry.is_hidden() || split_path.file_name.starts_with('.')).then(|| {
|
||||
let mut suggestion = Suggestion::new(
|
||||
file_name.as_str(),
|
||||
path,
|
||||
Some(entry.file_type.to_string()),
|
||||
SuggestionType::Argument,
|
||||
Priority::default(),
|
||||
);
|
||||
suggestion.file_type = Some(entry.file_type);
|
||||
suggestion.override_icon = Some(match entry.file_type {
|
||||
EngineFileType::File => IconType::File,
|
||||
EngineFileType::Directory => IconType::Folder,
|
||||
});
|
||||
MatchedSuggestion {
|
||||
suggestion,
|
||||
match_type,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect_vec()
|
||||
}
|
||||
|
||||
/// A path split into the parent path (the entire piece before the last separator) and the
|
||||
/// file_name (the piece after the last separator).
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct SplitPath {
|
||||
/// The absolute path to the directory containing the file named `file_name`.
|
||||
directory_absolute_path: TypedPathBuf,
|
||||
|
||||
/// The path to the directory containing the file named `file_name`, relative to the current
|
||||
/// working directory. This is may contain unexpanded `~` or `$HOME`.
|
||||
directory_relative_path_name: String,
|
||||
|
||||
/// The name of the `file`.
|
||||
file_name: String,
|
||||
}
|
||||
|
||||
impl SplitPath {
|
||||
/// Returns a `SplitPath` based on the given path values.
|
||||
///
|
||||
/// `current_directory` is the directory to which `relative_path` is relative.
|
||||
/// `relative_path` may contain '~' or '$HOME'. If `relative_path` begins with one of those
|
||||
/// strings, we expand that part of the path to the given `home_directory` value, if it is
|
||||
/// `Some()`. Note that `relative_path` comes directly from a user-specified path token. This
|
||||
/// may contain escaped tildes (for example if the user is completing on a path that contains
|
||||
/// literal tildes), which need to be unescaped before using the path to generate path
|
||||
/// suggestions.
|
||||
fn new(
|
||||
current_directory: TypedPath,
|
||||
relative_path: &str,
|
||||
home_directory: Option<&str>,
|
||||
path_separators: &[char],
|
||||
) -> Self {
|
||||
let (directory_relative_path_name, file_name) = match relative_path.rfind(path_separators) {
|
||||
Some(pos) => relative_path.split_at(pos + 1),
|
||||
None => ("", relative_path),
|
||||
};
|
||||
|
||||
let directory_absolute_path = if directory_relative_path_name.is_empty() {
|
||||
current_directory.to_path_buf()
|
||||
} else if let Some(rest) = iproduct!([HOME_DIR_ENV_VAR_PREFIX, "~"], path_separators)
|
||||
.find_map(|(prefix, sep)| {
|
||||
directory_relative_path_name.strip_prefix(&format!("{prefix}{sep}"))
|
||||
})
|
||||
{
|
||||
let mut home_directory = TypedPathBuf::from(home_directory.unwrap_or_default());
|
||||
home_directory.push(rest.replace(r"\~", "~"));
|
||||
home_directory
|
||||
} else {
|
||||
current_directory.join(directory_relative_path_name.replace(r"\~", "~"))
|
||||
};
|
||||
|
||||
// Unescape escaped tildes in the filename.
|
||||
let file_name = file_name.replace(r"\~", "~");
|
||||
|
||||
SplitPath {
|
||||
directory_absolute_path,
|
||||
directory_relative_path_name: directory_relative_path_name.to_owned(),
|
||||
file_name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "path_test.rs"]
|
||||
mod tests;
|
||||
Reference in New Issue
Block a user