first pass of merging in warp (doesn't build)
This commit is contained in:
+26
-31
@@ -1,28 +1,23 @@
|
||||
use crate::keyboard::{remove_custom_keybinding, write_custom_keybinding, UserDefinedKeybinding};
|
||||
use crate::settings_view::keybindings::{KeybindingChangedEvent, KeybindingChangedNotifier};
|
||||
use std::borrow::Cow;
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use enum_iterator::{all, Sequence};
|
||||
use fuzzy_match::match_indices_case_insensitive;
|
||||
use itertools::Itertools;
|
||||
use lazy_static::lazy_static;
|
||||
|
||||
use galaxyui::keymap::{BindingId, IsBindingValid};
|
||||
use galaxyui::platform::OperatingSystem;
|
||||
use galaxyui::{
|
||||
actions::StandardAction,
|
||||
keymap::{
|
||||
BindingDescription, BindingLens, CustomTag, DescriptionContext, EditableBindingLens,
|
||||
Keystroke, Trigger,
|
||||
},
|
||||
Action,
|
||||
};
|
||||
use galaxyui::{AppContext, SingletonEntity};
|
||||
use regex::Regex;
|
||||
use std::borrow::Cow;
|
||||
use std::{
|
||||
cmp::Ordering,
|
||||
collections::{HashMap, HashSet},
|
||||
sync::Arc,
|
||||
use galaxyui::actions::StandardAction;
|
||||
use galaxyui::keymap::{
|
||||
BindingDescription, BindingId, BindingLens, CustomTag, DescriptionContext, EditableBindingLens,
|
||||
IsBindingValid, Keystroke, Trigger,
|
||||
};
|
||||
use galaxyui::platform::OperatingSystem;
|
||||
use galaxyui::{Action, AppContext, SingletonEntity};
|
||||
|
||||
use crate::keyboard::{remove_custom_keybinding, write_custom_keybinding, UserDefinedKeybinding};
|
||||
use crate::settings_view::keybindings::{KeybindingChangedEvent, KeybindingChangedNotifier};
|
||||
|
||||
pub const MAC_MENUS_CONTEXT: DescriptionContext = DescriptionContext::Custom("mac_menus");
|
||||
|
||||
@@ -406,9 +401,9 @@ pub fn custom_tag_to_keystroke(custom: CustomTag) -> Option<Keystroke> {
|
||||
}
|
||||
CustomAction::ToggleProjectExplorer => {
|
||||
if OperatingSystem::get().is_mac() {
|
||||
Keystroke::parse("ctrl-2").ok()
|
||||
Keystroke::parse("ctrl-1").ok()
|
||||
} else {
|
||||
Keystroke::parse("ctrl-shift-2").ok()
|
||||
Keystroke::parse("alt-1").ok()
|
||||
}
|
||||
}
|
||||
CustomAction::OpenRepository => {
|
||||
@@ -428,9 +423,9 @@ pub fn custom_tag_to_keystroke(custom: CustomTag) -> Option<Keystroke> {
|
||||
}
|
||||
CustomAction::ToggleConversationListView => {
|
||||
if OperatingSystem::get().is_mac() {
|
||||
Keystroke::parse("ctrl-1").ok()
|
||||
Keystroke::parse("ctrl-2").ok()
|
||||
} else {
|
||||
Keystroke::parse("alt-1").ok()
|
||||
Keystroke::parse("alt-2").ok()
|
||||
}
|
||||
}
|
||||
CustomAction::NewTerminalTab
|
||||
@@ -901,15 +896,8 @@ pub fn is_binding_pty_compliant(binding: BindingLens) -> IsBindingValid {
|
||||
let Some(keystroke) = trigger_to_keystroke(trigger) else {
|
||||
return IsBindingValid::Yes;
|
||||
};
|
||||
|
||||
let is_binding_in_allowlist = (OperatingSystem::get().is_mac()
|
||||
&& MAC_PTY_NON_COMPLIANT_ACTIONS.contains(binding.name))
|
||||
|| (OperatingSystem::get().is_windows()
|
||||
&& WINDOWS_PTY_NON_COMPLIANT_KEYSTROKES.contains(&keystroke))
|
||||
|| PTY_NON_COMPLIANT_KEYSTROKES.contains(&keystroke);
|
||||
|
||||
if CONTROL_CHARACTER_KEY_REGEX.is_match(keystroke.normalized().as_str())
|
||||
&& !is_binding_in_allowlist
|
||||
&& !is_pty_non_compliant_binding_allowed(binding.name, &keystroke)
|
||||
{
|
||||
// The binding interferes with a control character so it is not valid.
|
||||
IsBindingValid::No
|
||||
@@ -918,6 +906,13 @@ pub fn is_binding_pty_compliant(binding: BindingLens) -> IsBindingValid {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_pty_non_compliant_binding_allowed(binding_name: &str, keystroke: &Keystroke) -> bool {
|
||||
(OperatingSystem::get().is_mac() && MAC_PTY_NON_COMPLIANT_ACTIONS.contains(binding_name))
|
||||
|| (OperatingSystem::get().is_windows()
|
||||
&& WINDOWS_PTY_NON_COMPLIANT_KEYSTROKES.contains(keystroke))
|
||||
|| PTY_NON_COMPLIANT_KEYSTROKES.contains(keystroke)
|
||||
}
|
||||
|
||||
/// Validates all that bindings are cross-platform by returning [`IsBindingValid::No`] if a `cmd-*`
|
||||
/// binding is used on non-mac platforms.
|
||||
pub fn is_binding_cross_platform(binding: BindingLens) -> IsBindingValid {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use galaxyui::keymap::{EditableBinding, Keystroke, Trigger};
|
||||
use galaxyui::platform::OperatingSystem;
|
||||
use galaxyui::{
|
||||
keymap::{EditableBinding, Keystroke, Trigger},
|
||||
App,
|
||||
};
|
||||
use galaxyui::App;
|
||||
|
||||
use crate::{util::bindings::keybinding_name_to_display_string, workspace::WorkspaceAction};
|
||||
use crate::terminal;
|
||||
use crate::util::bindings::{keybinding_name_to_display_string, trigger_to_keystroke};
|
||||
use crate::workspace::WorkspaceAction;
|
||||
|
||||
#[test]
|
||||
fn test_keybinding_name_to_display_string() {
|
||||
@@ -72,3 +72,91 @@ fn test_keybinding_name_to_display_string() {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_orchestration_cycle_bindings_are_editable() {
|
||||
App::test((), |mut app| async move {
|
||||
app.update(terminal::init);
|
||||
|
||||
app.update(|ctx| {
|
||||
let next = ctx
|
||||
.editable_bindings()
|
||||
.find(|binding| binding.name == "terminal:cycle_next_orchestration_child_agent")
|
||||
.and_then(|binding| trigger_to_keystroke(binding.trigger));
|
||||
let previous = ctx
|
||||
.editable_bindings()
|
||||
.find(|binding| binding.name == "terminal:cycle_previous_orchestration_child_agent")
|
||||
.and_then(|binding| trigger_to_keystroke(binding.trigger));
|
||||
|
||||
assert_eq!(next, Keystroke::parse("ctrl-alt-]").ok());
|
||||
assert_eq!(previous, Keystroke::parse("ctrl-alt-[").ok());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_toggle_maximize_pane_binding_is_editable() {
|
||||
App::test((), |mut app| async move {
|
||||
app.update(crate::pane_group::init);
|
||||
|
||||
app.update(|ctx| {
|
||||
use crate::pane_group::TOGGLE_MAXIMIZE_PANE_BINDING_NAME;
|
||||
|
||||
// The toggle-maximize-pane action is registered as an editable binding so
|
||||
// it can be assigned a shortcut in Settings → Keyboard shortcuts.
|
||||
assert!(
|
||||
ctx.editable_bindings()
|
||||
.any(|binding| binding.name == TOGGLE_MAXIMIZE_PANE_BINDING_NAME),
|
||||
"{TOGGLE_MAXIMIZE_PANE_BINDING_NAME} should be registered as an editable binding"
|
||||
);
|
||||
|
||||
// It ships with a mac-only default shortcut (cmd-shift-enter) via its custom
|
||||
// action; other platforms have no default until the user assigns one. Either
|
||||
// way, whatever resolves here is what the pane header menu item surfaces.
|
||||
let default = keybinding_name_to_display_string(TOGGLE_MAXIMIZE_PANE_BINDING_NAME, ctx);
|
||||
if OperatingSystem::get().is_mac() {
|
||||
assert_eq!(Some("⇧⌘⏎"), default.as_deref());
|
||||
} else {
|
||||
assert_eq!(None, default);
|
||||
}
|
||||
|
||||
// A reassigned shortcut resolves to its display string on every platform.
|
||||
ctx.set_custom_trigger(
|
||||
TOGGLE_MAXIMIZE_PANE_BINDING_NAME.to_owned(),
|
||||
Trigger::Keystrokes(vec![Keystroke::parse("cmd-shift-M").unwrap()]),
|
||||
);
|
||||
|
||||
let displayed_keybinding = if OperatingSystem::get().is_mac() {
|
||||
"⇧⌘M"
|
||||
} else {
|
||||
"Shift Logo M"
|
||||
};
|
||||
assert_eq!(
|
||||
Some(displayed_keybinding),
|
||||
keybinding_name_to_display_string(TOGGLE_MAXIMIZE_PANE_BINDING_NAME, ctx)
|
||||
.as_deref()
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_terminal_page_scroll_bindings_are_editable() {
|
||||
App::test((), |mut app| async move {
|
||||
app.update(terminal::init);
|
||||
|
||||
app.update(|ctx| {
|
||||
let page_up = ctx
|
||||
.editable_bindings()
|
||||
.find(|binding| binding.name == "terminal:scroll_up_one_page")
|
||||
.and_then(|binding| trigger_to_keystroke(binding.trigger));
|
||||
let page_down = ctx
|
||||
.editable_bindings()
|
||||
.find(|binding| binding.name == "terminal:scroll_down_one_page")
|
||||
.and_then(|binding| trigger_to_keystroke(binding.trigger));
|
||||
|
||||
assert_eq!(page_up, Keystroke::parse("pageup").ok());
|
||||
assert_eq!(page_down, Keystroke::parse("pagedown").ok());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
pub mod external_editor;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::{fs, io};
|
||||
|
||||
#[cfg(windows)]
|
||||
use galaxy_util::path::is_network_resource;
|
||||
use galaxy_util::path::{CleanPathResult, LineAndColumnArg};
|
||||
|
||||
pub use self::external_editor::{open_file_path_in_external_editor, open_file_path_with_editor};
|
||||
use crate::terminal::model::grid::grid_handler::{ContainsPoint, Link};
|
||||
use crate::terminal::model::index::Point;
|
||||
use crate::terminal::ShellLaunchData;
|
||||
|
||||
pub use self::external_editor::{open_file_path_in_external_editor, open_file_path_with_editor};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum FilePathType {
|
||||
Absolute,
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
ffi::OsStr,
|
||||
path::{Path, PathBuf},
|
||||
sync::OnceLock,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::OsStr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use command::blocking::Command;
|
||||
use freedesktop_desktop_entry::DesktopEntry;
|
||||
@@ -14,6 +12,81 @@ use super::Editor;
|
||||
|
||||
static INSTALLED_EDITOR_METADATA: OnceLock<HashMap<Editor, EditorMetadata>> = OnceLock::new();
|
||||
|
||||
/// Tokenizes a freedesktop Exec string into a list of arguments.
|
||||
///
|
||||
/// Follows the quoting rules from the [Desktop Entry Specification](
|
||||
/// https://specifications.freedesktop.org/desktop-entry-spec/latest/exec-variables.html):
|
||||
/// - Arguments are separated by unquoted whitespace.
|
||||
/// - Double-quoted strings are treated as a single argument (quotes stripped).
|
||||
/// - Within double quotes, the escape sequences `\"`, `` \` ``, `\$`, and
|
||||
/// `\\` are recognized and resolved.
|
||||
///
|
||||
/// Field codes (`%f`, `%u`, etc.) are left as-is in the output tokens; they
|
||||
/// are expanded in a separate pass by the caller.
|
||||
fn tokenize_exec(exec: &str) -> Result<Vec<String>, DesktopExecError> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut chars = exec.chars().peekable();
|
||||
let mut in_quotes = false;
|
||||
// Tracks whether we have started accumulating a token. This is separate
|
||||
// from `current.is_empty()` because a quoted empty string (`""`) is a
|
||||
// valid zero-length token that should be emitted.
|
||||
let mut in_token = false;
|
||||
|
||||
while let Some(ch) = chars.next() {
|
||||
if in_quotes {
|
||||
match ch {
|
||||
'"' => {
|
||||
// Closing quote. The quoted content has already been
|
||||
// accumulated into `current`.
|
||||
in_quotes = false;
|
||||
}
|
||||
'\\' => {
|
||||
// Inside double quotes the spec recognizes four escape
|
||||
// sequences: \", \`, \$, \\.
|
||||
match chars.peek() {
|
||||
Some('"' | '`' | '$' | '\\') => {
|
||||
current.push(chars.next().unwrap());
|
||||
}
|
||||
_ => {
|
||||
// Not a recognized escape; keep the backslash.
|
||||
current.push('\\');
|
||||
}
|
||||
}
|
||||
}
|
||||
other => current.push(other),
|
||||
}
|
||||
} else {
|
||||
match ch {
|
||||
' ' | '\t' | '\n' => {
|
||||
if in_token {
|
||||
tokens.push(std::mem::take(&mut current));
|
||||
in_token = false;
|
||||
}
|
||||
}
|
||||
'"' => {
|
||||
in_quotes = true;
|
||||
in_token = true;
|
||||
}
|
||||
other => {
|
||||
current.push(other);
|
||||
in_token = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if in_quotes {
|
||||
return Err(DesktopExecError::UnterminatedQuote);
|
||||
}
|
||||
|
||||
if in_token {
|
||||
tokens.push(current);
|
||||
}
|
||||
|
||||
Ok(tokens)
|
||||
}
|
||||
|
||||
/// A data struct to hold relevant info pulled from a [freedesktop_desktop_entry::DesktopEntry].
|
||||
/// Mostly here to get around the lack of an owned version of DesktopEntry.
|
||||
struct EditorMetadata {
|
||||
@@ -66,76 +139,69 @@ impl EditorMetadata {
|
||||
})
|
||||
}
|
||||
|
||||
/// Common implementation of building a command
|
||||
/// Common implementation of building a command from a .desktop Exec key.
|
||||
///
|
||||
/// - Iterates over all characters in the Exec field, replacing field codes,
|
||||
/// to generate a new command string
|
||||
/// - Builds a new command that executes `sh -c <command_string>`
|
||||
/// Tokenizes the Exec string (handling quoting per the freedesktop spec),
|
||||
/// expands field codes via `field_code_processor`, and returns a `Command`
|
||||
/// that executes the program directly — without going through a shell.
|
||||
///
|
||||
/// Field code replacement is handled by the `field_code_processor` callback.
|
||||
/// See [`Self::build_default_command`] and [`Self::process_field_code`]
|
||||
/// for examples of how these work.
|
||||
///
|
||||
/// ```ignore
|
||||
/// use std::path::PathBuf;
|
||||
/// use galaxy::util::file::external_editor::linux::EditorMetadata;
|
||||
///
|
||||
/// let desktop_file_path = PathBuf::from("/var/lib/snapd/desktop/applications/webstorm_webstorm.desktop");
|
||||
/// let metadata = EditorMetadata::try_new(desktop_file_path)?;
|
||||
///
|
||||
/// let my_file_path = PathBuf::from("~/foo.rs");
|
||||
///
|
||||
/// // This is identicial to metadata.build_default_command(my_file_path);
|
||||
/// let command = metadata.build_command(|me, acc, c| me.process_field_code(acc, c, my_file_path))?;
|
||||
///
|
||||
/// // If I want to do some custom stuff, I can use a modified field code processor
|
||||
/// let command = metadata.build_command(|me, acc, c| {
|
||||
/// match c {
|
||||
/// 'c' => acc += "foobar",
|
||||
/// c => me.process_field_code(acc, c, my_file_path),
|
||||
/// }
|
||||
/// });
|
||||
/// ```
|
||||
/// Field code expansion is handled by the `field_code_processor` callback,
|
||||
/// which receives the field code character (the char after `%`) and pushes
|
||||
/// replacement arguments onto the provided `Vec<String>`.
|
||||
fn build_command<T>(&self, field_code_processor: T) -> Result<Command, DesktopExecError>
|
||||
where
|
||||
T: Fn(&Self, &mut String, char),
|
||||
T: Fn(&Self, &mut Vec<String>, char),
|
||||
{
|
||||
let raw_exec = &self.exec;
|
||||
let tokens = tokenize_exec(&self.exec)?;
|
||||
let mut args: Vec<String> = Vec::new();
|
||||
|
||||
let mut iter = raw_exec.chars();
|
||||
let mut processed_exec = String::new();
|
||||
while let Some(ch) = iter.next() {
|
||||
if ch != '%' {
|
||||
processed_exec.push(ch);
|
||||
continue;
|
||||
for token in &tokens {
|
||||
if let Some(field_code) = token.strip_prefix('%') {
|
||||
match field_code.len() {
|
||||
// A bare `%` with nothing after it is malformed.
|
||||
0 => return Err(DesktopExecError::MalformedFieldCode),
|
||||
1 => {
|
||||
let code_char = field_code.chars().next().unwrap();
|
||||
if code_char == '%' {
|
||||
// Literal percent.
|
||||
args.push("%".to_string());
|
||||
} else {
|
||||
field_code_processor(self, &mut args, code_char);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Tokens like `%foo` are not field codes; treat as literal.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let Some(next_char) = iter.next() else {
|
||||
return Err(DesktopExecError::MalformedFieldCode);
|
||||
};
|
||||
field_code_processor(self, &mut processed_exec, next_char);
|
||||
args.push(token.clone());
|
||||
}
|
||||
|
||||
let mut command = Command::new("sh");
|
||||
command.args(["-c", &processed_exec]);
|
||||
let program = args.first().ok_or(DesktopExecError::NoExec)?;
|
||||
let mut command = Command::new(program);
|
||||
command.args(&args[1..]);
|
||||
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
/// The default handler for replacing field codes with values
|
||||
/// The default handler for replacing field codes with argument values.
|
||||
///
|
||||
/// Takes in a `field_code`, and handles appending replacement values
|
||||
/// to the passed in `processed_exec` string. Follows the standard
|
||||
/// here: https://specifications.freedesktop.org/desktop-entry-spec/latest/ar01s07.html.
|
||||
/// Any fields like %f, %F, %u, and %U that rely on a file path use the `file_path`
|
||||
/// parameter.
|
||||
/// Takes a `field_code` character (the char after `%`) and pushes the
|
||||
/// corresponding argument(s) onto `args`. Follows the [Desktop Entry
|
||||
/// Specification](https://specifications.freedesktop.org/desktop-entry-spec/latest/exec-variables.html).
|
||||
///
|
||||
/// Any errors or missing information (ex: %i with no Icon field, %U wiht a non-existent path)
|
||||
/// will fail silently, and result in nothing being appended to `processed_exec`
|
||||
fn process_field_code(&self, processed_exec: &mut String, field_code: char, file_path: &Path) {
|
||||
/// Any errors or missing information (e.g., `%i` with no Icon field,
|
||||
/// `%u` with a non-existent path) will fail silently, resulting in no
|
||||
/// arguments being pushed.
|
||||
fn process_field_code(&self, args: &mut Vec<String>, field_code: char, file_path: &Path) {
|
||||
match field_code {
|
||||
// file path
|
||||
'f' | 'F' => *processed_exec += file_path.to_str().unwrap_or_default(),
|
||||
// URI
|
||||
// Single file path or file list.
|
||||
'f' | 'F' => {
|
||||
if let Some(s) = file_path.to_str() {
|
||||
args.push(s.to_string());
|
||||
}
|
||||
}
|
||||
// Single URI or URI list.
|
||||
'u' | 'U' => {
|
||||
// TODO(daprahamian): B/c we are using canonicalize, this will fail
|
||||
// if the file we are checking here does not actually exist. Also
|
||||
@@ -146,27 +212,31 @@ impl EditorMetadata {
|
||||
// See https://github.com/rust-lang/rust/issues/92750
|
||||
if let Ok(absolute) = file_path.canonicalize() {
|
||||
if let Ok(file_url) = url::Url::from_file_path(absolute) {
|
||||
*processed_exec += file_url.as_str();
|
||||
args.push(file_url.as_str().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Localized Name
|
||||
// Localized application name.
|
||||
'c' => {
|
||||
if let Some(localized_name) = self.localized_name.as_ref() {
|
||||
*processed_exec += localized_name;
|
||||
args.push(localized_name.clone());
|
||||
}
|
||||
}
|
||||
// Icon argument
|
||||
// Icon key — expands to two arguments per the spec.
|
||||
'i' => {
|
||||
if let Some(icon) = &self.icon {
|
||||
*processed_exec += "--icon ";
|
||||
*processed_exec += icon;
|
||||
args.push("--icon".to_string());
|
||||
args.push(icon.clone());
|
||||
}
|
||||
}
|
||||
// Path to the display file
|
||||
'k' => *processed_exec += self.desktop_file_path.to_str().unwrap_or_default(),
|
||||
// Just add the character
|
||||
other => processed_exec.push(other),
|
||||
// Location of the .desktop file.
|
||||
'k' => {
|
||||
if let Some(s) = self.desktop_file_path.to_str() {
|
||||
args.push(s.to_string());
|
||||
}
|
||||
}
|
||||
// Unknown or deprecated field codes are silently dropped.
|
||||
_ => {}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -184,59 +254,64 @@ impl EditorMetadata {
|
||||
self.build_command(|me, acc, c| me.process_field_code(acc, c, file_path))
|
||||
}
|
||||
|
||||
/// A variant of [`Self::build_default_command`] for jetbrains IDEs
|
||||
/// A variant of [`Self::build_default_command`] for JetBrains IDEs.
|
||||
///
|
||||
/// Works the same, except that for %f, %F, %u, and %U field codes.
|
||||
/// When adding a file or URL, additional CLI flags are injected to specify
|
||||
/// line and column number if available.
|
||||
/// For `%f`, `%F`, `%u`, and `%U` field codes, injects `--line` and
|
||||
/// optionally `--column` arguments before the file path.
|
||||
///
|
||||
/// NOTE: This is a non-standard behavior according to the .desktop specification.
|
||||
/// Any time we use this, it should be manually tested to verify that it works properly.
|
||||
/// NOTE: This is non-standard behavior according to the .desktop spec.
|
||||
/// Any time we use this, it should be manually tested to verify that it
|
||||
/// works properly.
|
||||
fn build_jetbrains_command(
|
||||
&self,
|
||||
file_path: &Path,
|
||||
line_column_number: Option<LineAndColumnArg>,
|
||||
) -> Result<Command, DesktopExecError> {
|
||||
self.build_command(|me, acc, field_code| match field_code {
|
||||
self.build_command(|me, args, field_code| match field_code {
|
||||
'f' | 'F' | 'u' | 'U' => {
|
||||
if let Some(file_path) = file_path.to_str() {
|
||||
if let Some(line_column_number) = line_column_number {
|
||||
*acc += &format!("--line {} ", line_column_number.line_num);
|
||||
args.push("--line".to_string());
|
||||
args.push(line_column_number.line_num.to_string());
|
||||
if let Some(column_num) = line_column_number.column_num {
|
||||
*acc += &format!("--column {column_num} ");
|
||||
args.push("--column".to_string());
|
||||
args.push(column_num.to_string());
|
||||
}
|
||||
}
|
||||
*acc += file_path;
|
||||
args.push(file_path.to_string());
|
||||
}
|
||||
}
|
||||
other => me.process_field_code(acc, other, file_path),
|
||||
other => me.process_field_code(args, other, file_path),
|
||||
})
|
||||
}
|
||||
/// A variant of [`Self::build_default_command`] for sublime
|
||||
|
||||
/// A variant of [`Self::build_default_command`] for Sublime Text.
|
||||
///
|
||||
/// Works the same, except that for %f, %F, %u, and %U field codes.
|
||||
/// When adding a file or URL, the file name is appended with the line and column number if available.
|
||||
/// For `%f`, `%F`, `%u`, and `%U` field codes, appends `:line:col` to
|
||||
/// the file path as a single argument.
|
||||
///
|
||||
/// NOTE: This is a non-standard behavior according to the .desktop specification.
|
||||
/// Any time we use this, it should be manually tested to verify that it works properly.
|
||||
/// NOTE: This is non-standard behavior according to the .desktop spec.
|
||||
/// Any time we use this, it should be manually tested to verify that it
|
||||
/// works properly.
|
||||
fn build_sublime_command(
|
||||
&self,
|
||||
file_path: &Path,
|
||||
line_column_number: Option<LineAndColumnArg>,
|
||||
) -> Result<Command, DesktopExecError> {
|
||||
self.build_command(|me, acc, field_code| match field_code {
|
||||
self.build_command(|me, args, field_code| match field_code {
|
||||
'f' | 'F' | 'u' | 'U' => {
|
||||
if let Some(file_path) = file_path.to_str() {
|
||||
*acc += file_path;
|
||||
let mut arg = file_path.to_string();
|
||||
if let Some(line_column_number) = line_column_number {
|
||||
*acc += &format!(":{}", line_column_number.line_num);
|
||||
arg += &format!(":{}", line_column_number.line_num);
|
||||
if let Some(column_num) = line_column_number.column_num {
|
||||
*acc += &format!(":{column_num}");
|
||||
arg += &format!(":{column_num}");
|
||||
}
|
||||
}
|
||||
args.push(arg);
|
||||
}
|
||||
}
|
||||
other => me.process_field_code(acc, other, file_path),
|
||||
other => me.process_field_code(args, other, file_path),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -406,7 +481,6 @@ impl Editor {
|
||||
}
|
||||
|
||||
pub fn is_installed(&self, _ctx: &mut AppContext) -> bool {
|
||||
use Editor::*;
|
||||
match self {
|
||||
// For Zed editors on Linux, we need to detect which channel is installed by checking both
|
||||
// the .desktop file and the actual binary location
|
||||
@@ -440,7 +514,6 @@ impl Editor {
|
||||
file_path: &Path,
|
||||
line_column_number: Option<LineAndColumnArg>,
|
||||
) -> Option<Command> {
|
||||
use Editor::*;
|
||||
match self {
|
||||
VSCode => {
|
||||
let suffix = line_column_number
|
||||
@@ -563,7 +636,10 @@ enum DesktopExecError {
|
||||
#[error("Attempted to create command for desktop entry with no exec field")]
|
||||
NoExec,
|
||||
|
||||
#[error("Malformed exec call: non-terminated field code")]
|
||||
#[error("Unterminated double quote in Exec string")]
|
||||
UnterminatedQuote,
|
||||
|
||||
#[error("Malformed field code in Exec string (bare %)")]
|
||||
MalformedFieldCode,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use galaxy_util::path::LineAndColumnArg;
|
||||
|
||||
use super::{DesktopExecError, EditorMetadata};
|
||||
use std::path::PathBuf;
|
||||
use super::{tokenize_exec, DesktopExecError, EditorMetadata};
|
||||
|
||||
#[cfg(test)]
|
||||
fn with_files(tag: &str, contents: &str, cb: impl FnOnce(PathBuf, PathBuf) -> anyhow::Result<()>) {
|
||||
@@ -23,6 +24,58 @@ fn with_files(tag: &str, contents: &str, cb: impl FnOnce(PathBuf, PathBuf) -> an
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- tokenize_exec unit tests ----------
|
||||
|
||||
#[test]
|
||||
fn test_tokenize_simple() {
|
||||
let tokens = tokenize_exec("echo hello world").unwrap();
|
||||
assert_eq!(tokens, vec!["echo", "hello", "world"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tokenize_quoted_argument() {
|
||||
let tokens = tokenize_exec(r#""/path/with spaces/editor" %f"#).unwrap();
|
||||
assert_eq!(tokens, vec!["/path/with spaces/editor", "%f"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tokenize_escape_sequences_in_quotes() {
|
||||
let tokens = tokenize_exec(r#""a\"b\\c\$d\`e""#).unwrap();
|
||||
assert_eq!(tokens, vec!["a\"b\\c$d`e"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tokenize_unrecognized_escape_in_quotes_keeps_backslash() {
|
||||
let tokens = tokenize_exec(r#""foo\nbar""#).unwrap();
|
||||
assert_eq!(tokens, vec!["foo\\nbar"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tokenize_unterminated_quote_errors() {
|
||||
let result = tokenize_exec(r#""unterminated"#);
|
||||
assert!(matches!(result, Err(DesktopExecError::UnterminatedQuote)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tokenize_multiple_whitespace() {
|
||||
let tokens = tokenize_exec("a b\tc\n d").unwrap();
|
||||
assert_eq!(tokens, vec!["a", "b", "c", "d"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tokenize_empty_string() {
|
||||
let tokens = tokenize_exec("").unwrap();
|
||||
assert!(tokens.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tokenize_quoted_empty_string_produces_token() {
|
||||
let tokens = tokenize_exec(r#"cmd """#).unwrap();
|
||||
assert_eq!(tokens, vec!["cmd", ""]);
|
||||
}
|
||||
|
||||
// ---------- build_command tests ----------
|
||||
|
||||
#[test]
|
||||
fn test_missing_exec_command_errors() {
|
||||
with_files(
|
||||
@@ -38,20 +91,20 @@ fn test_missing_exec_command_errors() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exec_ending_on_percent_fails() {
|
||||
fn test_unterminated_quote_errors() {
|
||||
let data = r#"
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Exec=echo "hello world" %
|
||||
Exec="unterminated %f
|
||||
"#;
|
||||
with_files(
|
||||
"test_exec_ending_on_percent_fails",
|
||||
"test_unterminated_quote_errors",
|
||||
data,
|
||||
|desktop, content| {
|
||||
let metadata = EditorMetadata::try_new(desktop)?;
|
||||
let result = metadata.build_default_command(&content);
|
||||
assert!(matches!(result, Err(DesktopExecError::MalformedFieldCode)));
|
||||
assert!(matches!(result, Err(DesktopExecError::UnterminatedQuote)));
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
@@ -63,7 +116,7 @@ fn test_basic_exec_no_field_codes() {
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Exec=echo "hello world"
|
||||
Exec=/usr/bin/editor --flag
|
||||
"#;
|
||||
with_files(
|
||||
"test_basic_exec_no_field_codes",
|
||||
@@ -73,11 +126,8 @@ fn test_basic_exec_no_field_codes() {
|
||||
let result = metadata.build_default_command(&content);
|
||||
assert!(result.is_ok());
|
||||
let cmd = result.unwrap();
|
||||
assert_eq!(cmd.get_program(), "sh");
|
||||
assert_eq!(
|
||||
cmd.get_args().collect::<Vec<_>>(),
|
||||
["-c", "echo \"hello world\""]
|
||||
);
|
||||
assert_eq!(cmd.get_program(), "/usr/bin/editor");
|
||||
assert_eq!(cmd.get_args().collect::<Vec<_>>(), ["--flag"]);
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
@@ -97,10 +147,9 @@ fn test_file_path_substitution() {
|
||||
let result = metadata.build_default_command(&content);
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(
|
||||
result.unwrap().get_args().collect::<Vec<_>>(),
|
||||
["-c", format!("cat {file_name}").as_str()]
|
||||
);
|
||||
let cmd = result.unwrap();
|
||||
assert_eq!(cmd.get_program(), "cat");
|
||||
assert_eq!(cmd.get_args().collect::<Vec<_>>(), [file_name.as_str()]);
|
||||
Ok(())
|
||||
});
|
||||
|
||||
@@ -116,10 +165,9 @@ fn test_file_path_substitution() {
|
||||
let result = metadata.build_default_command(&content);
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(
|
||||
result.unwrap().get_args().collect::<Vec<_>>(),
|
||||
["-c", format!("cat {file_name}").as_str()]
|
||||
);
|
||||
let cmd = result.unwrap();
|
||||
assert_eq!(cmd.get_program(), "cat");
|
||||
assert_eq!(cmd.get_args().collect::<Vec<_>>(), [file_name.as_str()]);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
@@ -140,9 +188,11 @@ fn test_file_url_substitution() {
|
||||
|
||||
assert!(result.is_ok());
|
||||
|
||||
let cmd = result.unwrap();
|
||||
assert_eq!(cmd.get_program(), "open");
|
||||
assert_eq!(
|
||||
result.unwrap().get_args().collect::<Vec<_>>(),
|
||||
["-c", &format!("open {expected_file_uri}")]
|
||||
cmd.get_args().collect::<Vec<_>>(),
|
||||
[expected_file_uri.as_str()]
|
||||
);
|
||||
Ok(())
|
||||
});
|
||||
@@ -161,34 +211,45 @@ fn test_file_url_substitution() {
|
||||
|
||||
assert!(result.is_ok());
|
||||
|
||||
let cmd = result.unwrap();
|
||||
assert_eq!(cmd.get_program(), "open");
|
||||
assert_eq!(
|
||||
result.unwrap().get_args().collect::<Vec<_>>(),
|
||||
["-c", &format!("open {expected_file_uri}")]
|
||||
cmd.get_args().collect::<Vec<_>>(),
|
||||
[expected_file_uri.as_str()]
|
||||
);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remaining_substitutions() {
|
||||
fn test_field_code_substitutions() {
|
||||
let data = r#"
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Exec=echo %c && echo %i && echo %k && echo %%
|
||||
Exec=/usr/bin/app %c %i %k %%
|
||||
Name=Warp Test Application
|
||||
Icon=/foo/bar/icon.png
|
||||
"#;
|
||||
with_files("test_remaining_substitutions", data, |desktop, content| {
|
||||
with_files("test_field_code_substitutions", data, |desktop, content| {
|
||||
let desktop_file_path = desktop.display().to_string();
|
||||
let metadata = EditorMetadata::try_new(desktop)?;
|
||||
let result = metadata.build_default_command(&content);
|
||||
|
||||
assert!(result.is_ok());
|
||||
|
||||
let cmd = result.unwrap();
|
||||
assert_eq!(cmd.get_program(), "/usr/bin/app");
|
||||
// %i expands to TWO arguments per the spec: --icon and the icon path.
|
||||
assert_eq!(
|
||||
result.unwrap().get_args().collect::<Vec<_>>(),
|
||||
["-c", &format!("echo Warp Test Application && echo --icon /foo/bar/icon.png && echo {desktop_file_path} && echo %")]
|
||||
cmd.get_args().collect::<Vec<_>>(),
|
||||
[
|
||||
"Warp Test Application",
|
||||
"--icon",
|
||||
"/foo/bar/icon.png",
|
||||
desktop_file_path.as_str(),
|
||||
"%",
|
||||
]
|
||||
);
|
||||
Ok(())
|
||||
});
|
||||
@@ -213,10 +274,9 @@ fn test_jetbrains_command_no_line_numbers() {
|
||||
|
||||
assert!(result.is_ok());
|
||||
|
||||
assert_eq!(
|
||||
result.unwrap().get_args().collect::<Vec<_>>(),
|
||||
["-c", &format!("/snap/bin/phpstorm {file_path}")]
|
||||
);
|
||||
let cmd = result.unwrap();
|
||||
assert_eq!(cmd.get_program(), "/snap/bin/phpstorm");
|
||||
assert_eq!(cmd.get_args().collect::<Vec<_>>(), [file_path.as_str()]);
|
||||
Ok(())
|
||||
},
|
||||
);
|
||||
@@ -247,9 +307,11 @@ fn test_jetbrains_command_line_numbers() {
|
||||
|
||||
assert!(result.is_ok());
|
||||
|
||||
let cmd = result.unwrap();
|
||||
assert_eq!(cmd.get_program(), "/snap/bin/phpstorm");
|
||||
assert_eq!(
|
||||
result.unwrap().get_args().collect::<Vec<_>>(),
|
||||
["-c", &format!("/snap/bin/phpstorm --line 42 {file_path}")]
|
||||
cmd.get_args().collect::<Vec<_>>(),
|
||||
["--line", "42", file_path.as_str()]
|
||||
);
|
||||
Ok(())
|
||||
},
|
||||
@@ -280,12 +342,11 @@ fn test_jetbrains_command_line_and_col_numbers() {
|
||||
|
||||
assert!(result.is_ok());
|
||||
|
||||
let cmd = result.unwrap();
|
||||
assert_eq!(cmd.get_program(), "/snap/bin/phpstorm");
|
||||
assert_eq!(
|
||||
result.unwrap().get_args().collect::<Vec<_>>(),
|
||||
[
|
||||
"-c",
|
||||
&format!("/snap/bin/phpstorm --line 42 --column 25 {file_path}")
|
||||
]
|
||||
cmd.get_args().collect::<Vec<_>>(),
|
||||
["--line", "42", "--column", "25", file_path.as_str()]
|
||||
);
|
||||
Ok(())
|
||||
},
|
||||
@@ -311,10 +372,9 @@ fn test_sublime_command_no_line_numbers() {
|
||||
|
||||
assert!(result.is_ok());
|
||||
|
||||
assert_eq!(
|
||||
result.unwrap().get_args().collect::<Vec<_>>(),
|
||||
["-c", &format!("/snap/bin/subl {file_path}")]
|
||||
);
|
||||
let cmd = result.unwrap();
|
||||
assert_eq!(cmd.get_program(), "/snap/bin/subl");
|
||||
assert_eq!(cmd.get_args().collect::<Vec<_>>(), [file_path.as_str()]);
|
||||
Ok(())
|
||||
},
|
||||
);
|
||||
@@ -344,9 +404,11 @@ fn test_sublime_command_line_numbers() {
|
||||
|
||||
assert!(result.is_ok());
|
||||
|
||||
let cmd = result.unwrap();
|
||||
assert_eq!(cmd.get_program(), "/snap/bin/subl");
|
||||
assert_eq!(
|
||||
result.unwrap().get_args().collect::<Vec<_>>(),
|
||||
["-c", &format!("/snap/bin/subl {file_path}:42")]
|
||||
cmd.get_args().collect::<Vec<_>>(),
|
||||
[format!("{file_path}:42").as_str()]
|
||||
);
|
||||
Ok(())
|
||||
},
|
||||
@@ -362,7 +424,7 @@ fn test_sublime_command_line_and_col_numbers() {
|
||||
Exec=/snap/bin/subl %f
|
||||
"#;
|
||||
with_files(
|
||||
"test_sublime_command_line_numbers",
|
||||
"test_sublime_command_line_and_col_numbers",
|
||||
data,
|
||||
|desktop, content| {
|
||||
let metadata = EditorMetadata::try_new(desktop)?;
|
||||
@@ -377,11 +439,247 @@ fn test_sublime_command_line_and_col_numbers() {
|
||||
|
||||
assert!(result.is_ok());
|
||||
|
||||
let cmd = result.unwrap();
|
||||
assert_eq!(cmd.get_program(), "/snap/bin/subl");
|
||||
assert_eq!(
|
||||
result.unwrap().get_args().collect::<Vec<_>>(),
|
||||
["-c", &format!("/snap/bin/subl {file_path}:42:25")]
|
||||
cmd.get_args().collect::<Vec<_>>(),
|
||||
[format!("{file_path}:42:25").as_str()]
|
||||
);
|
||||
Ok(())
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Injection prevention ----------
|
||||
|
||||
#[test]
|
||||
fn test_file_path_with_shell_metacharacters_is_single_arg() {
|
||||
// Verify that shell metacharacters in file paths are treated as literal
|
||||
// characters, not interpreted by a shell.
|
||||
let data = r#"
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Exec=/usr/bin/editor %f
|
||||
"#;
|
||||
|
||||
let malicious_path = PathBuf::from("/tmp/foo; rm -rf /");
|
||||
with_files(
|
||||
"test_file_path_with_shell_metacharacters",
|
||||
data,
|
||||
|desktop, _content| {
|
||||
let metadata = EditorMetadata::try_new(desktop)?;
|
||||
let result = metadata.build_default_command(&malicious_path);
|
||||
|
||||
assert!(result.is_ok());
|
||||
let cmd = result.unwrap();
|
||||
// The program is the editor, not "sh".
|
||||
assert_eq!(cmd.get_program(), "/usr/bin/editor");
|
||||
// The malicious path is a single argument, not split by shell.
|
||||
assert_eq!(cmd.get_args().collect::<Vec<_>>(), ["/tmp/foo; rm -rf /"]);
|
||||
Ok(())
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_path_with_spaces_is_single_arg() {
|
||||
let data = r#"
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Exec=/usr/bin/editor %f
|
||||
"#;
|
||||
|
||||
let path_with_spaces = PathBuf::from("/home/user/my documents/file.txt");
|
||||
with_files("test_file_path_with_spaces", data, |desktop, _content| {
|
||||
let metadata = EditorMetadata::try_new(desktop)?;
|
||||
let result = metadata.build_default_command(&path_with_spaces);
|
||||
|
||||
assert!(result.is_ok());
|
||||
let cmd = result.unwrap();
|
||||
assert_eq!(cmd.get_program(), "/usr/bin/editor");
|
||||
assert_eq!(
|
||||
cmd.get_args().collect::<Vec<_>>(),
|
||||
["/home/user/my documents/file.txt"]
|
||||
);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Quoted exec string ----------
|
||||
|
||||
#[test]
|
||||
fn test_quoted_executable_path() {
|
||||
let data = r#"
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Exec="/opt/My App/editor" --flag %f
|
||||
"#;
|
||||
with_files("test_quoted_executable_path", data, |desktop, content| {
|
||||
let metadata = EditorMetadata::try_new(desktop)?;
|
||||
let file_path = content.display().to_string();
|
||||
let result = metadata.build_default_command(&content);
|
||||
|
||||
assert!(result.is_ok());
|
||||
let cmd = result.unwrap();
|
||||
assert_eq!(cmd.get_program(), "/opt/My App/editor");
|
||||
assert_eq!(
|
||||
cmd.get_args().collect::<Vec<_>>(),
|
||||
["--flag", file_path.as_str()]
|
||||
);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Quoted exec string edge cases ----------
|
||||
|
||||
#[test]
|
||||
fn test_mixed_quoted_and_unquoted_in_single_token() {
|
||||
// Adjacent quoted and unquoted text without whitespace forms one token.
|
||||
let tokens = tokenize_exec(r#"foo"bar baz"qux"#).unwrap();
|
||||
assert_eq!(tokens, vec!["foobar bazqux"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_quoted_field_code_is_still_expanded() {
|
||||
// The spec says field codes must not be used inside a quoted argument and
|
||||
// the result is undefined. Our implementation expands them anyway since
|
||||
// quotes are stripped before field code processing.
|
||||
let data = r#"
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Exec=/usr/bin/editor "%f"
|
||||
"#;
|
||||
with_files(
|
||||
"test_quoted_field_code_is_still_expanded",
|
||||
data,
|
||||
|desktop, content| {
|
||||
let metadata = EditorMetadata::try_new(desktop)?;
|
||||
let file_path = content.display().to_string();
|
||||
let result = metadata.build_default_command(&content);
|
||||
|
||||
assert!(result.is_ok());
|
||||
let cmd = result.unwrap();
|
||||
assert_eq!(cmd.get_program(), "/usr/bin/editor");
|
||||
assert_eq!(cmd.get_args().collect::<Vec<_>>(), [file_path.as_str()]);
|
||||
Ok(())
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Malformed field codes ----------
|
||||
|
||||
#[test]
|
||||
fn test_bare_percent_at_end_errors() {
|
||||
let data = r#"
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Exec=/usr/bin/editor %
|
||||
"#;
|
||||
with_files(
|
||||
"test_bare_percent_at_end_errors",
|
||||
data,
|
||||
|desktop, content| {
|
||||
let metadata = EditorMetadata::try_new(desktop)?;
|
||||
let result = metadata.build_default_command(&content);
|
||||
assert!(matches!(result, Err(DesktopExecError::MalformedFieldCode)));
|
||||
Ok(())
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Field code edge cases ----------
|
||||
|
||||
#[test]
|
||||
fn test_localized_name_with_spaces_is_single_arg() {
|
||||
let data = r#"
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Exec=/usr/bin/app --title %c %f
|
||||
Name=My Cool Application
|
||||
"#;
|
||||
with_files(
|
||||
"test_localized_name_with_spaces",
|
||||
data,
|
||||
|desktop, content| {
|
||||
let metadata = EditorMetadata::try_new(desktop)?;
|
||||
let file_path = content.display().to_string();
|
||||
let result = metadata.build_default_command(&content);
|
||||
|
||||
assert!(result.is_ok());
|
||||
let cmd = result.unwrap();
|
||||
assert_eq!(cmd.get_program(), "/usr/bin/app");
|
||||
// %c expands to a single arg even though the name contains spaces.
|
||||
assert_eq!(
|
||||
cmd.get_args().collect::<Vec<_>>(),
|
||||
["--title", "My Cool Application", file_path.as_str()]
|
||||
);
|
||||
Ok(())
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Shell metacharacters in Exec tokens ----------
|
||||
|
||||
#[test]
|
||||
fn test_shell_constructs_in_exec_are_literal() {
|
||||
// Subcommand syntax and backticks in the Exec string itself are not
|
||||
// interpreted because we execute directly, not via sh -c.
|
||||
let data = r#"
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Exec=/usr/bin/app $(whoami) `id` %f
|
||||
"#;
|
||||
with_files(
|
||||
"test_shell_constructs_in_exec_are_literal",
|
||||
data,
|
||||
|desktop, content| {
|
||||
let metadata = EditorMetadata::try_new(desktop)?;
|
||||
let file_path = content.display().to_string();
|
||||
let result = metadata.build_default_command(&content);
|
||||
|
||||
assert!(result.is_ok());
|
||||
let cmd = result.unwrap();
|
||||
assert_eq!(cmd.get_program(), "/usr/bin/app");
|
||||
assert_eq!(
|
||||
cmd.get_args().collect::<Vec<_>>(),
|
||||
["$(whoami)", "`id`", file_path.as_str()]
|
||||
);
|
||||
Ok(())
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Deprecated / unknown field codes ----------
|
||||
|
||||
#[test]
|
||||
fn test_deprecated_field_codes_are_dropped() {
|
||||
let data = r#"
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Exec=/usr/bin/app %d %D %n %N %v %m %f
|
||||
"#;
|
||||
with_files(
|
||||
"test_deprecated_field_codes_are_dropped",
|
||||
data,
|
||||
|desktop, content| {
|
||||
let metadata = EditorMetadata::try_new(desktop)?;
|
||||
let file_path = content.display().to_string();
|
||||
let result = metadata.build_default_command(&content);
|
||||
|
||||
assert!(result.is_ok());
|
||||
let cmd = result.unwrap();
|
||||
assert_eq!(cmd.get_program(), "/usr/bin/app");
|
||||
// All deprecated codes are silently dropped; only %f remains.
|
||||
assert_eq!(cmd.get_args().collect::<Vec<_>>(), [file_path.as_str()]);
|
||||
Ok(())
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
#![allow(deprecated)]
|
||||
use std::fmt::Write;
|
||||
use std::path::Path;
|
||||
|
||||
use instant::Instant;
|
||||
use std::slice;
|
||||
use std::{fmt::Write, path::Path};
|
||||
|
||||
use cocoa::{
|
||||
base::{id, nil},
|
||||
foundation::{NSAutoreleasePool, NSString},
|
||||
};
|
||||
use command::r#async::Command;
|
||||
use galaxyui::{platform::mac::make_nsstring, ApplicationBundleInfo};
|
||||
use instant::Instant;
|
||||
use objc2::rc::{autoreleasepool, Retained};
|
||||
use objc2_app_kit::NSWorkspace;
|
||||
use objc2_foundation::{NSBundle, NSString, NSURL};
|
||||
use galaxy_core::channel::ChannelState;
|
||||
use galaxy_core::AppId;
|
||||
use galaxyui::ApplicationBundleInfo;
|
||||
|
||||
use super::*;
|
||||
|
||||
// Functions implemented in objC files.
|
||||
extern "C" {
|
||||
fn get_default_app_bundle_for_file(file_path: id) -> id;
|
||||
}
|
||||
|
||||
/// The exeutable we use to launch the editor.
|
||||
/// The executable we use to launch the editor.
|
||||
#[derive(Debug)]
|
||||
pub enum OpenFileInEditorMethod {
|
||||
// A custom binary (e.g. the code CLI tool for VSCode).
|
||||
@@ -333,7 +327,7 @@ pub fn open_file_path_with_line_and_col(
|
||||
let editor = if with_editor.is_some_and(|editor| editor.is_installed(ctx)) {
|
||||
with_editor
|
||||
} else {
|
||||
let app_bundle_id = unsafe { default_app_to_open_path(full_path) };
|
||||
let app_bundle_id = default_app_to_open_path(full_path);
|
||||
app_bundle_id
|
||||
.as_deref()
|
||||
.and_then(Editor::new_from_identifier)
|
||||
@@ -344,27 +338,63 @@ pub fn open_file_path_with_line_and_col(
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// NSWorkspace's default-app routing can hand files to a sibling
|
||||
// Warp channel (e.g. Stable handling files while Preview is running).
|
||||
// When the resolved default is a different Warp, open with the
|
||||
// running channel's bundle directly.
|
||||
let bundle_id = default_app_to_open_path(full_path);
|
||||
if let Some(bundle_id) = bundle_id.as_deref() {
|
||||
let current = ChannelState::app_id().to_string();
|
||||
if bundle_id != current
|
||||
&& is_warp_bundle(bundle_id)
|
||||
&& open_with_bundle(¤t, full_path)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.open_file_path(full_path);
|
||||
}
|
||||
|
||||
fn is_warp_bundle(bundle_id: &str) -> bool {
|
||||
AppId::parse(bundle_id)
|
||||
.map(|id| id.qualifier() == "dev" && id.organization() == "warp")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn open_with_bundle(bundle_id: &str, path: &Path) -> bool {
|
||||
// Wait for `open` to exit; a non-zero status needs to bubble up so
|
||||
// the caller's ctx.open_file_path fallback still runs.
|
||||
command::blocking::Command::new("/usr/bin/open")
|
||||
.args(["-b", bundle_id])
|
||||
.arg(path)
|
||||
.status()
|
||||
.is_ok_and(|s| s.success())
|
||||
}
|
||||
|
||||
// Get the Mac default app for opening the file path.
|
||||
//
|
||||
// The NSString returned by `-[NSBundle bundleIdentifier]` is autoreleased by
|
||||
// Cocoa. We wrap the call in a local pool so the autoreleased string (and the
|
||||
// one we pass in via `make_nsstring`) are drained before we return, and copy
|
||||
// the UTF-8 bytes out into an owned `String` so no dangling pointer escapes.
|
||||
unsafe fn default_app_to_open_path(file_path: &Path) -> Option<String> {
|
||||
let pool = NSAutoreleasePool::new(nil);
|
||||
let bundle_id = get_default_app_bundle_for_file(make_nsstring(file_path.to_string_lossy()));
|
||||
let result = if bundle_id == nil {
|
||||
None
|
||||
} else {
|
||||
let cstr = bundle_id.UTF8String() as *const u8;
|
||||
std::str::from_utf8(slice::from_raw_parts(cstr, bundle_id.len()))
|
||||
.ok()
|
||||
.map(ToOwned::to_owned)
|
||||
};
|
||||
pool.drain();
|
||||
result
|
||||
// We open a local autorelease pool around the lookup so any temporaries AppKit
|
||||
// hands back (e.g. from the Launch Services lookup inside
|
||||
// `URLForApplicationToOpenURL:`) are drained before we return, and copy the
|
||||
// bundle identifier into an owned `String` so nothing tied to the pool escapes.
|
||||
fn default_app_to_open_path(file_path: &Path) -> Option<String> {
|
||||
autoreleasepool(|_| {
|
||||
let file_path = NSString::from_str(&file_path.to_string_lossy());
|
||||
get_default_app_bundle_for_file(&file_path).map(|bundle_id| bundle_id.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
// Returns the bundle identifier of the application macOS would use to open the
|
||||
// given file, or `None` if there is no registered handler.
|
||||
fn get_default_app_bundle_for_file(file_path: &NSString) -> Option<Retained<NSString>> {
|
||||
let file_url = NSURL::fileURLWithPath(file_path);
|
||||
let app_url = NSWorkspace::sharedWorkspace().URLForApplicationToOpenURL(&file_url)?;
|
||||
let app_bundle = NSBundle::bundleWithURL(&app_url)?;
|
||||
app_bundle.bundleIdentifier()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mac_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
use super::is_warp_bundle;
|
||||
|
||||
#[test]
|
||||
fn is_warp_bundle_recognises_warp_channels() {
|
||||
assert!(is_warp_bundle("dev.warp.Warp"));
|
||||
assert!(is_warp_bundle("dev.warp.WarpDev"));
|
||||
assert!(is_warp_bundle("dev.warp.WarpPreview"));
|
||||
assert!(is_warp_bundle("dev.warp.WarpOss"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_warp_bundle_rejects_other_apps() {
|
||||
assert!(!is_warp_bundle("com.microsoft.VSCode"));
|
||||
assert!(!is_warp_bundle("com.apple.TextEdit"));
|
||||
assert!(!is_warp_bundle("dev.zed.Zed"));
|
||||
assert!(!is_warp_bundle("invalid"));
|
||||
assert!(!is_warp_bundle(""));
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
||||
mod linux;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac;
|
||||
@@ -40,10 +40,10 @@ pub const SUPPORTED_EDITORS: &[Editor] = &[
|
||||
Editor::Sublime3,
|
||||
#[cfg(target_os = "macos")]
|
||||
Editor::Sublime4,
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
#[cfg(any(target_os = "macos", any(target_os = "linux", target_os = "freebsd")))]
|
||||
// Zed is available on macos and linux
|
||||
Editor::Zed,
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
#[cfg(any(target_os = "macos", any(target_os = "linux", target_os = "freebsd")))]
|
||||
// Zed Preview is available on macos and linux
|
||||
Editor::ZedPreview,
|
||||
Editor::GoLand,
|
||||
@@ -311,7 +311,7 @@ pub fn open_file_path_with_editor(
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(target_os = "macos")] {
|
||||
mac::open_file_path_with_line_and_col(line_column_number, editor, &full_path, ctx);
|
||||
} else if #[cfg(target_os = "linux")] {
|
||||
} else if #[cfg(any(target_os = "linux", target_os = "freebsd"))] {
|
||||
linux::open_file_path_with_line_and_col(line_column_number, editor, &full_path, ctx);
|
||||
} else if #[cfg(windows)]{
|
||||
windows::open_file_path_with_line_and_col(line_column_number, editor, &full_path, ctx);
|
||||
@@ -322,5 +322,5 @@ pub fn open_file_path_with_editor(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_test.rs"]
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
-1
@@ -303,7 +303,6 @@ fn test_editor_try_from_supported_editors() {
|
||||
|
||||
#[test]
|
||||
fn test_editor_try_from_unsupported_editors() {
|
||||
use super::Editor;
|
||||
|
||||
// Test unsupported terminal editors
|
||||
assert!(Editor::try_from("vim").is_err());
|
||||
@@ -1,6 +1,8 @@
|
||||
pub use crate::util::openable_file_type::EditorLayout;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use settings::{macros::define_settings_group, SupportedPlatforms, SyncToCloud};
|
||||
use settings::macros::define_settings_group;
|
||||
use settings::{RespectUserSyncSetting, SupportedPlatforms, SyncToCloud};
|
||||
|
||||
pub use crate::util::openable_file_type::EditorLayout;
|
||||
|
||||
#[derive(
|
||||
Debug,
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
//! Module containing logic to determine to open a file in a text editor, if it is installed.
|
||||
//! TODO(PLAT-749): Add support for more editors.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use command::r#async::Command;
|
||||
use enum_iterator::{all, cardinality};
|
||||
use galaxy_util::path::LineAndColumnArg;
|
||||
use galaxyui::AppContext;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE};
|
||||
use winreg::RegKey;
|
||||
use winreg::HKEY;
|
||||
use winreg::{RegKey, HKEY};
|
||||
|
||||
use super::Editor;
|
||||
|
||||
|
||||
+566
-357
File diff suppressed because it is too large
Load Diff
+328
-1
@@ -4,7 +4,10 @@ use command::r#async::Command;
|
||||
use command::Stdio;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use super::{detect_current_branch, detect_current_branch_display};
|
||||
use super::{
|
||||
detect_current_branch, detect_current_branch_display, get_pr_for_branch, is_gh_auth_error,
|
||||
is_gh_missing_error, RepositoryInfo,
|
||||
};
|
||||
|
||||
/// Helper: run a git command inside the given repo directory.
|
||||
async fn git(repo: &Path, args: &[&str]) -> String {
|
||||
@@ -19,6 +22,86 @@ async fn git(repo: &Path, args: &[&str]) -> String {
|
||||
String::from_utf8_lossy(&output.stdout).trim().to_owned()
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
#[test]
|
||||
fn repository_info_from_gh_output_parses_name_and_owner() {
|
||||
assert_eq!(
|
||||
super::repository_info_from_gh_output(
|
||||
r#"{"name":"warp-internal","owner":{"login":"warpdotdev"}}"#
|
||||
)
|
||||
.unwrap(),
|
||||
RepositoryInfo {
|
||||
name: "warp-internal".to_owned(),
|
||||
owner: Some("warpdotdev".to_owned()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "local_fs", unix))]
|
||||
#[tokio::test]
|
||||
async fn get_repository_info_returns_none_when_gh_cannot_resolve_github_repo() {
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let (_dir, repo) = init_repo().await;
|
||||
|
||||
let fake_bin = tempfile::tempdir().expect("failed to create fake bin dir");
|
||||
let gh_path = fake_bin.path().join("gh");
|
||||
fs::write(
|
||||
&gh_path,
|
||||
"#!/bin/sh\nprintf 'none of the git remotes configured for this repository point to a known GitHub host\\n' >&2\nexit 1\n",
|
||||
)
|
||||
.expect("failed to write fake gh");
|
||||
let mut permissions = fs::metadata(&gh_path).unwrap().permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&gh_path, permissions).unwrap();
|
||||
|
||||
let path_env = format!(
|
||||
"{}:{}",
|
||||
fake_bin.path().display(),
|
||||
std::env::var("PATH").unwrap_or_default()
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
super::get_repository_info(&repo, Some(&path_env))
|
||||
.await
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
#[test]
|
||||
fn repository_info_from_gh_output_rejects_missing_name() {
|
||||
assert!(super::repository_info_from_gh_output(r#"{"owner":{"login":"warpdotdev"}}"#).is_err());
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
#[test]
|
||||
fn repository_info_from_gh_output_rejects_missing_owner_login() {
|
||||
assert!(
|
||||
super::repository_info_from_gh_output(r#"{"name":"warp-internal","owner":{}}"#).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
#[test]
|
||||
fn repository_info_from_gh_output_rejects_empty_fields() {
|
||||
assert!(
|
||||
super::repository_info_from_gh_output(r#"{"name":"","owner":{"login":"warpdotdev"}}"#)
|
||||
.is_err()
|
||||
);
|
||||
assert!(super::repository_info_from_gh_output(
|
||||
r#"{"name":"warp-internal","owner":{"login":""}}"#
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
#[test]
|
||||
fn repository_info_from_gh_output_rejects_malformed_json() {
|
||||
assert!(super::repository_info_from_gh_output("not json").is_err());
|
||||
}
|
||||
|
||||
/// Creates a temp git repo with one commit and returns `(dir_handle, repo_path)`.
|
||||
async fn init_repo() -> (TempDir, std::path::PathBuf) {
|
||||
let dir = tempfile::tempdir().expect("failed to create temp dir");
|
||||
@@ -32,6 +115,92 @@ async fn init_repo() -> (TempDir, std::path::PathBuf) {
|
||||
(dir, path)
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "local_fs", unix))]
|
||||
#[tokio::test]
|
||||
async fn get_repository_info_reads_gh_repo_view() {
|
||||
let (_dir, repo) = init_repo().await;
|
||||
|
||||
let fake_bin = tempfile::tempdir().expect("failed to create fake bin dir");
|
||||
let gh_path = fake_bin.path().join("gh");
|
||||
fs::write(
|
||||
&gh_path,
|
||||
"#!/bin/sh\nprintf '{\"name\":\"warp-internal\",\"owner\":{\"login\":\"warpdotdev\"}}\\n'\n",
|
||||
)
|
||||
.expect("failed to write fake gh");
|
||||
let mut permissions = fs::metadata(&gh_path).unwrap().permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&gh_path, permissions).unwrap();
|
||||
|
||||
let path_env = format!(
|
||||
"{}:{}",
|
||||
fake_bin.path().display(),
|
||||
std::env::var("PATH").unwrap_or_default()
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
super::get_repository_info(&repo, Some(&path_env))
|
||||
.await
|
||||
.unwrap(),
|
||||
Some(RepositoryInfo {
|
||||
name: "warp-internal".to_owned(),
|
||||
owner: Some("warpdotdev".to_owned()),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_missing_gh_errors() {
|
||||
assert!(is_gh_missing_error(
|
||||
"Failed to execute gh command: No such file or directory (os error 2)"
|
||||
));
|
||||
assert!(is_gh_missing_error(
|
||||
"Failed to execute gh command: program not found"
|
||||
));
|
||||
|
||||
assert!(!is_gh_missing_error(
|
||||
"gh command failed: GraphQL: authentication required; run gh auth login"
|
||||
));
|
||||
assert!(!is_gh_missing_error(
|
||||
"Post \"https://api.github.com/graphql\": dial tcp: lookup api.github.com: no such host"
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
#[test]
|
||||
fn detects_no_pr_for_branch_errors() {
|
||||
assert!(super::is_no_pr_for_branch_error(
|
||||
"gh command failed: no pull requests found for branch \"feature-a\""
|
||||
));
|
||||
assert!(super::is_no_pr_for_branch_error(
|
||||
"gh command failed: no open pull requests found for branch \"feature-a\""
|
||||
));
|
||||
assert!(super::is_no_pr_for_branch_error(
|
||||
"GraphQL: NO OPEN PULL REQUESTS FOUND FOR BRANCH feature-a"
|
||||
));
|
||||
assert!(!super::is_no_pr_for_branch_error("authentication required"));
|
||||
assert!(!super::is_no_pr_for_branch_error("repository not found"));
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
#[test]
|
||||
fn detects_repository_lookup_not_applicable_errors() {
|
||||
assert!(super::is_repository_lookup_not_applicable_error(
|
||||
"gh command failed: none of the git remotes configured for this repository point to a known GitHub host"
|
||||
));
|
||||
assert!(super::is_repository_lookup_not_applicable_error(
|
||||
"gh command failed: no GitHub remotes"
|
||||
));
|
||||
assert!(super::is_repository_lookup_not_applicable_error(
|
||||
"gh command failed: not a GitHub repository"
|
||||
));
|
||||
assert!(!super::is_repository_lookup_not_applicable_error(
|
||||
"authentication required"
|
||||
));
|
||||
assert!(!super::is_repository_lookup_not_applicable_error(
|
||||
"repository not found"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn on_normal_branch_returns_branch_name() {
|
||||
let (_dir, repo) = init_repo().await;
|
||||
@@ -70,6 +239,164 @@ async fn detached_head_display_returns_short_sha() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_pr_for_branch_returns_none_for_detached_head() {
|
||||
let (_dir, repo) = init_repo().await;
|
||||
git(&repo, &["checkout", "--detach", "HEAD"]).await;
|
||||
assert_eq!(get_pr_for_branch(&repo, None).await.unwrap(), None);
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
#[tokio::test]
|
||||
async fn committed_branch_files_excludes_uncommitted_and_untracked() {
|
||||
let (_dir, repo) = init_repo().await;
|
||||
// Branch off main; the merge base is main's initial commit.
|
||||
git(&repo, &["checkout", "-b", "feature"]).await;
|
||||
|
||||
// Commit a new file on the feature branch — this SHOULD appear in the
|
||||
// committed branch diff.
|
||||
std::fs::write(repo.join("committed.txt"), "line1\nline2\n").expect("write committed.txt");
|
||||
git(&repo, &["add", "committed.txt"]).await;
|
||||
git(&repo, &["commit", "-m", "add committed.txt"]).await;
|
||||
|
||||
// Further-modify the committed file in the working tree (uncommitted) and
|
||||
// add an untracked file. Neither is part of the PR's committed history, so
|
||||
// neither should appear, and the committed file's counts must reflect only
|
||||
// the committed change (2 added lines, not 3).
|
||||
std::fs::write(repo.join("committed.txt"), "line1\nline2\nline3\n")
|
||||
.expect("modify committed.txt");
|
||||
std::fs::write(repo.join("untracked.txt"), "new\n").expect("write untracked.txt");
|
||||
|
||||
let entries = super::get_committed_branch_file_entries(&repo)
|
||||
.await
|
||||
.expect("committed branch files");
|
||||
|
||||
assert_eq!(
|
||||
entries.len(),
|
||||
1,
|
||||
"expected only the committed file: {entries:?}"
|
||||
);
|
||||
assert_eq!(entries[0].path, "committed.txt");
|
||||
assert_eq!(entries[0].additions, 2);
|
||||
assert_eq!(entries[0].deletions, 0);
|
||||
assert!(
|
||||
!entries.iter().any(|e| e.path == "untracked.txt"),
|
||||
"untracked files must be excluded: {entries:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn get_pr_for_branch_does_not_require_origin_remote() {
|
||||
|
||||
use super::PrInfo;
|
||||
|
||||
let (_dir, repo) = init_repo().await;
|
||||
|
||||
let fake_bin = tempfile::tempdir().expect("failed to create fake bin dir");
|
||||
let gh_path = fake_bin.path().join("gh");
|
||||
fs::write(
|
||||
&gh_path,
|
||||
"#!/bin/sh\nprintf '{\"number\":123,\"url\":\"https://github.com/warp/warp/pull/123\",\"state\":\"OPEN\",\"isDraft\":true,\"baseRefName\":\"main\"}\\n'\n",
|
||||
)
|
||||
.expect("failed to write fake gh");
|
||||
let mut permissions = fs::metadata(&gh_path).unwrap().permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&gh_path, permissions).unwrap();
|
||||
|
||||
let path_env = format!(
|
||||
"{}:{}",
|
||||
fake_bin.path().display(),
|
||||
std::env::var("PATH").unwrap_or_default()
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
get_pr_for_branch(&repo, Some(&path_env)).await.unwrap(),
|
||||
Some(PrInfo {
|
||||
number: 123,
|
||||
url: "https://github.com/warp/warp/pull/123".to_string(),
|
||||
state: "OPEN".to_string(),
|
||||
draft: true,
|
||||
base_branch: "main".to_string(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn get_pr_for_branch_returns_none_when_gh_finds_no_pr() {
|
||||
|
||||
let (_dir, repo) = init_repo().await;
|
||||
|
||||
let fake_bin = tempfile::tempdir().expect("failed to create fake bin dir");
|
||||
let gh_path = fake_bin.path().join("gh");
|
||||
fs::write(
|
||||
&gh_path,
|
||||
"#!/bin/sh\nprintf 'no pull requests found for branch \"main\"\\n' >&2\nexit 1\n",
|
||||
)
|
||||
.expect("failed to write fake gh");
|
||||
let mut permissions = fs::metadata(&gh_path).unwrap().permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&gh_path, permissions).unwrap();
|
||||
|
||||
let path_env = format!(
|
||||
"{}:{}",
|
||||
fake_bin.path().display(),
|
||||
std::env::var("PATH").unwrap_or_default()
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
get_pr_for_branch(&repo, Some(&path_env)).await.unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn get_pr_for_branch_returns_none_when_gh_cannot_resolve_github_repo() {
|
||||
|
||||
let (_dir, repo) = init_repo().await;
|
||||
|
||||
let fake_bin = tempfile::tempdir().expect("failed to create fake bin dir");
|
||||
let gh_path = fake_bin.path().join("gh");
|
||||
fs::write(
|
||||
&gh_path,
|
||||
"#!/bin/sh\nprintf 'none of the git remotes configured for this repository point to a known GitHub host\\n' >&2\nexit 1\n",
|
||||
)
|
||||
.expect("failed to write fake gh");
|
||||
let mut permissions = fs::metadata(&gh_path).unwrap().permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&gh_path, permissions).unwrap();
|
||||
|
||||
let path_env = format!(
|
||||
"{}:{}",
|
||||
fake_bin.path().display(),
|
||||
std::env::var("PATH").unwrap_or_default()
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
get_pr_for_branch(&repo, Some(&path_env)).await.unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn detects_gh_auth_errors() {
|
||||
assert!(is_gh_auth_error(
|
||||
"You are not logged in to any GitHub hosts"
|
||||
));
|
||||
assert!(is_gh_auth_error(
|
||||
"GraphQL: authentication required; run gh auth login"
|
||||
));
|
||||
assert!(is_gh_auth_error(
|
||||
"To get started with GitHub CLI, run: gh auth login"
|
||||
));
|
||||
|
||||
assert!(!is_gh_auth_error(
|
||||
"Post \"https://api.github.com/graphql\": dial tcp: lookup api.github.com: no such host"
|
||||
));
|
||||
assert!(!is_gh_auth_error("no pull requests found for branch"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detached_tag_display_returns_short_sha() {
|
||||
let (_dir, repo) = init_repo().await;
|
||||
|
||||
@@ -3,13 +3,39 @@
|
||||
//! This module provides common functionality for processing images before they are
|
||||
//! sent to the AI agent, whether attached by the user or read via the read_files tool.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use image::{GenericImageView, ImageError};
|
||||
use mime_guess::from_path;
|
||||
|
||||
/// Max image size is 3.75 MB.
|
||||
/// The max size of an image we will send is 5MB. However, due to the 33% inflation of Base64, this means
|
||||
/// the largest size a user can attach is actually ~3.75MB.
|
||||
pub const MAX_IMAGE_SIZE_BYTES: usize = 3750 * 1000;
|
||||
|
||||
/// Cap on individual image bytes when relaying a dropped/pasted image to a
|
||||
/// CLI agent (Claude Code etc.) via the system clipboard. CLI agents handle
|
||||
/// their own compression, so this limit only exists to keep us from loading
|
||||
/// arbitrarily large files into memory.
|
||||
pub const MAX_IMAGE_SIZE_BYTES_FOR_CLI_AGENT: usize = 500 * 1_000_000;
|
||||
|
||||
/// How many leading bytes of a file are enough for `infer_mime_type` to
|
||||
/// match a magic-number signature. Callers that already have the full bytes
|
||||
/// in memory should pass only the first `MIME_SNIFF_BYTES` to avoid handing
|
||||
/// arbitrarily large slices to the sniffer.
|
||||
pub const MIME_SNIFF_BYTES: usize = 8 * 1024;
|
||||
|
||||
/// Returns the MIME type for `path`, preferring magic-byte detection from
|
||||
/// `file_bytes` and falling back to the path's extension when the magic
|
||||
/// bytes don't yield a confident match. Falls all the way back to
|
||||
/// `application/octet-stream`. `file_bytes` only needs to contain the first
|
||||
/// `MIME_SNIFF_BYTES` of the file for the magic-number check.
|
||||
pub fn infer_mime_type(path: &Path, file_bytes: &[u8]) -> String {
|
||||
infer::get(file_bytes)
|
||||
.map(|kind| kind.mime_type().to_string())
|
||||
.unwrap_or_else(|| from_path(path).first_or_octet_stream().to_string())
|
||||
}
|
||||
|
||||
/// 1.15 Megapixels
|
||||
pub const MAX_IMAGE_PIXELS: f64 = 1150. * 1000.;
|
||||
|
||||
|
||||
@@ -66,7 +66,6 @@ fn test_process_image_for_agent_invalid_data() {
|
||||
|
||||
/// Creates a large test PNG image that exceeds MAX_IMAGE_PIXELS.
|
||||
fn create_large_test_png() -> Vec<u8> {
|
||||
use image::{ImageBuffer, Rgba};
|
||||
// Create a 2000x1000 image (2M pixels, exceeds MAX_IMAGE_PIXELS of 1.15M)
|
||||
let img: ImageBuffer<Rgba<u8>, Vec<u8>> =
|
||||
ImageBuffer::from_fn(2000, 1000, |_x, _y| Rgba([255u8, 0u8, 0u8, 255u8]));
|
||||
@@ -100,7 +99,6 @@ fn test_resize_image_large_image_gets_resized() {
|
||||
|
||||
/// Creates a very tall/narrow image to test dimension clamping.
|
||||
fn create_tall_test_png() -> Vec<u8> {
|
||||
use image::{ImageBuffer, Rgba};
|
||||
// Create a 100x20000 image (2M pixels, but very tall)
|
||||
// After pixel-based scaling, height would still exceed MAX_IMAGE_DIMENSION
|
||||
let img: ImageBuffer<Rgba<u8>, Vec<u8>> =
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
use galaxyui::elements::PartialClickableElement;
|
||||
use std::collections::HashMap;
|
||||
use std::ops::Range;
|
||||
use urlocator::{UrlLocation, UrlLocator};
|
||||
|
||||
use string_offset::ByteOffset;
|
||||
use urlocator::{UrlLocation, UrlLocator};
|
||||
use galaxyui::elements::{MouseStateHandle, PartialClickableElement};
|
||||
use galaxyui::platform::Cursor;
|
||||
use galaxyui::text::char_slice;
|
||||
use galaxyui::Action;
|
||||
|
||||
use crate::ai::agent::{AIAgentActionType, AIAgentOutput, AIAgentTextSection, ReadFilesRequest};
|
||||
use crate::ai::blocklist::block::view_impl::output::LinkActionConstructors;
|
||||
use crate::ai::blocklist::block::TextLocation;
|
||||
use crate::terminal::links::should_directly_open_link;
|
||||
use crate::terminal::model::grid::grid_handler::FILE_LINK_SEPARATORS;
|
||||
use crate::terminal::model::grid::grid_handler::is_file_link_separator;
|
||||
use crate::terminal::ShellLaunchData;
|
||||
use galaxyui::elements::MouseStateHandle;
|
||||
use galaxyui::text::char_slice;
|
||||
use galaxyui::Action;
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "local_fs")] {
|
||||
@@ -213,22 +214,41 @@ fn addr_of(s: &str) -> usize {
|
||||
s.as_ptr() as usize
|
||||
}
|
||||
|
||||
/// Given a word with no whitespace in it, returns all the possible file paths within the word
|
||||
/// from longest to shortest. File paths within a word can be split by a list of FILE_LINK_SEPARATORS,
|
||||
/// and those separators may be part of file paths themselves.
|
||||
/// Possible file paths begin after a separator and end before a separator.
|
||||
/// For example, given /path/to/file:16:hello, it will return
|
||||
/// ["/path/to/file:16:hello", "/path/to/file:16", "/path/to/file", "16:hello", "hello"]
|
||||
/// Maximum byte length of a token to search for file paths in. Used as a guard against scanning huge non-path tokens.
|
||||
/// - Linux PATH_MAX: 4096 bytes.
|
||||
/// - macOS PATH_MAX: 1024 bytes.
|
||||
/// - Windows long-path cap: 32,767 UTF-16 units = 98,301 bytes.
|
||||
const MAX_WORD_LEN_FOR_FILE_PATH: usize = 96 * 1024;
|
||||
/// Maximum [`is_file_link_separator`] characters per token, to bound candidate substrings.
|
||||
/// 256 keeps per-token allocations under ~1 MiB and is far above any real path.
|
||||
const MAX_SEPARATORS_PER_WORD: usize = 256;
|
||||
|
||||
/// A separator's byte range in the original word.
|
||||
///
|
||||
/// File path candidates start after one separator and end before another. Using [`ByteOffset`]
|
||||
/// keeps the byte-indexing semantics explicit when separators are multi-byte characters like
|
||||
/// box-drawing glyphs.
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
fn possible_file_paths_in_word(word: &str) -> impl Iterator<Item = &str> {
|
||||
type SeparatorByteRange = Range<ByteOffset>;
|
||||
|
||||
/// Returns separator byte ranges in `word`, framed by zero-width virtual separators at
|
||||
/// the start and end of the word. Returns empty if either safety cap is exceeded.
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
fn separator_byte_ranges_for_file_path_search(word: &str) -> Vec<SeparatorByteRange> {
|
||||
if word.len() > MAX_WORD_LEN_FOR_FILE_PATH {
|
||||
return Vec::new();
|
||||
}
|
||||
// To include any substrings starting at the beginning of the word, we
|
||||
// pretend there's a separator before the first character.
|
||||
let mut separator_byte_indices = vec![-1];
|
||||
// pretend there's a zero-width separator before the first character.
|
||||
let mut separator_byte_ranges = vec![ByteOffset::zero()..ByteOffset::zero()];
|
||||
// We use char_indices() to get byte indices of each char which are used to index the string,
|
||||
// rather than chars().enumerate() would give char indices.
|
||||
for (i, c) in word.char_indices() {
|
||||
if FILE_LINK_SEPARATORS.contains(&c) {
|
||||
separator_byte_indices.push(i as i32);
|
||||
if is_file_link_separator(c) {
|
||||
if separator_byte_ranges.len() > MAX_SEPARATORS_PER_WORD {
|
||||
return Vec::new();
|
||||
}
|
||||
separator_byte_ranges.push(ByteOffset::from(i)..ByteOffset::from(i + c.len_utf8()));
|
||||
}
|
||||
}
|
||||
// Consider trailing periods to be separators. This is because
|
||||
@@ -237,24 +257,41 @@ fn possible_file_paths_in_word(word: &str) -> impl Iterator<Item = &str> {
|
||||
// periods can also be part of a valid file path.
|
||||
let word_ends_with_period = word.ends_with('.');
|
||||
if word_ends_with_period {
|
||||
separator_byte_indices.push((word.len() - 1) as i32);
|
||||
separator_byte_ranges.push(ByteOffset::from(word.len() - 1)..ByteOffset::from(word.len()));
|
||||
}
|
||||
// To include any substrings ending at the end of the word, we pretend there's
|
||||
// a separator after the last character.
|
||||
separator_byte_indices.push(word.len() as i32);
|
||||
// a zero-width separator after the last character.
|
||||
separator_byte_ranges.push(ByteOffset::from(word.len())..ByteOffset::from(word.len()));
|
||||
separator_byte_ranges
|
||||
}
|
||||
|
||||
/// Given a word with no whitespace in it, returns all the possible file paths within the word
|
||||
/// from longest to shortest. File paths within a word can be split by [`is_file_link_separator`]
|
||||
/// characters, and those separators may be part of file paths themselves.
|
||||
/// Possible file paths begin after a separator and end before a separator.
|
||||
/// For example, given /path/to/file:16:hello, it will return
|
||||
/// ["/path/to/file:16:hello", "/path/to/file:16", "/path/to/file", "16:hello", "hello"]
|
||||
///
|
||||
/// Tokens exceeding [`MAX_WORD_LEN_FOR_FILE_PATH`] or [`MAX_SEPARATORS_PER_WORD`]
|
||||
/// yield no candidates to bound the substring enumeration.
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
fn possible_file_paths_in_word(word: &str) -> impl Iterator<Item = &str> {
|
||||
let separator_byte_ranges = separator_byte_ranges_for_file_path_search(word);
|
||||
let mut possible_path_byte_ranges = vec![];
|
||||
for (i, start_index) in separator_byte_indices.iter().cloned().enumerate() {
|
||||
for end_index in separator_byte_indices.iter().skip(i + 1).cloned() {
|
||||
if start_index + 1 < end_index {
|
||||
possible_path_byte_ranges.push(start_index + 1..end_index);
|
||||
for (i, start_separator) in separator_byte_ranges.iter().cloned().enumerate() {
|
||||
for end_separator in separator_byte_ranges.iter().skip(i + 1).cloned() {
|
||||
if start_separator.end < end_separator.start {
|
||||
possible_path_byte_ranges.push(start_separator.end..end_separator.start);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sort by longest to shortest.
|
||||
possible_path_byte_ranges.sort_by(|a, b| (b.end - b.start).cmp(&(a.end - a.start)));
|
||||
possible_path_byte_ranges.sort_by(|a, b| {
|
||||
(b.end.as_usize() - b.start.as_usize()).cmp(&(a.end.as_usize() - a.start.as_usize()))
|
||||
});
|
||||
possible_path_byte_ranges
|
||||
.into_iter()
|
||||
.map(|range| &word[(range.start as usize)..(range.end as usize)])
|
||||
.map(|range| &word[range.start.as_usize()..range.end.as_usize()])
|
||||
}
|
||||
|
||||
/// Returns a DetectedLink::FilePath if expanded_path is a valid path that actually exists on the file system.
|
||||
@@ -474,7 +511,7 @@ fn detect_line_ranges_after_file_path(
|
||||
.char_indices()
|
||||
.map(|(offs, ch)| (offs + file_path_byte_end, ch));
|
||||
|
||||
// Finds an opening paranthesis, allowing some whitespace after file path, or returns None on failure
|
||||
// Finds an opening parenthesis, allowing some whitespace after file path, or returns None on failure
|
||||
let mut paren_start_idx = None;
|
||||
for (char_idx, ch) in chars_iter {
|
||||
if ch == '(' {
|
||||
@@ -486,7 +523,7 @@ fn detect_line_ranges_after_file_path(
|
||||
}
|
||||
let paren_start_idx = paren_start_idx?;
|
||||
|
||||
// Find the matching closing paranthesis, or returns None on failure
|
||||
// Find the matching closing parenthesis, or returns None on failure
|
||||
let paren_end_index = paren_start_idx + text[paren_start_idx..].find(')')?;
|
||||
|
||||
// Extract the content between parentheses, and parse valid line ranges
|
||||
@@ -718,5 +755,5 @@ pub(crate) fn detect_links(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "link_detection_test.rs"]
|
||||
#[path = "link_detection_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
use itertools::Itertools;
|
||||
|
||||
#[test]
|
||||
fn test_possible_file_paths_in_word() {
|
||||
let word = "/path/to/file:16:hello";
|
||||
let possible_paths = possible_file_paths_in_word(word).collect_vec();
|
||||
assert_eq!(
|
||||
possible_paths,
|
||||
vec![
|
||||
"/path/to/file:16:hello",
|
||||
"/path/to/file:16",
|
||||
"/path/to/file",
|
||||
"16:hello",
|
||||
"hello",
|
||||
"16"
|
||||
]
|
||||
);
|
||||
|
||||
let word = "/path/to/file:162:47.";
|
||||
let possible_paths = possible_file_paths_in_word(word).collect_vec();
|
||||
assert_eq!(
|
||||
possible_paths,
|
||||
vec![
|
||||
"/path/to/file:162:47.",
|
||||
"/path/to/file:162:47",
|
||||
"/path/to/file:162",
|
||||
"/path/to/file",
|
||||
"162:47.",
|
||||
"162:47",
|
||||
"162",
|
||||
"47.",
|
||||
"47"
|
||||
]
|
||||
);
|
||||
|
||||
let word = "<Cargo.toml:16:4>";
|
||||
let possible_paths = possible_file_paths_in_word(word).collect_vec();
|
||||
assert_eq!(
|
||||
possible_paths,
|
||||
vec![
|
||||
"<Cargo.toml:16:4>",
|
||||
"<Cargo.toml:16:4",
|
||||
"Cargo.toml:16:4>",
|
||||
"Cargo.toml:16:4",
|
||||
"<Cargo.toml:16",
|
||||
"Cargo.toml:16",
|
||||
"<Cargo.toml",
|
||||
"Cargo.toml",
|
||||
"16:4>",
|
||||
"16:4",
|
||||
"16",
|
||||
"4>",
|
||||
"4"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_possible_file_paths_in_word_multibyte() {
|
||||
let word = "/path/音楽/テストファイル.txt:16:ḧeĹḹo";
|
||||
let possible_paths = possible_file_paths_in_word(word).collect_vec();
|
||||
assert_eq!(
|
||||
possible_paths,
|
||||
vec![
|
||||
"/path/音楽/テストファイル.txt:16:ḧeĹḹo",
|
||||
"/path/音楽/テストファイル.txt:16",
|
||||
"/path/音楽/テストファイル.txt",
|
||||
"16:ḧeĹḹo",
|
||||
"ḧeĹḹo",
|
||||
"16"
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
use itertools::Itertools;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_possible_file_paths_in_word() {
|
||||
let word = "/path/to/file:16:hello";
|
||||
let possible_paths = possible_file_paths_in_word(word).collect_vec();
|
||||
assert_eq!(
|
||||
possible_paths,
|
||||
vec![
|
||||
"/path/to/file:16:hello",
|
||||
"/path/to/file:16",
|
||||
"/path/to/file",
|
||||
"16:hello",
|
||||
"hello",
|
||||
"16"
|
||||
]
|
||||
);
|
||||
|
||||
let word = "/path/to/file:162:47.";
|
||||
let possible_paths = possible_file_paths_in_word(word).collect_vec();
|
||||
assert_eq!(
|
||||
possible_paths,
|
||||
vec![
|
||||
"/path/to/file:162:47.",
|
||||
"/path/to/file:162:47",
|
||||
"/path/to/file:162",
|
||||
"/path/to/file",
|
||||
"162:47.",
|
||||
"162:47",
|
||||
"162",
|
||||
"47.",
|
||||
"47"
|
||||
]
|
||||
);
|
||||
|
||||
let word = "<Cargo.toml:16:4>";
|
||||
let possible_paths = possible_file_paths_in_word(word).collect_vec();
|
||||
assert_eq!(
|
||||
possible_paths,
|
||||
vec![
|
||||
"<Cargo.toml:16:4>",
|
||||
"<Cargo.toml:16:4",
|
||||
"Cargo.toml:16:4>",
|
||||
"Cargo.toml:16:4",
|
||||
"<Cargo.toml:16",
|
||||
"Cargo.toml:16",
|
||||
"<Cargo.toml",
|
||||
"Cargo.toml",
|
||||
"16:4>",
|
||||
"16:4",
|
||||
"16",
|
||||
"4>",
|
||||
"4"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_possible_file_paths_in_word_multibyte() {
|
||||
let word = "/path/音楽/テストファイル.txt:16:ḧeĹḹo";
|
||||
let possible_paths = possible_file_paths_in_word(word).collect_vec();
|
||||
assert_eq!(
|
||||
possible_paths,
|
||||
vec![
|
||||
"/path/音楽/テストファイル.txt:16:ḧeĹḹo",
|
||||
"/path/音楽/テストファイル.txt:16",
|
||||
"/path/音楽/テストファイル.txt",
|
||||
"16:ḧeĹḹo",
|
||||
"ḧeĹḹo",
|
||||
"16"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_possible_file_paths_in_tree_output() {
|
||||
let word = "│└──alpha.md";
|
||||
let possible_paths = possible_file_paths_in_word(word).collect_vec();
|
||||
assert!(possible_paths.contains(&"alpha.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_possible_file_paths_in_tree_output_multibyte_filename() {
|
||||
let word = "│└──音楽.md";
|
||||
let possible_paths = possible_file_paths_in_word(word).collect_vec();
|
||||
assert!(possible_paths.contains(&"音楽.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_possible_file_paths_in_tree_output_absolute_path_leaf() {
|
||||
let word = "│└──/tmp/foo.md";
|
||||
let possible_paths = possible_file_paths_in_word(word).collect_vec();
|
||||
assert!(possible_paths.contains(&"/tmp/foo.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_possible_file_paths_in_word_cjk_punctuation() {
|
||||
// Fullwidth colon (U+FF1A) directly touching a path — common in CJK prose
|
||||
// such as `路径:/path/to/file`.
|
||||
let word = "路径:/path/to/file.md";
|
||||
let possible_paths = possible_file_paths_in_word(word).collect_vec();
|
||||
assert!(possible_paths.contains(&"/path/to/file.md"));
|
||||
assert!(possible_paths.contains(&"路径"));
|
||||
|
||||
// Fullwidth parentheses (U+FF08 / U+FF09) wrapping a path.
|
||||
let word = "(/path/to/file)";
|
||||
let possible_paths = possible_file_paths_in_word(word).collect_vec();
|
||||
assert!(possible_paths.contains(&"/path/to/file"));
|
||||
|
||||
// CJK corner brackets (U+300C / U+300D) wrapping a path.
|
||||
let word = "「/path/to/file」";
|
||||
let possible_paths = possible_file_paths_in_word(word).collect_vec();
|
||||
assert!(possible_paths.contains(&"/path/to/file"));
|
||||
|
||||
// Ideographic full stop (U+3002) following a path.
|
||||
let word = "/path/to/file。";
|
||||
let possible_paths = possible_file_paths_in_word(word).collect_vec();
|
||||
assert!(possible_paths.contains(&"/path/to/file"));
|
||||
|
||||
// Fullwidth comma (U+FF0C) between paths.
|
||||
let word = "/a/b,/c/d";
|
||||
let possible_paths = possible_file_paths_in_word(word).collect_vec();
|
||||
assert!(possible_paths.contains(&"/a/b"));
|
||||
assert!(possible_paths.contains(&"/c/d"));
|
||||
|
||||
// CJK letters (general category Lo) must NOT split a token, otherwise paths
|
||||
// legitimately containing CJK characters would be fragmented.
|
||||
let word = "/path/音楽/テスト.txt";
|
||||
let possible_paths = possible_file_paths_in_word(word).collect_vec();
|
||||
assert!(possible_paths.contains(&"/path/音楽/テスト.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_possible_file_paths_in_word_skips_oversized_token() {
|
||||
let oversized = "a".repeat(MAX_WORD_LEN_FOR_FILE_PATH + 1);
|
||||
assert!(possible_file_paths_in_word(&oversized).next().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_possible_file_paths_in_word_accepts_token_at_word_length_cap() {
|
||||
let at_cap = "a".repeat(MAX_WORD_LEN_FOR_FILE_PATH);
|
||||
let possible_paths = possible_file_paths_in_word(&at_cap).collect_vec();
|
||||
assert_eq!(possible_paths, vec![at_cap.as_str()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_possible_file_paths_in_word_skips_token_with_too_many_separators() {
|
||||
let too_many_separators = ":".repeat(MAX_SEPARATORS_PER_WORD + 1);
|
||||
assert!(possible_file_paths_in_word(&too_many_separators)
|
||||
.next()
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_possible_file_paths_in_word_accepts_token_at_separator_count_cap() {
|
||||
// A token with separators interleaved between letters: e.g. "a:a:a:...:a".
|
||||
// Has exactly MAX_SEPARATORS_PER_WORD ':' characters and is non-empty
|
||||
// between them, so we expect at least one candidate (e.g. "a").
|
||||
let mut at_cap = String::with_capacity(MAX_SEPARATORS_PER_WORD * 2 + 1);
|
||||
at_cap.push('a');
|
||||
for _ in 0..MAX_SEPARATORS_PER_WORD {
|
||||
at_cap.push(':');
|
||||
at_cap.push('a');
|
||||
}
|
||||
assert!(possible_file_paths_in_word(&at_cap).next().is_some());
|
||||
}
|
||||
@@ -217,5 +217,5 @@ where
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "meta_test.rs"]
|
||||
#[path = "meta_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
+4
-4
@@ -9,9 +9,8 @@ pub mod image;
|
||||
pub(crate) mod link_detection;
|
||||
pub mod links;
|
||||
pub mod openable_file_type;
|
||||
#[cfg(feature = "local_tty")]
|
||||
pub mod path;
|
||||
pub mod sync;
|
||||
pub mod repo_detection;
|
||||
pub mod time_format;
|
||||
pub mod tooltips;
|
||||
pub(crate) mod traffic_lights;
|
||||
@@ -20,11 +19,12 @@ pub mod vm_detection;
|
||||
#[cfg(windows)]
|
||||
pub mod windows;
|
||||
|
||||
use itertools::Itertools;
|
||||
use std::cmp::Ordering;
|
||||
use std::fmt;
|
||||
use std::ops::Range;
|
||||
|
||||
use itertools::Itertools;
|
||||
|
||||
pub fn merge_ranges(mut ranges: Vec<Range<usize>>) -> Vec<Range<usize>> {
|
||||
let mut i = 1;
|
||||
while i < ranges.len() {
|
||||
@@ -75,7 +75,7 @@ impl fmt::Debug for AsciiDebug<'_> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "\"")?;
|
||||
for &byte in self.0 {
|
||||
// Check if the byte is a standard printable charcter.
|
||||
// Check if the byte is a standard printable character.
|
||||
if (32..126).contains(&byte) {
|
||||
write!(f, "{}", byte as char)?;
|
||||
} else {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
//! File type detection utilities for determining if files can be opened in Warp.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
pub use warp_util::file_type::{is_binary_file, is_file_content_binary, is_markdown_file};
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::util::file::external_editor::{settings::EditorChoice, Editor, EditorSettings};
|
||||
pub use galaxy_util::file_type::{is_binary_file, is_file_content_binary, is_markdown_file};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(
|
||||
Debug,
|
||||
@@ -59,8 +61,7 @@ pub enum FileTarget {
|
||||
/// Checks if a file is a code file with language support.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub fn is_supported_code_file(path: impl AsRef<Path>) -> bool {
|
||||
let path = path.as_ref();
|
||||
languages::language_by_filename(path).is_some()
|
||||
languages::language_by_local_filename(path.as_ref()).is_some()
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "local_fs"))]
|
||||
@@ -81,6 +82,62 @@ pub fn is_supported_image_file(path: impl AsRef<Path>) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Returns true if `path` looks like a shell script the user intends to run when
|
||||
/// "Open with Warp" is invoked from Finder/another app via a `file://` URL.
|
||||
///
|
||||
/// Policy: extension in {sh, bash, zsh, fish, ksh} with the user-execute bit set on Unix,
|
||||
/// or extension in {ps1, bat, cmd} on Windows (no x-bit concept). On Unix, files with no
|
||||
/// extension but a `#!` shebang and the user-execute bit set also qualify.
|
||||
///
|
||||
/// Narrow on purpose: this only affects the URI entry point, not "Open in New Tab" from
|
||||
/// other UI surfaces, which still want shell scripts viewable in the editor.
|
||||
/// Returns true if `path` exists and starts with a `#!` shebang. Reads only the
|
||||
/// first two bytes — the URI entry point is reached from a `file://` URL, so the
|
||||
/// file is attacker-controlled in size and `std::fs::read` would risk an OOM.
|
||||
pub(crate) fn starts_with_shebang(path: &Path) -> bool {
|
||||
use std::io::Read;
|
||||
let mut prefix = [0u8; 2];
|
||||
match std::fs::File::open(path) {
|
||||
Ok(mut file) => file.read_exact(&mut prefix).is_ok() && prefix == [b'#', b'!'],
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn is_runnable_shell_script(path: &Path) -> bool {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
// Match the documented routing policy: only the owner's execute bit counts.
|
||||
// A file `chmod 070` belongs to a group, not to the user invoking Warp.
|
||||
let has_user_x_bit = std::fs::metadata(path)
|
||||
.map(|m| m.permissions().mode() & 0o100 != 0)
|
||||
.unwrap_or(false);
|
||||
if !has_user_x_bit {
|
||||
return false;
|
||||
}
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.to_ascii_lowercase());
|
||||
if let Some(ext) = ext.as_deref() {
|
||||
return matches!(ext, "sh" | "bash" | "zsh" | "fish" | "ksh" | "command");
|
||||
}
|
||||
starts_with_shebang(path)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub fn is_runnable_shell_script(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.to_ascii_lowercase())
|
||||
.is_some_and(|ext| matches!(ext.as_str(), "ps1" | "bat" | "cmd"))
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
pub fn is_runnable_shell_script(_path: &Path) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Determines if a file can be opened in Warp and returns its type.
|
||||
/// Returns `None` if the file is binary and should not be opened.
|
||||
pub fn is_file_openable_in_warp(path: &Path) -> Option<OpenableFileType> {
|
||||
@@ -177,171 +234,5 @@ pub fn resolve_file_target_with_editor_choice(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use settings::Setting as _;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn test_binary_files_not_openable() {
|
||||
assert!(is_file_openable_in_warp(Path::new("image.png")).is_none());
|
||||
assert!(is_file_openable_in_warp(Path::new("video.mp4")).is_none());
|
||||
assert!(is_file_openable_in_warp(Path::new("binary.exe")).is_none());
|
||||
assert!(is_file_openable_in_warp(Path::new("archive.zip")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn test_open_code_panels_file_editor_default_is_warp() {
|
||||
use crate::util::file::external_editor::settings::OpenCodePanelsFileEditor;
|
||||
|
||||
assert_eq!(
|
||||
OpenCodePanelsFileEditor::default_value(),
|
||||
EditorChoice::Warp
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn test_resolve_file_target_markdown_viewer_precedence() {
|
||||
let target = resolve_file_target_with_editor_choice(
|
||||
Path::new("README.md"),
|
||||
EditorChoice::ExternalEditor(Editor::VSCode),
|
||||
true, /* prefer_markdown_viewer */
|
||||
EditorLayout::SplitPane,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(target, FileTarget::MarkdownViewer(EditorLayout::SplitPane));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn test_resolve_file_target_warp_uses_default_layout() {
|
||||
let target = resolve_file_target_with_editor_choice(
|
||||
Path::new("data.txt"),
|
||||
EditorChoice::Warp,
|
||||
true, /* prefer_markdown_viewer */
|
||||
EditorLayout::NewTab,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(target, FileTarget::CodeEditor(EditorLayout::NewTab));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn test_resolve_file_target_binary_is_system_generic() {
|
||||
let target = resolve_file_target_with_editor_choice(
|
||||
Path::new("image.png"),
|
||||
EditorChoice::Warp,
|
||||
true, /* prefer_markdown_viewer */
|
||||
EditorLayout::SplitPane,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(target, FileTarget::SystemGeneric);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn test_resolve_file_target_binary_uses_env_editor() {
|
||||
let target = resolve_file_target_with_editor_choice(
|
||||
Path::new("image.png"),
|
||||
EditorChoice::EnvEditor,
|
||||
true, /* prefer_markdown_viewer */
|
||||
EditorLayout::SplitPane,
|
||||
None,
|
||||
);
|
||||
assert_eq!(target, FileTarget::EnvEditor);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_markdown_files() {
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("README.md")),
|
||||
Some(OpenableFileType::Markdown)
|
||||
);
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("doc.markdown")),
|
||||
Some(OpenableFileType::Markdown)
|
||||
);
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("README")),
|
||||
Some(OpenableFileType::Markdown)
|
||||
);
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("CHANGELOG")),
|
||||
Some(OpenableFileType::Markdown)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn test_code_files() {
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("main.rs")),
|
||||
Some(OpenableFileType::Code)
|
||||
);
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("app.js")),
|
||||
Some(OpenableFileType::Code)
|
||||
);
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("script.py")),
|
||||
Some(OpenableFileType::Code)
|
||||
);
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("config.json")),
|
||||
Some(OpenableFileType::Code)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(feature = "local_fs"))]
|
||||
fn test_code_files() {
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("main.rs")),
|
||||
Some(OpenableFileType::Text)
|
||||
);
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("app.js")),
|
||||
Some(OpenableFileType::Text)
|
||||
);
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("script.py")),
|
||||
Some(OpenableFileType::Text)
|
||||
);
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("config.json")),
|
||||
Some(OpenableFileType::Text)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_text_files() {
|
||||
// Files that are text but don't have language support
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("data.txt")),
|
||||
Some(OpenableFileType::Text)
|
||||
);
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("data.csv")),
|
||||
Some(OpenableFileType::Text)
|
||||
);
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("file.svg")),
|
||||
Some(OpenableFileType::Text)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_supported_code_file() {
|
||||
assert!(is_supported_code_file(Path::new("main.rs")));
|
||||
assert!(is_supported_code_file(Path::new("app.js")));
|
||||
assert!(is_supported_code_file(Path::new("script.py")));
|
||||
assert!(!is_supported_code_file(Path::new("data.txt")));
|
||||
assert!(!is_supported_code_file(Path::new("image.png")));
|
||||
}
|
||||
}
|
||||
#[path = "openable_file_type_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
use std::path::Path;
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
use settings::Setting as _;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_binary_files_not_openable() {
|
||||
assert!(is_file_openable_in_warp(Path::new("image.png")).is_none());
|
||||
assert!(is_file_openable_in_warp(Path::new("video.mp4")).is_none());
|
||||
assert!(is_file_openable_in_warp(Path::new("binary.exe")).is_none());
|
||||
assert!(is_file_openable_in_warp(Path::new("archive.zip")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn test_open_code_panels_file_editor_default_is_warp() {
|
||||
use crate::util::file::external_editor::settings::OpenCodePanelsFileEditor;
|
||||
|
||||
assert_eq!(
|
||||
OpenCodePanelsFileEditor::default_value(),
|
||||
EditorChoice::Warp
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn test_resolve_file_target_markdown_viewer_precedence() {
|
||||
let target = resolve_file_target_with_editor_choice(
|
||||
Path::new("README.md"),
|
||||
EditorChoice::ExternalEditor(Editor::VSCode),
|
||||
true, /* prefer_markdown_viewer */
|
||||
EditorLayout::SplitPane,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(target, FileTarget::MarkdownViewer(EditorLayout::SplitPane));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn test_resolve_file_target_warp_uses_default_layout() {
|
||||
let target = resolve_file_target_with_editor_choice(
|
||||
Path::new("data.txt"),
|
||||
EditorChoice::Warp,
|
||||
true, /* prefer_markdown_viewer */
|
||||
EditorLayout::NewTab,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(target, FileTarget::CodeEditor(EditorLayout::NewTab));
|
||||
}
|
||||
|
||||
/// `file.open` from local control relies on this resolver never routing to an
|
||||
/// external editor or the system default app, even when user settings prefer one.
|
||||
#[test]
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn test_resolve_file_target_to_open_in_warp_never_leaves_warp() {
|
||||
use crate::util::file::external_editor::settings::{
|
||||
OpenCodePanelsFileEditor, OpenConversationLayoutPreference, OpenFileEditor, OpenFileLayout,
|
||||
PreferMarkdownViewer, PreferTabbedEditorView,
|
||||
};
|
||||
|
||||
let settings = EditorSettings {
|
||||
open_file_editor: OpenFileEditor::new(Some(EditorChoice::ExternalEditor(Editor::VSCode))),
|
||||
open_code_panels_file_editor: OpenCodePanelsFileEditor::new(Some(
|
||||
EditorChoice::ExternalEditor(Editor::VSCode),
|
||||
)),
|
||||
open_file_layout: OpenFileLayout::new(None),
|
||||
prefer_markdown_viewer: PreferMarkdownViewer::new(Some(false)),
|
||||
prefer_tabbed_editor_view: PreferTabbedEditorView::new(None),
|
||||
open_conversation_layout_preference: OpenConversationLayoutPreference::new(None),
|
||||
};
|
||||
for path in ["README.md", "data.txt", "main.rs", "image.png", "script.sh"] {
|
||||
let target = resolve_file_target_to_open_in_warp(Path::new(path), &settings, None);
|
||||
assert!(
|
||||
matches!(
|
||||
target,
|
||||
FileTarget::CodeEditor(_) | FileTarget::MarkdownViewer(_)
|
||||
),
|
||||
"{path} must resolve to an in-Warp surface, got {target:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn test_resolve_file_target_binary_is_system_generic() {
|
||||
let target = resolve_file_target_with_editor_choice(
|
||||
Path::new("image.png"),
|
||||
EditorChoice::Warp,
|
||||
true, /* prefer_markdown_viewer */
|
||||
EditorLayout::SplitPane,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(target, FileTarget::SystemGeneric);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn test_resolve_file_target_binary_uses_env_editor() {
|
||||
let target = resolve_file_target_with_editor_choice(
|
||||
Path::new("image.png"),
|
||||
EditorChoice::EnvEditor,
|
||||
true, /* prefer_markdown_viewer */
|
||||
EditorLayout::SplitPane,
|
||||
None,
|
||||
);
|
||||
assert_eq!(target, FileTarget::EnvEditor);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_markdown_files() {
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("README.md")),
|
||||
Some(OpenableFileType::Markdown)
|
||||
);
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("doc.markdown")),
|
||||
Some(OpenableFileType::Markdown)
|
||||
);
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("README")),
|
||||
Some(OpenableFileType::Markdown)
|
||||
);
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("CHANGELOG")),
|
||||
Some(OpenableFileType::Markdown)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn test_code_files() {
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("main.rs")),
|
||||
Some(OpenableFileType::Code)
|
||||
);
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("app.js")),
|
||||
Some(OpenableFileType::Code)
|
||||
);
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("script.py")),
|
||||
Some(OpenableFileType::Code)
|
||||
);
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("config.json")),
|
||||
Some(OpenableFileType::Code)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(feature = "local_fs"))]
|
||||
fn test_code_files() {
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("main.rs")),
|
||||
Some(OpenableFileType::Text)
|
||||
);
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("app.js")),
|
||||
Some(OpenableFileType::Text)
|
||||
);
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("script.py")),
|
||||
Some(OpenableFileType::Text)
|
||||
);
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("config.json")),
|
||||
Some(OpenableFileType::Text)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_text_files() {
|
||||
// Files that are text but don't have language support
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("data.txt")),
|
||||
Some(OpenableFileType::Text)
|
||||
);
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("data.csv")),
|
||||
Some(OpenableFileType::Text)
|
||||
);
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("file.svg")),
|
||||
Some(OpenableFileType::Text)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_supported_code_file() {
|
||||
assert!(is_supported_code_file(Path::new("main.rs")));
|
||||
assert!(is_supported_code_file(Path::new("app.js")));
|
||||
assert!(is_supported_code_file(Path::new("script.py")));
|
||||
assert!(!is_supported_code_file(Path::new("data.txt")));
|
||||
assert!(!is_supported_code_file(Path::new("image.png")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn test_is_runnable_shell_script_executable_sh() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("hello.sh");
|
||||
std::fs::write(&p, b"#!/bin/bash\necho hi\n").unwrap();
|
||||
let mut perms = std::fs::metadata(&p).unwrap().permissions();
|
||||
perms.set_mode(0o755);
|
||||
std::fs::set_permissions(&p, perms).unwrap();
|
||||
assert!(is_runnable_shell_script(&p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn test_is_runnable_shell_script_non_executable_sh() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("hello.sh");
|
||||
std::fs::write(&p, b"#!/bin/bash\necho hi\n").unwrap();
|
||||
let mut perms = std::fs::metadata(&p).unwrap().permissions();
|
||||
perms.set_mode(0o644);
|
||||
std::fs::set_permissions(&p, perms).unwrap();
|
||||
assert!(!is_runnable_shell_script(&p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn test_is_runnable_shell_script_group_only_executable_rejected() {
|
||||
// Mode 0o070: group-x and group-r/w only, no user-execute. Must NOT classify
|
||||
// as runnable — only the owner's execute bit drives the routing decision.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("group_only.sh");
|
||||
std::fs::write(&p, b"#!/bin/bash\necho hi\n").unwrap();
|
||||
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o070)).unwrap();
|
||||
assert!(!is_runnable_shell_script(&p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn test_is_runnable_shell_script_other_shell_extensions() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for name in ["run.bash", "run.zsh", "run.fish", "run.ksh", "run.command"] {
|
||||
let p = dir.path().join(name);
|
||||
std::fs::write(&p, b"#!/bin/sh\n:\n").unwrap();
|
||||
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
assert!(is_runnable_shell_script(&p), "{name} should be runnable");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn test_is_runnable_shell_script_shebang_no_extension() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("noext");
|
||||
std::fs::write(&p, b"#!/bin/sh\necho hi\n").unwrap();
|
||||
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
assert!(is_runnable_shell_script(&p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn test_is_runnable_shell_script_shebang_no_extension_no_x_bit() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("noext");
|
||||
std::fs::write(&p, b"#!/bin/sh\necho hi\n").unwrap();
|
||||
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
assert!(!is_runnable_shell_script(&p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn test_is_runnable_shell_script_plain_text_rejected() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("notes.txt");
|
||||
std::fs::write(&p, b"just some text\n").unwrap();
|
||||
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
assert!(!is_runnable_shell_script(&p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn test_is_runnable_shell_script_symlink_to_executable() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let target = dir.path().join("real.sh");
|
||||
std::fs::write(&target, b"#!/bin/sh\n:\n").unwrap();
|
||||
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let link = dir.path().join("link.sh");
|
||||
std::os::unix::fs::symlink(&target, &link).unwrap();
|
||||
assert!(is_runnable_shell_script(&link));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_starts_with_shebang_present() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("script");
|
||||
std::fs::write(&p, b"#!/bin/sh\necho hi\n").unwrap();
|
||||
assert!(starts_with_shebang(&p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_starts_with_shebang_absent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("plain");
|
||||
std::fs::write(&p, b"echo hi\n").unwrap();
|
||||
assert!(!starts_with_shebang(&p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_starts_with_shebang_one_byte_file() {
|
||||
// `read_exact(&mut [0u8; 2])` must short-read on a single-byte file.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("tiny");
|
||||
std::fs::write(&p, b"#").unwrap();
|
||||
assert!(!starts_with_shebang(&p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_starts_with_shebang_missing_path() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("nope");
|
||||
assert!(!starts_with_shebang(&p));
|
||||
}
|
||||
+97
-5
@@ -1,13 +1,71 @@
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
env,
|
||||
ffi::OsStr,
|
||||
path::{self, Path},
|
||||
path::{self, Path, PathBuf},
|
||||
};
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use is_executable::IsExecutable as _;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use itertools::Itertools as _;
|
||||
use warp_util::local_or_remote_path::LocalOrRemotePath;
|
||||
use warpui::{AppContext, SingletonEntity};
|
||||
|
||||
use crate::remote_server::manager::RemoteServerManager;
|
||||
|
||||
/// Fallback label used when a `RemotePath`'s host is not currently tracked.
|
||||
/// Matches the fallback in `terminal::writeable_pty::remote_server_controller::connection_label_from_user_and_host`.
|
||||
const UNKNOWN_HOST_LABEL: &str = "Remote host";
|
||||
|
||||
/// Returns the display name of a local or remote path, prefixed with the
|
||||
/// host label for remote paths.
|
||||
pub fn display_name_with_host(path: &LocalOrRemotePath, ctx: &AppContext) -> String {
|
||||
let name = path.display_name();
|
||||
match path {
|
||||
LocalOrRemotePath::Local(_) => name.to_string(),
|
||||
LocalOrRemotePath::Remote(remote) => {
|
||||
let host_label = RemoteServerManager::as_ref(ctx)
|
||||
.host_label(&remote.host_id)
|
||||
.unwrap_or(UNKNOWN_HOST_LABEL);
|
||||
format!("{host_label}:{name}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the display path of a local or remote path,
|
||||
/// prefixed with the host label for remote paths.
|
||||
///
|
||||
/// When `abbreviate_home` is true, local paths under the user's home directory
|
||||
/// are abbreviated with a `~/` prefix. The flag is ignored for remote paths,
|
||||
/// whose home directory lives on a different machine.
|
||||
pub fn display_path_with_host(
|
||||
path: &LocalOrRemotePath,
|
||||
abbreviate_home: bool,
|
||||
ctx: &AppContext,
|
||||
) -> String {
|
||||
match path {
|
||||
LocalOrRemotePath::Local(local_path) => {
|
||||
if abbreviate_home {
|
||||
dirs::home_dir()
|
||||
.and_then(|home| local_path.strip_prefix(&home).ok())
|
||||
.map(|relative| format!("~/{}", relative.display()))
|
||||
.unwrap_or_else(|| local_path.display().to_string())
|
||||
} else {
|
||||
path.display_path()
|
||||
}
|
||||
}
|
||||
LocalOrRemotePath::Remote(remote) => {
|
||||
let host_label = RemoteServerManager::as_ref(ctx)
|
||||
.host_label(&remote.host_id)
|
||||
.unwrap_or(UNKNOWN_HOST_LABEL);
|
||||
format!("{host_label}:{}", path.display_path())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub fn file_exists_and_is_executable(path: &Path) -> bool {
|
||||
// We need to check that the file exists, as the `is_executable` crate doesn't validate this on
|
||||
// Windows.
|
||||
@@ -21,6 +79,7 @@ pub fn file_exists_and_is_executable(path: &Path) -> bool {
|
||||
/// Callers that need to resolve against a different PATH (e.g. one
|
||||
/// captured from the user's interactive login shell) should use
|
||||
/// [`resolve_executable_in_path`] directly.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub fn resolve_executable(command: &str) -> Option<Cow<'_, Path>> {
|
||||
let path_var = env::var_os("PATH").unwrap_or_default();
|
||||
resolve_executable_in_path(command, &path_var)
|
||||
@@ -33,20 +92,53 @@ pub fn resolve_executable(command: &str) -> Option<Cow<'_, Path>> {
|
||||
/// captured from the user's interactive login shell, matching how
|
||||
/// MCP/LSP find binaries). Callers that want the process's PATH should
|
||||
/// use [`resolve_executable`] instead.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub fn resolve_executable_in_path<'a>(command: &'a str, path_env: &OsStr) -> Option<Cow<'a, Path>> {
|
||||
if command.contains(path::MAIN_SEPARATOR) {
|
||||
let path = Path::new(command);
|
||||
return file_exists_and_is_executable(path).then_some(Cow::Borrowed(path));
|
||||
}
|
||||
for path_dir in env::split_paths(path_env).unique() {
|
||||
let resolved = path_dir.join(command);
|
||||
if file_exists_and_is_executable(&resolved) {
|
||||
if let Some(resolved) = resolve_executable_in_dir(&path_dir, command) {
|
||||
return Some(Cow::Owned(resolved));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "path_test.rs"]
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn resolve_executable_in_dir(path_dir: &Path, command: &str) -> Option<PathBuf> {
|
||||
let resolved = path_dir.join(command);
|
||||
if file_exists_and_is_executable(&resolved) {
|
||||
return Some(resolved);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
if Path::new(command).extension().is_none() {
|
||||
for ext in windows_path_extensions() {
|
||||
let resolved = path_dir.join(format!("{command}{ext}"));
|
||||
if file_exists_and_is_executable(&resolved) {
|
||||
return Some(resolved);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_path_extensions() -> impl Iterator<Item = String> {
|
||||
env::var_os("PATHEXT")
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.split(';')
|
||||
.map(str::trim)
|
||||
.filter(|ext| !ext.is_empty())
|
||||
.map(str::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_family = "wasm")))]
|
||||
#[path = "path_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -19,9 +19,10 @@ fn test_trim_newline() {
|
||||
#[cfg(not(windows))]
|
||||
#[test]
|
||||
fn test_resolve_command() {
|
||||
use crate::util::path::resolve_executable;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::util::path::resolve_executable;
|
||||
|
||||
assert_eq!(
|
||||
&resolve_executable("/bin/sh").unwrap(),
|
||||
Path::new("/bin/sh")
|
||||
@@ -47,3 +48,47 @@ fn test_resolve_command() {
|
||||
// Note the trailing space.
|
||||
assert!(resolve_executable("zsh ").is_none());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
struct EnvVarGuard {
|
||||
key: &'static str,
|
||||
original: Option<std::ffi::OsString>,
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
impl EnvVarGuard {
|
||||
fn set(key: &'static str, value: impl Into<std::ffi::OsString>) -> Self {
|
||||
let original = std::env::var_os(key);
|
||||
std::env::set_var(key, value.into());
|
||||
Self { key, original }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
impl Drop for EnvVarGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(original) = &self.original {
|
||||
std::env::set_var(self.key, original);
|
||||
} else {
|
||||
std::env::remove_var(self.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn test_resolve_command_uses_pathext() {
|
||||
use crate::util::path::resolve_executable_in_path;
|
||||
|
||||
let temp_dir = tempfile::TempDir::new().unwrap();
|
||||
let command_path = temp_dir.path().join("codex.cmd");
|
||||
std::fs::write(&command_path, "@echo off\r\n").unwrap();
|
||||
|
||||
let _pathext = EnvVarGuard::set("PATHEXT", ".CMD;.EXE");
|
||||
let resolved = resolve_executable_in_path("codex", temp_dir.path().as_os_str()).unwrap();
|
||||
assert_eq!(
|
||||
resolved.as_ref().to_string_lossy().to_ascii_lowercase(),
|
||||
command_path.to_string_lossy().to_ascii_lowercase()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//! Helpers for constructing repo detection calls from view contexts.
|
||||
//!
|
||||
//! The core detection logic lives on
|
||||
//! [`DetectedRepositories::detect_possible_git_repo`]. This module provides
|
||||
//! a thin convenience layer that constructs the appropriate remote detection
|
||||
//! future from [`RemoteServerManager`] before delegating.
|
||||
|
||||
use std::future::Future;
|
||||
|
||||
use futures::future::ready;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use futures::future::Either;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use repo_metadata::repositories::DetectedRepositories;
|
||||
use repo_metadata::repositories::RepoDetectionSource;
|
||||
use galaxy_core::SessionId;
|
||||
use warp_util::local_or_remote_path::LocalOrRemotePath;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use warpui::SingletonEntity;
|
||||
use warpui::{View, ViewContext};
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::remote_server::manager::RemoteServerManager;
|
||||
|
||||
/// Describes whether the active session is local or remote.
|
||||
pub enum RepoDetectionSessionType {
|
||||
/// A local terminal session — repo detection runs on the local filesystem.
|
||||
Local,
|
||||
/// A remote SSH session — repo detection is delegated to the remote server
|
||||
/// via `navigate_to_directory`.
|
||||
Remote { session_id: SessionId },
|
||||
}
|
||||
|
||||
/// Detects the git repository root for the given working directory.
|
||||
///
|
||||
/// Constructs the appropriate remote detection future (if needed) and delegates
|
||||
/// to [`DetectedRepositories::detect_possible_git_repo`].
|
||||
///
|
||||
/// The caller is responsible for registering remote repo roots in
|
||||
/// `DetectedRepositories` and triggering downstream side effects (git status,
|
||||
/// code review, etc.) in the spawn callback.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub fn detect_possible_git_repo<V: View>(
|
||||
session_type: RepoDetectionSessionType,
|
||||
active_directory: &str,
|
||||
source: RepoDetectionSource,
|
||||
ctx: &mut ViewContext<V>,
|
||||
) -> impl Future<Output = Option<LocalOrRemotePath>> {
|
||||
// Build the remote detection future if this is a remote session.
|
||||
// For local sessions, pass None so DetectedRepositories uses the local path.
|
||||
// For remote sessions without a connected server, pass a future that
|
||||
// resolves to None immediately — this avoids falling through to local
|
||||
// detection, which would misclassify a remote CWD as a local repo if
|
||||
// the same absolute path happens to exist locally.
|
||||
let remote_detect = match session_type {
|
||||
RepoDetectionSessionType::Local => None,
|
||||
RepoDetectionSessionType::Remote { session_id } => {
|
||||
if RemoteServerManager::as_ref(ctx).is_session_potentially_active(session_id) {
|
||||
Some(Either::Left(RemoteServerManager::handle(ctx).update(
|
||||
ctx,
|
||||
|mgr, ctx| {
|
||||
mgr.navigate_to_directory(session_id, active_directory.to_string(), ctx)
|
||||
},
|
||||
)))
|
||||
} else {
|
||||
Some(Either::Right(ready(None)))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
DetectedRepositories::handle(ctx).update(ctx, |repos, ctx| {
|
||||
repos.detect_possible_git_repo(active_directory, source, remote_detect, ctx)
|
||||
})
|
||||
}
|
||||
|
||||
/// Repository detection is not available in WASM builds because
|
||||
/// `DetectedRepositories` is not registered there.
|
||||
#[cfg(target_family = "wasm")]
|
||||
pub fn detect_possible_git_repo<V: View>(
|
||||
_session_type: RepoDetectionSessionType,
|
||||
_active_directory: &str,
|
||||
_source: RepoDetectionSource,
|
||||
_ctx: &mut ViewContext<V>,
|
||||
) -> impl Future<Output = Option<LocalOrRemotePath>> {
|
||||
ready(None)
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
//! Synchronization utilities.
|
||||
|
||||
use std::future::Future;
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
};
|
||||
|
||||
use event_listener::Event;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sync_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
/// A set-once asynchronous condition variable.
|
||||
///
|
||||
/// Generally, a [condition variable](http://www.cs.cornell.edu/courses/cs3110/2012fa/recitations/rec16.html)
|
||||
/// lets tasks wait until some condition becomes true (for example, we might want to wait for the
|
||||
/// user to have logged in, or for the initial load of Warp Drive objects to have finished). When
|
||||
/// the condition becomes true, one or all of the waiting tasks can wake up and do their work.
|
||||
///
|
||||
/// This [`Condition`] implementation models the simpler case where a condition becomes true and
|
||||
/// is then *always* true (unless reset explicitly). This allows waiting for something to happen at least once. If a task
|
||||
/// starts waiting before the condition is met, it will block, but if the condition is already true,
|
||||
/// it continues immediately.
|
||||
///
|
||||
/// We use this in Warp Drive to wait for the initial load of changed objects to finish. Regular UI
|
||||
/// framework events aren't suitable, because they don't tell us if the load had *already*
|
||||
/// finished - a task that subscribed too late would block forever!
|
||||
///
|
||||
/// Also see [`std::sync::Condvar`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Condition {
|
||||
// This is more or less the reference example for async-listener:
|
||||
// https://github.com/smol-rs/event-listener
|
||||
flag: Arc<AtomicBool>,
|
||||
event: Arc<Event>,
|
||||
}
|
||||
|
||||
impl Condition {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
flag: Arc::new(AtomicBool::new(false)),
|
||||
event: Arc::new(Event::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark the condition as true.
|
||||
pub fn set(&self) {
|
||||
self.flag.store(true, Ordering::SeqCst);
|
||||
self.event.notify(usize::MAX);
|
||||
}
|
||||
|
||||
/// Reset the condition to false so that future [`wait`](Self::wait) calls
|
||||
/// will block until [`set`](Self::set) is called again.
|
||||
pub fn reset(&self) {
|
||||
self.flag.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Returns `true` if the condition has already been set.
|
||||
pub fn is_set(&self) -> bool {
|
||||
self.flag.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Asynchronously wait for the condition to be true.
|
||||
pub fn wait(&self) -> impl Future<Output = ()> {
|
||||
let flag = self.flag.clone();
|
||||
let event = self.event.clone();
|
||||
async move {
|
||||
// Loop in case of spurious wakeups.
|
||||
loop {
|
||||
// Check if the condition has already been set.
|
||||
if flag.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
|
||||
let listener = event.listen();
|
||||
|
||||
// Check the flag again after creating the listener, in case it was set while we
|
||||
// started listening.
|
||||
if flag.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
|
||||
listener.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Condition {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
use futures_lite::future;
|
||||
|
||||
use super::Condition;
|
||||
|
||||
#[test]
|
||||
fn test_condition_multiple_waiters() {
|
||||
future::block_on(async {
|
||||
let condition = Condition::new();
|
||||
|
||||
let mut listener1 = Box::pin(condition.wait());
|
||||
let mut listener2 = Box::pin(condition.wait());
|
||||
|
||||
// Neither listener should be ready.
|
||||
assert!(future::poll_once(&mut listener1).await.is_none());
|
||||
assert!(future::poll_once(&mut listener2).await.is_none());
|
||||
|
||||
condition.set();
|
||||
|
||||
// Now, both should complete.
|
||||
assert!(future::poll_once(listener1).await.is_some());
|
||||
assert!(future::poll_once(listener2).await.is_some());
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_condition_after_set() {
|
||||
future::block_on(async {
|
||||
let condition = Condition::new();
|
||||
condition.set();
|
||||
|
||||
// After the condition is set, waiting should complete immediately.
|
||||
assert!(future::poll_once(condition.wait()).await.is_some());
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_condition_multiple_sets() {
|
||||
future::block_on(async {
|
||||
let condition = Condition::new();
|
||||
|
||||
// Test that multiple interleavings of `wait` and `set` all complete as expected.
|
||||
let first = condition.wait();
|
||||
condition.set();
|
||||
let second = condition.wait();
|
||||
condition.set();
|
||||
let third = condition.wait();
|
||||
|
||||
assert!(future::poll_once(first).await.is_some());
|
||||
assert!(future::poll_once(second).await.is_some());
|
||||
assert!(future::poll_once(third).await.is_some());
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
use chrono::{DateTime, Duration, Local, Utc};
|
||||
use std::ops::Sub;
|
||||
|
||||
use chrono::{DateTime, Duration, Local, Utc};
|
||||
|
||||
// Some conversion ratios for time units.
|
||||
const SEC_TO_MS: f64 = 1000.;
|
||||
const MIN_TO_MS: f64 = 60. * SEC_TO_MS;
|
||||
|
||||
+13
-15
@@ -3,18 +3,16 @@
|
||||
#[cfg(feature = "local_fs")]
|
||||
use std::path::Path;
|
||||
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
Border, Container, CornerRadius, Flex, MouseStateHandle, ParentElement, Radius, Text,
|
||||
},
|
||||
ui_components::components::{Coords, UiComponent, UiComponentStyles},
|
||||
AppContext, Element, EventContext, SingletonEntity,
|
||||
use galaxyui::elements::{
|
||||
Border, Container, CornerRadius, Flex, MouseStateHandle, ParentElement, Radius, Text,
|
||||
};
|
||||
use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
|
||||
use warpui::{AppContext, Element, EventContext, SingletonEntity};
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance, settings::PrivacySettings, terminal::model::secrets::SecretLevel,
|
||||
ui_components::blended_colors,
|
||||
};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::settings::PrivacySettings;
|
||||
use crate::terminal::model::secrets::SecretLevel;
|
||||
use crate::ui_components::blended_colors;
|
||||
|
||||
/// A link to be shown in a tooltip
|
||||
pub struct TooltipLink<OnClick> {
|
||||
@@ -244,13 +242,13 @@ where
|
||||
/// - Whether Warp is an OS-level default editor (skips Markdown files)
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub fn should_show_open_in_warp_link(path: &Path, app: &AppContext) -> bool {
|
||||
use crate::{
|
||||
code::view::is_binary_file,
|
||||
notebooks::file::is_markdown_file,
|
||||
util::file::external_editor::{settings::EditorChoice, EditorSettings},
|
||||
};
|
||||
use galaxyui::SingletonEntity;
|
||||
|
||||
use crate::code::view::is_binary_file;
|
||||
use crate::notebooks::file::is_markdown_file;
|
||||
use crate::util::file::external_editor::settings::EditorChoice;
|
||||
use crate::util::file::external_editor::EditorSettings;
|
||||
|
||||
let settings = EditorSettings::as_ref(app);
|
||||
|
||||
if matches!(*settings.open_file_editor, EditorChoice::Warp) {
|
||||
|
||||
@@ -1,37 +1,40 @@
|
||||
//! This module is meant to be a single source of truth for information about the windows' "traffic
|
||||
//! light" buttons, the minimize, maximize, and close buttons in the corner of the window, so named
|
||||
//! b/c of their resemblence to traffic lights on MacOS. How (whether or not) these are rendered
|
||||
//! b/c of their resemblance to traffic lights on MacOS. How (whether or not) these are rendered
|
||||
//! depends on the platform. The Warp app must use this information to avoid rendering UI elements
|
||||
//! underneath them.
|
||||
|
||||
#[cfg(windows)]
|
||||
pub mod windows;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
||||
mod linux_only {
|
||||
pub(super) use crate::workspace::TOTAL_TAB_BAR_HEIGHT;
|
||||
pub(super) use std::sync::Arc;
|
||||
|
||||
pub(super) use pathfinder_color::ColorU;
|
||||
pub(super) use pathfinder_geometry::vector::vec2f;
|
||||
pub(super) use galaxyui::elements::{
|
||||
Align, Border, ChildAnchor, ConstrainedBox, Container, CornerRadius, Flex, Hoverable, Icon,
|
||||
OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, Rect, Stack,
|
||||
};
|
||||
pub(super) use pathfinder_color::ColorU;
|
||||
pub(super) use pathfinder_geometry::vector::vec2f;
|
||||
pub(super) use std::sync::Arc;
|
||||
|
||||
pub(super) use crate::workspace::TOTAL_TAB_BAR_HEIGHT;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
||||
use linux_only::*;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows_only {
|
||||
pub(super) use crate::ui_components::icons::Icon as IconComponent;
|
||||
pub(super) use pathfinder_color::ColorU;
|
||||
pub(super) use pathfinder_geometry::vector::vec2f;
|
||||
pub(super) use galaxy_core::ui::theme;
|
||||
pub(super) use galaxyui::elements::{
|
||||
Align, Border, ChildAnchor, ConstrainedBox, Container, CornerRadius, Hoverable,
|
||||
OffsetPositioning, ParentAnchor, ParentOffsetBounds, Radius, Rect, Stack,
|
||||
};
|
||||
pub(super) use pathfinder_color::ColorU;
|
||||
pub(super) use pathfinder_geometry::vector::vec2f;
|
||||
|
||||
pub(super) use crate::ui_components::icons::Icon as IconComponent;
|
||||
pub(super) const WINDOWS_BRIGHT_RED: ColorU = ColorU {
|
||||
r: 232,
|
||||
g: 17,
|
||||
@@ -43,18 +46,17 @@ mod windows_only {
|
||||
pub(super) const WINDOWS_BUTTON_PADDING_HORIZONTAL: f32 = 12.;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use windows_only::*;
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
use galaxyui::elements::Empty;
|
||||
|
||||
use crate::themes::theme::GalaxyTheme;
|
||||
use galaxyui::elements::MouseStateHandle;
|
||||
use galaxyui::platform::FullscreenState;
|
||||
use galaxyui::{AppContext, Element, WindowId};
|
||||
#[cfg(target_os = "windows")]
|
||||
use windows_only::*;
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
use crate::themes::theme::WarpTheme;
|
||||
|
||||
#[cfg(any(target_os = "windows", any(target_os = "linux", target_os = "freebsd")))]
|
||||
const BUTTON_ICON_SIZE: f32 = 22.;
|
||||
|
||||
pub fn traffic_light_data(ctx: &AppContext, window_id: WindowId) -> Option<TrafficLightData> {
|
||||
@@ -73,7 +75,9 @@ pub fn traffic_light_data(ctx: &AppContext, window_id: WindowId) -> Option<Traff
|
||||
side: TrafficLightSide::Left,
|
||||
scales_with_zoom: false,
|
||||
})
|
||||
} else if cfg!(target_os = "linux") && !ctx.windows().is_tiling_window_manager() {
|
||||
} else if cfg!(any(target_os = "linux", target_os = "freebsd"))
|
||||
&& !ctx.windows().is_tiling_window_manager()
|
||||
{
|
||||
Some(TrafficLightData {
|
||||
width: 116.,
|
||||
side: TrafficLightSide::Right,
|
||||
@@ -145,7 +149,7 @@ impl TrafficLightData {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
||||
pub fn render(
|
||||
&self,
|
||||
fullscreen_state: FullscreenState,
|
||||
@@ -153,7 +157,7 @@ impl TrafficLightData {
|
||||
theme: &GalaxyTheme,
|
||||
_app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
if !cfg!(target_os = "linux") {
|
||||
if !cfg!(any(target_os = "linux", target_os = "freebsd")) {
|
||||
return Empty::new().finish();
|
||||
}
|
||||
|
||||
@@ -226,7 +230,7 @@ impl TrafficLightData {
|
||||
.finish()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
||||
fn render_linux_maximize_button_icon(
|
||||
fg_color: ColorU,
|
||||
fullscreen_state: FullscreenState,
|
||||
@@ -282,7 +286,7 @@ impl TrafficLightData {
|
||||
maximize_button_icon
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
||||
fn render_button(
|
||||
mouse_state: MouseStateHandle,
|
||||
child: Box<dyn Element>,
|
||||
@@ -442,7 +446,10 @@ impl TrafficLightData {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(all(not(target_os = "linux"), not(target_os = "windows")))]
|
||||
#[cfg(all(
|
||||
not(any(target_os = "linux", target_os = "freebsd")),
|
||||
not(target_os = "windows")
|
||||
))]
|
||||
pub fn render(
|
||||
&self,
|
||||
_fullscreen_state: FullscreenState,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
//! Module containing helper functions to render the windows traffic lights.
|
||||
|
||||
use crate::util::traffic_lights::windows::RendererState;
|
||||
use crate::util::traffic_lights::windows_only::WINDOWS_BRIGHT_RED;
|
||||
use crate::util::traffic_lights::{TrafficLightData, TrafficLightMouseStates};
|
||||
use crate::workspace::TOTAL_TAB_BAR_HEIGHT;
|
||||
use galaxy_core::ui::theme::{Fill, GalaxyTheme};
|
||||
use std::sync::Arc;
|
||||
|
||||
use pathfinder_color::ColorU;
|
||||
use galaxy_core::ui::theme::{Fill, WarpTheme};
|
||||
use galaxyui::elements::{
|
||||
Align, ConstrainedBox, Container, CrossAxisAlignment, Flex, Hoverable, MainAxisAlignment,
|
||||
MainAxisSize, MouseStateHandle, ParentElement, Text,
|
||||
@@ -12,8 +11,11 @@ use galaxyui::elements::{
|
||||
use galaxyui::fonts::FamilyId;
|
||||
use galaxyui::platform::FullscreenState;
|
||||
use galaxyui::{AppContext, Element, SingletonEntity};
|
||||
use pathfinder_color::ColorU;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::util::traffic_lights::windows::RendererState;
|
||||
use crate::util::traffic_lights::windows_only::WINDOWS_BRIGHT_RED;
|
||||
use crate::util::traffic_lights::{TrafficLightData, TrafficLightMouseStates};
|
||||
use crate::workspace::TOTAL_TAB_BAR_HEIGHT;
|
||||
|
||||
/// Possible window traffic light icons.
|
||||
#[derive(Copy, Clone)]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::LazyLock;
|
||||
use std::{env, path};
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use galaxyui::{AppContext, SingletonEntity};
|
||||
|
||||
use crate::system::SystemInfo;
|
||||
|
||||
Reference in New Issue
Block a user