Files
galaxy/app/src/completer/mod.rs
T

488 lines
17 KiB
Rust

#[cfg(feature = "completions_v2")]
mod js;
use std::collections::{HashMap, HashSet};
use std::ops::Deref;
use std::path::Path;
use std::sync::Arc;
use anyhow::Result;
use async_trait::async_trait;
use lazy_static::lazy_static;
use smol_str::SmolStr;
use typed_path::{TypedPath, TypedPathBuf};
use galaxy_completer::completer::{
CommandExitStatus, CommandOutput, CompletionContext, EngineDirEntry, EngineFileType,
GeneratorContext, PathCompletionContext, PathSeparators, TopLevelCommandCaseSensitivity,
};
use galaxy_completer::signatures::CommandRegistry;
use galaxy_core::features::FeatureFlag;
use galaxy_util::path::{EscapeChar, ShellFamily};
use galaxyui::{AppContext, SingletonEntity};
use crate::safe_warn;
use crate::terminal::model::session::{ExecuteCommandOptions, Session, SessionType};
use crate::util::AsciiDebug;
use crate::workflows::aliases::WorkflowAliases;
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,
};
static ref EMPTY_COMMAND_REGISTRY: Arc<CommandRegistry> = Arc::new(CommandRegistry::empty());
}
#[derive(Clone)]
pub struct SessionContext {
pub session: Arc<Session>,
command_registry: Arc<CommandRegistry>,
pub current_working_directory: TypedPathBuf,
#[cfg(feature = "completions_v2")]
js_ctx: Option<js::SessionJsExecutionContext>,
cached_directory_entries: dashmap::DashMap<TypedPathBuf, Arc<Vec<EngineDirEntry>>>,
/// Snapshot of all Warp workflow aliases.
workflow_aliases: HashMap<String, String>,
}
impl SessionContext {
async fn list_directory_entries_internal(
&self,
directory: &TypedPath<'_>,
) -> Vec<EngineDirEntry> {
match self.session.session_type() {
SessionType::Local => {
let dir = match self.session.maybe_convert_to_native_path(directory) {
Ok(dir) => dir,
Err(err) => {
log::warn!("Failed to convert path: {err:#}");
return Vec::new();
}
};
// We intentionally use the synchronous `std::fs::read_dir`,
// despite this being an async function, because the overhead
// of switching threads is very expensive relative to the
// amount of work being done. Converting the a `DirEntry`
// to `EngineDirEntry` can usually be done without additional
// syscalls (though one is necessary if the entry is a
// symlink).
//
// It's possible that it would be better to use
// `async_fs::read_dir` if the directory is on a network mount,
// but I don't think it's worth optimizing for that case.
let Some(read_dir) = std::fs::read_dir(dir.as_path()).ok() else {
return vec![];
};
read_dir
.filter_map(|res| res.and_then(EngineDirEntry::try_from).ok())
.collect::<Vec<_>>()
}
SessionType::WarpifiedRemote { .. } => {
let env_vars = self
.session
.path()
.as_deref()
.map(|path| HashMap::from_iter([("PATH".to_string(), path.to_string())]));
let Some(ls_command) = ls_script_for_dir(directory) else {
return vec![];
};
// The in-band command executor doesn't support executing from
// from an arbitrary directory, so we need to cd into the
// directory we want within the ls script.
let command_output_result = self
.session
.execute_command(
&ls_command,
None,
env_vars,
ExecuteCommandOptions::default(),
)
.await;
if let Ok(command_output) = command_output_result {
let Ok(output_string) = command_output.to_string() else {
log::warn!(
"Executing `ls` on remote box returned unparseable bytes: `{:?}`",
AsciiDebug(command_output.output())
);
return vec![];
};
match command_output.status {
CommandExitStatus::Success => {
let mut entries = Vec::new();
let mut entries_iter = output_string.split('\0');
let dirs = entries_iter
.by_ref()
// We use two consecutive null characters to separate files and
// folders, so detect that here. Note that take_while consumes the
// first entry that returns false.
.take_while(|entry| !entry.is_empty())
.filter_map(|entry| {
if entry == "." {
return None;
}
Path::new(entry)
.file_name()
.and_then(|name| name.to_str())
.map(|name| EngineDirEntry {
file_name: name.to_owned(),
file_type: EngineFileType::Directory,
})
});
entries.extend(dirs);
let files = entries_iter.filter_map(|entry| {
Path::new(entry)
.file_name()
.and_then(|name| name.to_str())
.map(|name| EngineDirEntry {
file_name: name.to_owned(),
file_type: EngineFileType::File,
})
});
entries.extend(files);
entries
}
CommandExitStatus::Failure => {
safe_warn!(
safe: ("Executing `ls` on remote box failed with non-zero status code."),
full: ("Executing `ls` on remote box failed with error: {}", &String::from_utf8_lossy(command_output.output()))
);
vec![]
}
}
} else {
log::warn!(
"Executing `ls` on remote box failed with error {command_output_result:?}"
);
vec![]
}
}
}
}
}
#[async_trait]
impl PathCompletionContext for SessionContext {
fn home_directory(&self) -> Option<&str> {
self.session.home_dir()
}
fn pwd(&self) -> TypedPath<'_> {
self.current_working_directory.to_path()
}
fn shell_family(&self) -> ShellFamily {
self.session.shell_family()
}
async fn list_directory_entries(&self, directory: TypedPathBuf) -> Arc<Vec<EngineDirEntry>> {
if let Some(entries) = self.cached_directory_entries.get(&directory) {
return entries.clone();
}
let result = self
.list_directory_entries_internal(&directory.to_path())
.await;
let result = Arc::new(result);
self.cached_directory_entries
.insert(directory, result.clone());
result
}
fn path_separators(&self) -> PathSeparators {
self.session.path_separators()
}
}
#[async_trait]
impl GeneratorContext for SessionContext {
async fn execute_command_at_pwd(
&self,
shell_command: &str,
session_env_vars: Option<HashMap<String, String>>,
) -> Result<CommandOutput> {
let mut env_vars = session_env_vars.unwrap_or_default();
// We need to run the command with the PATH var set explicitly even if we have session env vars
// because if the user opened Warp through a parent process that didn't have the PATH var set
// (i.e. outside of a shell, for example opening the app via Finder),
// the subshell won't inherit the PATH var, but we need the PATH var
// to reference executables we might run as part of generators.
if let Some(path) = self.session.path().as_deref() {
env_vars.insert("PATH".to_string(), path.to_string());
}
let env_vars_option = if env_vars.is_empty() {
None
} else {
Some(env_vars)
};
self.session
.execute_command(
shell_command,
self.pwd().to_str(),
env_vars_option,
ExecuteCommandOptions {
run_command_in_same_shell_as_session: !FeatureFlag::RunGeneratorsWithCmdExe
.is_enabled(),
},
)
.await
}
fn supports_parallel_execution(&self) -> bool {
self.session.supports_parallel_command_execution()
}
}
impl CompletionContext for SessionContext {
fn generator_context(&self) -> Option<&dyn GeneratorContext> {
Some(self)
}
fn path_completion_context(&self) -> Option<&dyn PathCompletionContext> {
Some(self)
}
fn top_level_commands(&self) -> Box<dyn Iterator<Item = &str> + '_> {
Box::new(
self.session
.top_level_commands()
.chain(self.workflow_aliases.keys().map(String::as_str)),
)
}
fn command_case_sensitivity(&self) -> TopLevelCommandCaseSensitivity {
self.session.command_case_sensitivity()
}
fn escape_char(&self) -> EscapeChar {
self.session.shell_family().escape_char()
}
fn aliases(&self) -> Box<dyn Iterator<Item = (&str, &str)> + '_> {
let session_aliases = self
.session
.aliases()
.iter()
.map(|(alias, command)| (alias.as_str(), command.as_str()));
let workflow_aliases = self
.workflow_aliases
.iter()
.map(|(alias, command)| (alias.as_str(), command.as_str()));
Box::new(workflow_aliases.chain(session_aliases))
}
fn alias_command(&self, alias: &str) -> Option<&str> {
self.workflow_aliases
.get(alias)
.or_else(|| self.session.aliases().get(alias))
.map(Deref::deref)
}
fn abbreviations(&self) -> Option<&HashMap<SmolStr, String>> {
Some(self.session.abbreviations())
}
fn functions(&self) -> Option<&HashSet<SmolStr>> {
Some(self.session.functions())
}
fn builtins(&self) -> Option<&HashSet<SmolStr>> {
Some(self.session.builtins())
}
fn command_registry(&self) -> &CommandRegistry {
&self.command_registry
}
fn environment_variable_names(&self) -> Option<&HashSet<SmolStr>> {
Some(self.session.environment_variable_names())
}
fn shell_supports_autocd(&self) -> Option<bool> {
Some(self.session.shell().supports_autocd())
}
#[cfg(feature = "completions_v2")]
fn js_context(&self) -> Option<&dyn galaxy_completer::completer::JsExecutionContext> {
self.js_ctx
.as_ref()
.map(|ctx| -> &dyn galaxy_completer::completer::JsExecutionContext { ctx })
}
fn shell_family(&self) -> Option<ShellFamily> {
Some(self.session.shell_family())
}
}
impl SessionContext {
pub fn new(
session: impl Into<Arc<Session>>,
command_registry: Arc<CommandRegistry>,
current_working_directory: TypedPathBuf,
#[allow(unused_variables)] ctx: &AppContext,
) -> Self {
let workflow_aliases = if FeatureFlag::WorkflowAliases.is_enabled() {
WorkflowAliases::as_ref(ctx).autocomplete_data(ctx)
} else {
Default::default()
};
cfg_if::cfg_if! {
if #[cfg(feature = "completions_v2")] {
use crate::plugin::{PluginHost, service::CallJsFunctionService};
let js_function_caller = PluginHost::handle(ctx)
.as_ref(ctx)
.plugin_service_caller::<CallJsFunctionService>();
Self {
session: session.into(),
command_registry,
current_working_directory,
js_ctx: js_function_caller.map(js::SessionJsExecutionContext::new),
cached_directory_entries: Default::default(),
workflow_aliases,
}
} else {
Self {
session: session.into(),
command_registry,
current_working_directory,
cached_directory_entries: Default::default(),
workflow_aliases,
}
}
}
}
}
/// `CompletionContext` implementation for "global" completions, that provide completions on all
/// commands in the `command_registry` rather than providing session-specific completions.
///
/// This `CompletionContext` is not coupled to a specific session and thus does not provide path or
/// generator execution, which wouldn't have clear semantics without being coupled to a session.
#[derive(Clone)]
pub struct SessionAgnosticContext {
command_registry: Arc<CommandRegistry>,
}
impl SessionAgnosticContext {
pub fn new(command_registry: Arc<CommandRegistry>) -> Self {
Self { command_registry }
}
}
impl CompletionContext for SessionAgnosticContext {
fn top_level_commands(&self) -> Box<dyn Iterator<Item = &str> + '_> {
Box::new(self.command_registry.registered_commands())
}
fn command_registry(&self) -> &CommandRegistry {
&self.command_registry
}
fn environment_variable_names(&self) -> Option<&HashSet<SmolStr>> {
None
}
fn shell_supports_autocd(&self) -> Option<bool> {
None
}
fn path_completion_context(&self) -> Option<&dyn PathCompletionContext> {
None
}
fn generator_context(&self) -> Option<&dyn GeneratorContext> {
None
}
}
/// Empty `CompletionContext` used in places without a live shell session
/// (i.e. shared session viewers without a real terminal instance).
#[derive(Clone)]
pub struct EmptyCompletionContext;
impl EmptyCompletionContext {
pub fn new() -> Self {
Self
}
}
impl CompletionContext for EmptyCompletionContext {
fn top_level_commands(&self) -> Box<dyn Iterator<Item = &str> + '_> {
Box::new(std::iter::empty())
}
fn command_registry(&self) -> &CommandRegistry {
&EMPTY_COMMAND_REGISTRY
}
fn environment_variable_names(&self) -> Option<&HashSet<SmolStr>> {
None
}
fn shell_supports_autocd(&self) -> Option<bool> {
None
}
fn path_completion_context(&self) -> Option<&dyn PathCompletionContext> {
None
}
fn generator_context(&self) -> Option<&dyn GeneratorContext> {
None
}
}
/// List files and directories in a directory; used for completions on a remote machine.
/// This uses `find` instead of `ls` due to the challenges of parsing `ls` output for
/// unusual file names (e.g.: ones including newlines).
/// We intentionally ignore '.' and '..' here as we add those suggestions manually.
fn ls_script_for_dir(directory: &TypedPath) -> Option<String> {
// We need to cd into the directory we want completions for
let Some(dir_str) = directory.to_str() else {
log::warn!("Non-unicode character found in path: `{directory:?}`");
return None;
};
let escaped_dir = galaxy_util::path::ShellFamily::Posix.shell_escape(dir_str);
// Get all directories with -print0, which makes all items end in `\0` (null character)
// Get all files with -print0, which makes all items end in `\0`
// Separate the two lists with `\0`
// Ex: `a\0b\0\c\0\0d.txt\0e.txt\0f.txt\0`
// Then do the same for anything that is not a directory, and call it a 'File'.
let command = format!(
r#"
cd {escaped_dir} &&
find . -maxdepth 1 -type d -print0 &&
printf '%b' '\0' &&
find . -maxdepth 1 -not -type d -print0
"#
)
// Ensure all newlines are escaped, and that the command is a single line.
// ls_script_for_dir should not contain newlines, as we need to run it as a
// single line for TMUX control mode at this time.
.replace("\n", " ");
Some(command)
}
#[cfg(test)]
#[path = "test.rs"]
mod tests;