first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+26 -31
View File
@@ -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 {
+93 -5
View File
@@ -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());
});
});
}
+2 -4
View File
@@ -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,
+170 -94
View File
@@ -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,
}
+347 -49
View File
@@ -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(())
},
);
}
+64 -34
View File
@@ -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(&current, 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(""));
}
+5 -5
View File
@@ -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;
@@ -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,
+5 -5
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+328 -1
View File
@@ -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;
+26
View File
@@ -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.;
-2
View File
@@ -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>> =
+65 -28
View File
@@ -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;
-75
View File
@@ -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"
]
);
}
+168
View File
@@ -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());
}
+1 -1
View File
@@ -217,5 +217,5 @@ where
}
#[cfg(test)]
#[path = "meta_test.rs"]
#[path = "meta_tests.rs"]
mod tests;
+4 -4
View File
@@ -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 {
+64 -173
View File
@@ -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;
+323
View File
@@ -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
View File
@@ -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()
);
}
+86
View File
@@ -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)
}
-95
View File
@@ -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()
}
}
-52
View File
@@ -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());
})
}
+2 -1
View File
@@ -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
View File
@@ -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) {
+29 -22
View File
@@ -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 -1
View File
@@ -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;