Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,572 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
ffi::OsStr,
|
||||
path::{Path, PathBuf},
|
||||
sync::OnceLock,
|
||||
};
|
||||
|
||||
use command::blocking::Command;
|
||||
use freedesktop_desktop_entry::DesktopEntry;
|
||||
use warp_util::path::LineAndColumnArg;
|
||||
use warpui::AppContext;
|
||||
|
||||
use super::Editor;
|
||||
|
||||
static INSTALLED_EDITOR_METADATA: OnceLock<HashMap<Editor, EditorMetadata>> = OnceLock::new();
|
||||
|
||||
/// 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 {
|
||||
/// Path to the .desktop file.
|
||||
desktop_file_path: PathBuf,
|
||||
|
||||
/// The EXEC string from the .desktop file that details how
|
||||
/// to open the application. Contains field codes that need
|
||||
/// to be replaced.
|
||||
exec: String,
|
||||
|
||||
/// The name of the app, localized to the user's language if
|
||||
/// possible.
|
||||
localized_name: Option<String>,
|
||||
|
||||
// Path to a desktop icon.
|
||||
icon: Option<String>,
|
||||
}
|
||||
|
||||
impl EditorMetadata {
|
||||
/// Builds a new metadata from a given desktop file path
|
||||
///
|
||||
/// Reads in the file at `desktop_file_path`, and Attempts
|
||||
/// to build a new [`EditorMetdata`] from the file
|
||||
///
|
||||
/// # errors
|
||||
/// - [`DesktopExecError::IoError`] if reading the file fails
|
||||
/// - [`DesktopExecError::DecodeError`] if parsing the desktop entry fails
|
||||
/// - [`DesktopExecError::NoExec`] if the desktop entry does not have an Exec field
|
||||
fn try_new(desktop_file_path: PathBuf) -> Result<Self, DesktopExecError> {
|
||||
let input = std::fs::read_to_string(&desktop_file_path)?;
|
||||
|
||||
let entry = DesktopEntry::decode(&desktop_file_path, &input)?;
|
||||
|
||||
let Some(exec) = entry.exec() else {
|
||||
return Err(DesktopExecError::NoExec);
|
||||
};
|
||||
|
||||
// Doing all the calculations here to get owned versions of data fields,
|
||||
// so we can drop entry
|
||||
let exec = exec.to_string();
|
||||
let localized_name = entry.name(Some("en")).map(|x| x.to_string());
|
||||
let icon = entry.icon().map(str::to_string);
|
||||
|
||||
Ok(Self {
|
||||
desktop_file_path,
|
||||
exec,
|
||||
localized_name,
|
||||
icon,
|
||||
})
|
||||
}
|
||||
|
||||
/// Common implementation of building a command
|
||||
///
|
||||
/// - 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>`
|
||||
///
|
||||
/// 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 warp::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),
|
||||
/// }
|
||||
/// });
|
||||
/// ```
|
||||
fn build_command<T>(&self, field_code_processor: T) -> Result<Command, DesktopExecError>
|
||||
where
|
||||
T: Fn(&Self, &mut String, char),
|
||||
{
|
||||
let raw_exec = &self.exec;
|
||||
|
||||
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;
|
||||
}
|
||||
let Some(next_char) = iter.next() else {
|
||||
return Err(DesktopExecError::MalformedFieldCode);
|
||||
};
|
||||
field_code_processor(self, &mut processed_exec, next_char);
|
||||
}
|
||||
|
||||
let mut command = Command::new("sh");
|
||||
command.args(["-c", &processed_exec]);
|
||||
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
/// The default handler for replacing field codes with 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.
|
||||
///
|
||||
/// 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) {
|
||||
match field_code {
|
||||
// file path
|
||||
'f' | 'F' => *processed_exec += file_path.to_str().unwrap_or_default(),
|
||||
// URI
|
||||
'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
|
||||
// it requires an fs check, which is not fun. In the future, it would
|
||||
// be nice to replace this with the pending std::path::absolute in
|
||||
// the future
|
||||
//
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Localized Name
|
||||
'c' => {
|
||||
if let Some(localized_name) = self.localized_name.as_ref() {
|
||||
*processed_exec += localized_name;
|
||||
}
|
||||
}
|
||||
// Icon argument
|
||||
'i' => {
|
||||
if let Some(icon) = &self.icon {
|
||||
*processed_exec += "--icon ";
|
||||
*processed_exec += icon;
|
||||
}
|
||||
}
|
||||
// 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),
|
||||
};
|
||||
}
|
||||
|
||||
/// Builds a command based on a FreeDesktop Desktop Entry Exec key.
|
||||
/// Will returns a `Command` object that invokes the Exec command,
|
||||
/// with all field codes replaced according to the standard.
|
||||
///
|
||||
/// The values for %f, %F, %u, and %U are all computed based on a single file
|
||||
/// path passed in. We do not support multiple paths at this time.
|
||||
///
|
||||
/// Any field code processing errors will fail silently
|
||||
///
|
||||
/// See https://specifications.freedesktop.org/desktop-entry-spec/latest/ar01s07.html
|
||||
fn build_default_command(&self, file_path: &Path) -> Result<Command, DesktopExecError> {
|
||||
self.build_command(|me, acc, c| me.process_field_code(acc, c, file_path))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// 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.
|
||||
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 {
|
||||
'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);
|
||||
if let Some(column_num) = line_column_number.column_num {
|
||||
*acc += &format!("--column {column_num} ");
|
||||
}
|
||||
}
|
||||
*acc += file_path;
|
||||
}
|
||||
}
|
||||
other => me.process_field_code(acc, other, file_path),
|
||||
})
|
||||
}
|
||||
/// A variant of [`Self::build_default_command`] for sublime
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
/// 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.
|
||||
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 {
|
||||
'f' | 'F' | 'u' | 'U' => {
|
||||
if let Some(file_path) = file_path.to_str() {
|
||||
*acc += file_path;
|
||||
if let Some(line_column_number) = line_column_number {
|
||||
*acc += &format!(":{}", line_column_number.line_num);
|
||||
if let Some(column_num) = line_column_number.column_num {
|
||||
*acc += &format!(":{column_num}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
other => me.process_field_code(acc, other, file_path),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the given file in the specified editor.
|
||||
///
|
||||
/// If `line_column_number` is `Some`, the file will be opened with the cursor
|
||||
/// at the given location (if supported by the editor).
|
||||
///
|
||||
/// If with_editor is `None`, we attempt to compute the default editor for the
|
||||
/// given file type, and open the file there.
|
||||
pub fn open_file_path_with_line_and_col(
|
||||
line_column_number: Option<LineAndColumnArg>,
|
||||
with_editor: Option<Editor>,
|
||||
full_path: &Path,
|
||||
ctx: &mut AppContext,
|
||||
) {
|
||||
if full_path.is_file() {
|
||||
let with_editor = with_editor.or_else(|| get_app_for_file_from_mime(full_path));
|
||||
if let Some(editor) = with_editor {
|
||||
if let Some(mut command) = editor.command(full_path, line_column_number) {
|
||||
if let Err(err) = command.spawn() {
|
||||
log::error!("Error launching {editor:?}: {err:#}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.open_file_path(full_path);
|
||||
}
|
||||
|
||||
/// Attempt to match a file with an existing editor based on Mime type
|
||||
///
|
||||
/// Calls xdg-mime to first find the mime type of a file, and then find
|
||||
/// the xdg default app for that file. We then check against existing
|
||||
/// loaded editors to see if we have support for that file.
|
||||
///
|
||||
/// Used so that if xdg-open will work on a file we already know about,
|
||||
/// we can use line and col numbers.
|
||||
fn get_app_for_file_from_mime(path: &Path) -> Option<Editor> {
|
||||
let mime_type = String::from_utf8(
|
||||
Command::new("xdg-mime")
|
||||
.arg("query")
|
||||
.arg("filetype")
|
||||
.arg(path)
|
||||
.output()
|
||||
.ok()?
|
||||
.stdout,
|
||||
)
|
||||
.ok()?;
|
||||
|
||||
let default_app = String::from_utf8(
|
||||
Command::new("xdg-mime")
|
||||
.args(["query", "default", mime_type.trim()])
|
||||
.output()
|
||||
.ok()?
|
||||
.stdout,
|
||||
)
|
||||
.ok()?;
|
||||
|
||||
let app_id = default_app.trim().replace(".desktop", "");
|
||||
|
||||
get_editor_by_app_id(compute_editors_by_id(), app_id.as_str())
|
||||
}
|
||||
|
||||
static EDITORS_BY_ID: OnceLock<HashMap<&'static str, Editor>> = OnceLock::new();
|
||||
// Compute a map from app ID to `Editor` for all supported editors.
|
||||
fn compute_editors_by_id() -> &'static HashMap<&'static str, Editor> {
|
||||
EDITORS_BY_ID.get_or_init(|| {
|
||||
let mut editors_by_id = HashMap::new();
|
||||
for editor in enum_iterator::all::<Editor>() {
|
||||
if let Some(app_ids) = editor.app_ids() {
|
||||
for app_id in app_ids.iter() {
|
||||
editors_by_id.insert(*app_id, editor);
|
||||
}
|
||||
}
|
||||
}
|
||||
editors_by_id
|
||||
})
|
||||
}
|
||||
|
||||
/// Looks up the editor given an app_id
|
||||
///
|
||||
/// Special case for snap desktop files. snap desktop files follow XDG Desktop Entry
|
||||
/// Specification 1.1, which predates standard naming conventions. We are winding up
|
||||
/// with names of the format:
|
||||
///
|
||||
/// {snap-package-id}_{app-id}.desktop
|
||||
/// Examples include "code_code.desktop", "code-insiders_code-insiders.desktop",
|
||||
/// "code_code-url-handler.desktop", etc. So we check for the _ and use whatever follows.
|
||||
///
|
||||
/// See: https://snapcraft.io/docs/desktop-menu-support
|
||||
/// See: https://forum.snapcraft.io/t/overriding-desktop-files-on-ubuntu-snaps/6599/4
|
||||
fn get_editor_by_app_id(
|
||||
editors_by_id: &HashMap<&'static str, Editor>,
|
||||
app_id: &str,
|
||||
) -> Option<Editor> {
|
||||
editors_by_id
|
||||
.get(app_id)
|
||||
.or_else(|| {
|
||||
let (_, app_id) = app_id.split_once('_')?;
|
||||
|
||||
if app_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
editors_by_id.get(app_id)
|
||||
})
|
||||
.copied()
|
||||
}
|
||||
|
||||
/// Computes the list of installed editors.
|
||||
fn compute_installed_editors() -> HashMap<Editor, EditorMetadata> {
|
||||
let editors_by_id = compute_editors_by_id();
|
||||
|
||||
// Iterate through the .desktop files in the places they are typically
|
||||
// installed and see if the app ID (file stem) matches a supported
|
||||
// editor.
|
||||
let mut editors = HashMap::new();
|
||||
for path in freedesktop_desktop_entry::Iter::new(freedesktop_desktop_entry::default_paths()) {
|
||||
let Some(app_id) = path.file_stem().and_then(OsStr::to_str) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(editor) = get_editor_by_app_id(editors_by_id, app_id) {
|
||||
match EditorMetadata::try_new(path) {
|
||||
Ok(metadata) => {
|
||||
editors.insert(editor, metadata);
|
||||
}
|
||||
Err(e) => log::warn!("Failed to load editor config: {e:#}"),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
}
|
||||
editors
|
||||
}
|
||||
|
||||
impl Editor {
|
||||
fn app_ids(&self) -> Option<&[&'static str]> {
|
||||
use Editor::*;
|
||||
match self {
|
||||
AndroidStudio => Some(&["android-studio", "jetbrains-studio"]),
|
||||
CLion => Some(&["clion", "jetbrains-clion"]),
|
||||
DataGrip => Some(&["datagrip", "jetbrains-datagrip"]),
|
||||
DataSpell => Some(&["dataspell", "jetbrains-dataspell"]),
|
||||
IntelliJ => Some(&["jetbrains-idea", "intellij-idea-ultimate"]),
|
||||
IntelliJCE => Some(&["jetbrains-idea-ce", "intellij-idea-community"]),
|
||||
GoLand => Some(&["goland", "jetbrains-goland"]),
|
||||
PhpStorm => Some(&["phpstorm", "jetbrains-phpstorm"]),
|
||||
PyCharm => Some(&["pycharm-professional", "jetbrains-pycharm"]),
|
||||
PyCharmCE => Some(&["pycharm-community", "jetbrains-pycharm-ce"]),
|
||||
Rider => Some(&["rider", "jetbrains-rider"]),
|
||||
RubyMine => Some(&["rubymine", "jetbrains-rubymine"]),
|
||||
Sublime => Some(&["sublime-text_subl", "sublime_text"]),
|
||||
VSCode => Some(&["code"]),
|
||||
VSCodeInsiders => Some(&["code-insiders"]),
|
||||
WebStorm => Some(&["webstorm", "jetbrains-webstorm"]),
|
||||
Windsurf => Some(&["windsurf"]),
|
||||
Zed => Some(&["dev.zed.Zed"]),
|
||||
ZedPreview => Some(&["dev.zed.Zed-Preview"]), // both Zed stable and preview use the same binary on Linux
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn installed_editors(&self) -> &HashMap<Editor, EditorMetadata> {
|
||||
INSTALLED_EDITOR_METADATA.get_or_init(compute_installed_editors)
|
||||
}
|
||||
|
||||
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
|
||||
Zed | ZedPreview => {
|
||||
// First check if .desktop file exists
|
||||
if !self.installed_editors().contains_key(self) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Then verify the correct binary exists in its installation path
|
||||
let home = std::env::var("HOME").unwrap_or_default();
|
||||
let binary_path = match self {
|
||||
Zed => format!("{home}/.local/zed.app/bin/zed"),
|
||||
ZedPreview => format!("{home}/.local/zed-preview.app/bin/zed"),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
std::path::Path::new(&binary_path).exists()
|
||||
}
|
||||
// For all other editors, just check the desktop file
|
||||
_ => self.installed_editors().contains_key(self),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_metadata(&self) -> Option<&EditorMetadata> {
|
||||
self.installed_editors().get(self)
|
||||
}
|
||||
|
||||
fn command(
|
||||
&self,
|
||||
file_path: &Path,
|
||||
line_column_number: Option<LineAndColumnArg>,
|
||||
) -> Option<Command> {
|
||||
use Editor::*;
|
||||
match self {
|
||||
VSCode => {
|
||||
let suffix = line_column_number
|
||||
.as_ref()
|
||||
.map(LineAndColumnArg::to_string_suffix)
|
||||
.unwrap_or_default();
|
||||
let mut command = Command::new("xdg-open");
|
||||
command.arg(format!("vscode://file{}{suffix}", file_path.display()));
|
||||
Some(command)
|
||||
}
|
||||
VSCodeInsiders => {
|
||||
let suffix = line_column_number
|
||||
.as_ref()
|
||||
.map(LineAndColumnArg::to_string_suffix)
|
||||
.unwrap_or_default();
|
||||
let mut command = Command::new("xdg-open");
|
||||
command.arg(format!(
|
||||
"vscode-insiders://file{}{suffix}",
|
||||
file_path.display()
|
||||
));
|
||||
Some(command)
|
||||
}
|
||||
Windsurf => {
|
||||
let suffix = line_column_number
|
||||
.as_ref()
|
||||
.map(LineAndColumnArg::to_string_suffix)
|
||||
.unwrap_or_default();
|
||||
let mut command = Command::new("xdg-open");
|
||||
command.arg(format!("windsurf://file{}{suffix}", file_path.display()));
|
||||
Some(command)
|
||||
}
|
||||
AndroidStudio | CLion | CLionCE | DataGrip | DataSpell | GoLand | IntelliJ
|
||||
| IntelliJCE | PhpStorm | PyCharm | PyCharmCE | Rider | RubyMine | WebStorm => {
|
||||
match self.get_metadata() {
|
||||
Some(metadata) => {
|
||||
match metadata.build_jetbrains_command(file_path, line_column_number) {
|
||||
Ok(command) => Some(command),
|
||||
Err(err) => {
|
||||
log::warn!("Failed to build editor open command: {err:#}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
Sublime => match self.get_metadata() {
|
||||
Some(metadata) => {
|
||||
log::info!("Opening at {file_path:?} + {line_column_number:?}");
|
||||
match metadata.build_sublime_command(file_path, line_column_number) {
|
||||
Ok(command) => {
|
||||
log::info!("Command: {command:?}");
|
||||
Some(command)
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("Failed to build editor open command: {err:#}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
},
|
||||
Zed | ZedPreview => {
|
||||
// Get the correct binary path based on which editor was selected
|
||||
let home = std::env::var("HOME").unwrap_or_default();
|
||||
let binary_path = match self {
|
||||
Zed => format!("{home}/.local/zed.app/bin/zed"),
|
||||
ZedPreview => format!("{home}/.local/zed-preview.app/bin/zed"),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
// Format the file path with line/column if provided
|
||||
let file_path_str = file_path.display().to_string();
|
||||
let position = if let Some(line_col) = line_column_number {
|
||||
if let Some(col) = line_col.column_num {
|
||||
format!("{}:{}:{}", file_path_str, line_col.line_num, col)
|
||||
} else {
|
||||
format!("{}:{}", file_path_str, line_col.line_num)
|
||||
}
|
||||
} else {
|
||||
file_path_str
|
||||
};
|
||||
|
||||
// Build command using setsid for proper detachment
|
||||
let mut command = Command::new("/usr/bin/setsid");
|
||||
command.args([
|
||||
"-f", // Fork to background
|
||||
&binary_path, // The specific Zed binary to run
|
||||
&position, // File path with optional line/column
|
||||
]);
|
||||
|
||||
// Redirect all stdio to null
|
||||
command.stdin(std::process::Stdio::null());
|
||||
command.stdout(std::process::Stdio::null());
|
||||
command.stderr(std::process::Stdio::null());
|
||||
Some(command)
|
||||
}
|
||||
_ => match self.get_metadata() {
|
||||
Some(metadata) => match metadata.build_default_command(file_path) {
|
||||
Ok(command) => Some(command),
|
||||
Err(err) => {
|
||||
log::error!("Failed to build editor open command: {err:#}");
|
||||
None
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
enum DesktopExecError {
|
||||
#[error("i/o error {0}")]
|
||||
IoError(#[from] std::io::Error),
|
||||
|
||||
#[error("decode error {0}")]
|
||||
DecodeError(#[from] freedesktop_desktop_entry::DecodeError),
|
||||
|
||||
#[error("Attempted to create command for desktop entry with no exec field")]
|
||||
NoExec,
|
||||
|
||||
#[error("Malformed exec call: non-terminated field code")]
|
||||
MalformedFieldCode,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "linux_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,387 @@
|
||||
use warp_util::path::LineAndColumnArg;
|
||||
|
||||
use super::{DesktopExecError, EditorMetadata};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[cfg(test)]
|
||||
fn with_files(tag: &str, contents: &str, cb: impl FnOnce(PathBuf, PathBuf) -> anyhow::Result<()>) {
|
||||
use crate::test_util::{Stub, VirtualFS};
|
||||
|
||||
VirtualFS::test(tag, |dirs, mut sandbox| {
|
||||
sandbox.with_files(vec![
|
||||
Stub::FileWithContent("bar.desktop", contents),
|
||||
Stub::EmptyFile("foo.txt"),
|
||||
]);
|
||||
|
||||
let desktop_file_path = dirs.tests().join("bar.desktop");
|
||||
let content_file_path = dirs.tests().join("foo.txt");
|
||||
|
||||
match cb(desktop_file_path, content_file_path) {
|
||||
Ok(_) => {}
|
||||
Err(err) => panic!("{err:?}"),
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_missing_exec_command_errors() {
|
||||
with_files(
|
||||
"test_missing_exec_command_errors",
|
||||
"",
|
||||
|desktop, _content| {
|
||||
let result = EditorMetadata::try_new(desktop);
|
||||
|
||||
assert!(matches!(result, Err(DesktopExecError::NoExec)));
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exec_ending_on_percent_fails() {
|
||||
let data = r#"
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Exec=echo "hello world" %
|
||||
"#;
|
||||
with_files(
|
||||
"test_exec_ending_on_percent_fails",
|
||||
data,
|
||||
|desktop, content| {
|
||||
let metadata = EditorMetadata::try_new(desktop)?;
|
||||
let result = metadata.build_default_command(&content);
|
||||
assert!(matches!(result, Err(DesktopExecError::MalformedFieldCode)));
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_basic_exec_no_field_codes() {
|
||||
let data = r#"
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Exec=echo "hello world"
|
||||
"#;
|
||||
with_files(
|
||||
"test_basic_exec_no_field_codes",
|
||||
data,
|
||||
|desktop, content| {
|
||||
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(), "sh");
|
||||
assert_eq!(
|
||||
cmd.get_args().collect::<Vec<_>>(),
|
||||
["-c", "echo \"hello world\""]
|
||||
);
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_path_substitution() {
|
||||
let data = r#"
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Exec=cat %f
|
||||
"#;
|
||||
with_files("test_file_path_substitution", data, |desktop, content| {
|
||||
let metadata = EditorMetadata::try_new(desktop)?;
|
||||
let file_name = content.display().to_string();
|
||||
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()]
|
||||
);
|
||||
Ok(())
|
||||
});
|
||||
|
||||
let data = r#"
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Exec=cat %F
|
||||
"#;
|
||||
with_files("test_file_path_substitution", data, |desktop, content| {
|
||||
let metadata = EditorMetadata::try_new(desktop)?;
|
||||
let file_name = content.display().to_string();
|
||||
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()]
|
||||
);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_url_substitution() {
|
||||
let data = r#"
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Exec=open %u
|
||||
"#;
|
||||
with_files("test_file_url_substitution", data, |desktop, content| {
|
||||
let metadata = EditorMetadata::try_new(desktop)?;
|
||||
let file_name = content.display().to_string();
|
||||
let expected_file_uri = format!("file://{file_name}");
|
||||
let result = metadata.build_default_command(&content);
|
||||
|
||||
assert!(result.is_ok());
|
||||
|
||||
assert_eq!(
|
||||
result.unwrap().get_args().collect::<Vec<_>>(),
|
||||
["-c", &format!("open {expected_file_uri}")]
|
||||
);
|
||||
Ok(())
|
||||
});
|
||||
|
||||
let data = r#"
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Exec=open %U
|
||||
"#;
|
||||
with_files("test_file_url_substitution", data, |desktop, content| {
|
||||
let metadata = EditorMetadata::try_new(desktop)?;
|
||||
let file_name = content.display().to_string();
|
||||
let expected_file_uri = format!("file://{file_name}");
|
||||
let result = metadata.build_default_command(&content);
|
||||
|
||||
assert!(result.is_ok());
|
||||
|
||||
assert_eq!(
|
||||
result.unwrap().get_args().collect::<Vec<_>>(),
|
||||
["-c", &format!("open {expected_file_uri}")]
|
||||
);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remaining_substitutions() {
|
||||
let data = r#"
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Exec=echo %c && echo %i && echo %k && echo %%
|
||||
Name=Warp Test Application
|
||||
Icon=/foo/bar/icon.png
|
||||
"#;
|
||||
with_files("test_remaining_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());
|
||||
|
||||
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 %")]
|
||||
);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jetbrains_command_no_line_numbers() {
|
||||
let data = r#"
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Exec=/snap/bin/phpstorm %f
|
||||
"#;
|
||||
|
||||
with_files(
|
||||
"test_jetbrains_command_no_line_numbers",
|
||||
data,
|
||||
|desktop, content| {
|
||||
let metadata = EditorMetadata::try_new(desktop)?;
|
||||
let file_path = content.display().to_string();
|
||||
let result = metadata.build_jetbrains_command(&content, None);
|
||||
|
||||
assert!(result.is_ok());
|
||||
|
||||
assert_eq!(
|
||||
result.unwrap().get_args().collect::<Vec<_>>(),
|
||||
["-c", &format!("/snap/bin/phpstorm {file_path}")]
|
||||
);
|
||||
Ok(())
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jetbrains_command_line_numbers() {
|
||||
let data = r#"
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Exec=/snap/bin/phpstorm %f
|
||||
"#;
|
||||
|
||||
with_files(
|
||||
"test_jetbrains_command_line_numbers",
|
||||
data,
|
||||
|desktop, content| {
|
||||
let metadata = EditorMetadata::try_new(desktop)?;
|
||||
let file_path = content.display().to_string();
|
||||
let result = metadata.build_jetbrains_command(
|
||||
&content,
|
||||
Some(LineAndColumnArg {
|
||||
line_num: 42,
|
||||
column_num: None,
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(result.is_ok());
|
||||
|
||||
assert_eq!(
|
||||
result.unwrap().get_args().collect::<Vec<_>>(),
|
||||
["-c", &format!("/snap/bin/phpstorm --line 42 {file_path}")]
|
||||
);
|
||||
Ok(())
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jetbrains_command_line_and_col_numbers() {
|
||||
let data = r#"
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Exec=/snap/bin/phpstorm %f
|
||||
"#;
|
||||
with_files(
|
||||
"test_jetbrains_command_line_and_col_numbers",
|
||||
data,
|
||||
|desktop, content| {
|
||||
let metadata = EditorMetadata::try_new(desktop)?;
|
||||
let file_path = content.display().to_string();
|
||||
let result = metadata.build_jetbrains_command(
|
||||
&content,
|
||||
Some(LineAndColumnArg {
|
||||
line_num: 42,
|
||||
column_num: Some(25),
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(result.is_ok());
|
||||
|
||||
assert_eq!(
|
||||
result.unwrap().get_args().collect::<Vec<_>>(),
|
||||
[
|
||||
"-c",
|
||||
&format!("/snap/bin/phpstorm --line 42 --column 25 {file_path}")
|
||||
]
|
||||
);
|
||||
Ok(())
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sublime_command_no_line_numbers() {
|
||||
let data = r#"
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Exec=/snap/bin/subl %f
|
||||
"#;
|
||||
with_files(
|
||||
"test_sublime_command_no_line_numbers",
|
||||
data,
|
||||
|desktop, content| {
|
||||
let metadata = EditorMetadata::try_new(desktop)?;
|
||||
let file_path = content.display().to_string();
|
||||
let result: Result<command::blocking::Command, DesktopExecError> =
|
||||
metadata.build_sublime_command(&content, None);
|
||||
|
||||
assert!(result.is_ok());
|
||||
|
||||
assert_eq!(
|
||||
result.unwrap().get_args().collect::<Vec<_>>(),
|
||||
["-c", &format!("/snap/bin/subl {file_path}")]
|
||||
);
|
||||
Ok(())
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sublime_command_line_numbers() {
|
||||
let data = r#"
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Exec=/snap/bin/subl %f
|
||||
"#;
|
||||
with_files(
|
||||
"test_sublime_command_line_numbers",
|
||||
data,
|
||||
|desktop, content| {
|
||||
let metadata = EditorMetadata::try_new(desktop)?;
|
||||
let file_path = content.display().to_string();
|
||||
let result = metadata.build_sublime_command(
|
||||
&content,
|
||||
Some(LineAndColumnArg {
|
||||
line_num: 42,
|
||||
column_num: None,
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(result.is_ok());
|
||||
|
||||
assert_eq!(
|
||||
result.unwrap().get_args().collect::<Vec<_>>(),
|
||||
["-c", &format!("/snap/bin/subl {file_path}:42")]
|
||||
);
|
||||
Ok(())
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sublime_command_line_and_col_numbers() {
|
||||
let data = r#"
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Exec=/snap/bin/subl %f
|
||||
"#;
|
||||
with_files(
|
||||
"test_sublime_command_line_numbers",
|
||||
data,
|
||||
|desktop, content| {
|
||||
let metadata = EditorMetadata::try_new(desktop)?;
|
||||
let file_path = content.display().to_string();
|
||||
let result = metadata.build_sublime_command(
|
||||
&content,
|
||||
Some(LineAndColumnArg {
|
||||
line_num: 42,
|
||||
column_num: Some(25),
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(result.is_ok());
|
||||
|
||||
assert_eq!(
|
||||
result.unwrap().get_args().collect::<Vec<_>>(),
|
||||
["-c", &format!("/snap/bin/subl {file_path}:42:25")]
|
||||
);
|
||||
Ok(())
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
#![allow(deprecated)]
|
||||
|
||||
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 warpui::{platform::mac::make_nsstring, 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.
|
||||
#[derive(Debug)]
|
||||
pub enum OpenFileInEditorMethod {
|
||||
// A custom binary (e.g. the code CLI tool for VSCode).
|
||||
Binary(String),
|
||||
// Default application bundle from the app registration info in Cocoa.
|
||||
FromApplicationBundleInfo,
|
||||
// Use /usr/bin/open to open the file directly using the Editor's registered URL protocol.
|
||||
// The optional bundle identifier parameter allows for two different use cases:
|
||||
//
|
||||
// 1. AppUrl(None) - Opens the URL directly with the system's default handler
|
||||
// Example: `open vscode://file/hello.rs`
|
||||
// Used by editors like VSCode that rely on URL scheme registration
|
||||
//
|
||||
// 2. AppUrl(Some(bundle_id)) - Opens the URL with a specific application bundle
|
||||
// Example: `open -b dev.zed.Zed zed://file/hello.rs`
|
||||
// Used by editors like Zed that need explicit bundle specification
|
||||
AppUrl(Option<&'static str>),
|
||||
}
|
||||
|
||||
impl OpenFileInEditorMethod {
|
||||
pub fn command(&self, application_bundle_info: ApplicationBundleInfo) -> Command {
|
||||
let mut open_command = Command::new("/usr/bin/open");
|
||||
|
||||
match self {
|
||||
OpenFileInEditorMethod::Binary(binary_path)
|
||||
if application_bundle_info.path.join(binary_path).exists() =>
|
||||
{
|
||||
Command::new(application_bundle_info.path.join(binary_path))
|
||||
}
|
||||
OpenFileInEditorMethod::AppUrl(_) => open_command,
|
||||
_ => {
|
||||
open_command.arg("-a").arg(application_bundle_info.path);
|
||||
open_command
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Editor {
|
||||
const VSCODE_IDENTIFIER: &'a str = "com.microsoft.VSCode";
|
||||
const VSCODE_INSIDERS_IDENTIFIER: &'a str = "com.microsoft.VSCodeInsiders";
|
||||
const PYCHARM_CE_IDENTIFIER: &'a str = "com.jetbrains.pycharm.ce";
|
||||
const INTELLIJ_CE_IDENTIFIER: &'a str = "com.jetbrains.intellij.ce";
|
||||
const CLION_CE_IDENTIFIER: &'a str = "com.jetbrains.clion.ce";
|
||||
|
||||
/// Bundle identifier for the Rust Rover Preview build.
|
||||
const RUST_ROVER_PREVIEW_IDENTIFIER: &'a str = "com.jetbrains.rustrover-EAP";
|
||||
|
||||
/// Bundle identifier for the Rust Rover build.
|
||||
const RUST_ROVER_IDENTIFIER: &'a str = "com.jetbrains.rustrover";
|
||||
|
||||
const PYCHARM_IDENTIFIER: &'a str = "com.jetbrains.PyCharm";
|
||||
const INTELLIJ_IDENTIFIER: &'a str = "com.jetbrains.intellij";
|
||||
const CLION_IDENTIFIER: &'a str = "com.jetbrains.CLion";
|
||||
const PHPSTORM_IDENTIFIER: &'a str = "com.jetbrains.PhpStorm";
|
||||
const RUBYMINE_IDENTIFIER: &'a str = "com.jetbrains.RubyMine";
|
||||
const WEBSTORM_IDENTIFIER: &'a str = "com.jetbrains.WebStorm";
|
||||
const SUBLIME_4_IDENTIFIER: &'a str = "com.sublimetext.4";
|
||||
const SUBLIME_3_IDENTIFIER: &'a str = "com.sublimetext.3";
|
||||
const SUBLIME_2_IDENTIFIER: &'a str = "com.sublimetext.2";
|
||||
const ATOM_IDENTIFIER: &'a str = "com.github.atom";
|
||||
const ZED_IDENTIFIER: &'a str = "dev.zed.Zed";
|
||||
const ZED_PREVIEW_IDENTIFIER: &'a str = "dev.zed.Zed-Preview";
|
||||
const GOLAND_IDENTIFIER: &'a str = "com.jetbrains.goland";
|
||||
const RIDER_IDENTIFIER: &'a str = "com.jetbrains.rider";
|
||||
const DATASPELL_IDENTIFIER: &'a str = "com.jetbrains.dataspell";
|
||||
const DATAGRIP_IDENTIFIER: &'a str = "com.jetbrains.datagrip";
|
||||
const ANDROID_STUDIO_IDENTIFIER: &'a str = "com.google.android.studio";
|
||||
const CURSOR_IDENTIFIER: &'a str = "com.todesktop.230313mzl4w4u92";
|
||||
const WINDSURF_IDENTIFIER: &'a str = "com.exafunction.windsurf";
|
||||
|
||||
pub fn new_from_identifier(app_identifier: &str) -> Option<Self> {
|
||||
match app_identifier {
|
||||
Editor::VSCODE_IDENTIFIER => Some(Editor::VSCode),
|
||||
Editor::VSCODE_INSIDERS_IDENTIFIER => Some(Editor::VSCodeInsiders),
|
||||
Editor::PYCHARM_CE_IDENTIFIER => Some(Editor::PyCharmCE),
|
||||
Editor::PYCHARM_IDENTIFIER => Some(Editor::PyCharm),
|
||||
Editor::INTELLIJ_CE_IDENTIFIER => Some(Editor::IntelliJCE),
|
||||
Editor::INTELLIJ_IDENTIFIER => Some(Editor::IntelliJ),
|
||||
Editor::CLION_IDENTIFIER => Some(Editor::CLion),
|
||||
Editor::CLION_CE_IDENTIFIER => Some(Editor::CLionCE),
|
||||
Editor::ATOM_IDENTIFIER => Some(Editor::Atom),
|
||||
Editor::SUBLIME_4_IDENTIFIER => Some(Editor::Sublime4),
|
||||
Editor::SUBLIME_3_IDENTIFIER => Some(Editor::Sublime3),
|
||||
Editor::SUBLIME_2_IDENTIFIER => Some(Editor::Sublime2),
|
||||
Editor::ZED_IDENTIFIER => Some(Editor::Zed),
|
||||
Editor::ZED_PREVIEW_IDENTIFIER => Some(Editor::ZedPreview),
|
||||
Editor::GOLAND_IDENTIFIER => Some(Editor::GoLand),
|
||||
Editor::RIDER_IDENTIFIER => Some(Editor::Rider),
|
||||
Editor::DATASPELL_IDENTIFIER => Some(Editor::DataSpell),
|
||||
Editor::DATAGRIP_IDENTIFIER => Some(Editor::DataGrip),
|
||||
Editor::ANDROID_STUDIO_IDENTIFIER => Some(Editor::AndroidStudio),
|
||||
Editor::CURSOR_IDENTIFIER => Some(Editor::Cursor),
|
||||
Editor::WINDSURF_IDENTIFIER => Some(Editor::Windsurf),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn application_bundle_info(
|
||||
&'a self,
|
||||
ctx: &'a mut AppContext,
|
||||
) -> Option<ApplicationBundleInfo<'a>> {
|
||||
ctx.application_bundle_info(match self {
|
||||
Editor::VSCode => Editor::VSCODE_IDENTIFIER,
|
||||
Editor::VSCodeInsiders => Editor::VSCODE_INSIDERS_IDENTIFIER,
|
||||
Editor::PyCharmCE => Editor::PYCHARM_CE_IDENTIFIER,
|
||||
Editor::PyCharm => Editor::PYCHARM_IDENTIFIER,
|
||||
Editor::IntelliJCE => Editor::INTELLIJ_CE_IDENTIFIER,
|
||||
Editor::IntelliJ => Editor::INTELLIJ_IDENTIFIER,
|
||||
Editor::CLionCE => Editor::CLION_CE_IDENTIFIER,
|
||||
Editor::CLion => Editor::CLION_IDENTIFIER,
|
||||
Editor::Sublime4 => Editor::SUBLIME_4_IDENTIFIER,
|
||||
Editor::Sublime3 => Editor::SUBLIME_3_IDENTIFIER,
|
||||
Editor::Sublime2 => Editor::SUBLIME_2_IDENTIFIER,
|
||||
Editor::Atom => Editor::ATOM_IDENTIFIER,
|
||||
Editor::PhpStorm => Editor::PHPSTORM_IDENTIFIER,
|
||||
Editor::WebStorm => Editor::WEBSTORM_IDENTIFIER,
|
||||
Editor::RubyMine => Editor::RUBYMINE_IDENTIFIER,
|
||||
Editor::Zed => Editor::ZED_IDENTIFIER,
|
||||
Editor::ZedPreview => Editor::ZED_PREVIEW_IDENTIFIER,
|
||||
Editor::GoLand => Editor::GOLAND_IDENTIFIER,
|
||||
Editor::Rider => Editor::RIDER_IDENTIFIER,
|
||||
Editor::DataSpell => Editor::DATASPELL_IDENTIFIER,
|
||||
Editor::DataGrip => Editor::DATAGRIP_IDENTIFIER,
|
||||
Editor::AndroidStudio => Editor::ANDROID_STUDIO_IDENTIFIER,
|
||||
Editor::Cursor => Editor::CURSOR_IDENTIFIER,
|
||||
Editor::RustRoverPreview => Editor::RUST_ROVER_PREVIEW_IDENTIFIER,
|
||||
Editor::RustRover => Editor::RUST_ROVER_IDENTIFIER,
|
||||
Editor::Windsurf => Editor::WINDSURF_IDENTIFIER,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_installed(&self, ctx: &mut AppContext) -> bool {
|
||||
self.application_bundle_info(ctx).is_some()
|
||||
}
|
||||
|
||||
fn command_executable_and_arguments(
|
||||
&self,
|
||||
line_column_number: Option<LineAndColumnArg>,
|
||||
full_path: &Path,
|
||||
) -> (OpenFileInEditorMethod, Vec<String>) {
|
||||
let full_path_with_line_column =
|
||||
Self::format_file_path_with_line_and_column(full_path, line_column_number);
|
||||
match self {
|
||||
Editor::VSCode => (
|
||||
OpenFileInEditorMethod::AppUrl(None),
|
||||
vec![format!("vscode://file{}", full_path_with_line_column)],
|
||||
),
|
||||
Editor::VSCodeInsiders => (
|
||||
OpenFileInEditorMethod::AppUrl(None),
|
||||
vec![format!(
|
||||
"vscode-insiders://file{}",
|
||||
full_path_with_line_column
|
||||
)],
|
||||
),
|
||||
Editor::Windsurf => (
|
||||
OpenFileInEditorMethod::AppUrl(None),
|
||||
vec![format!("windsurf://file{}", full_path_with_line_column)],
|
||||
),
|
||||
Editor::PyCharm | Editor::PyCharmCE => {
|
||||
Self::jetbrains_command("pycharm", line_column_number, full_path)
|
||||
}
|
||||
Editor::IntelliJ | Editor::IntelliJCE => {
|
||||
Self::jetbrains_command("idea", line_column_number, full_path)
|
||||
}
|
||||
Editor::CLion | Editor::CLionCE => {
|
||||
Self::jetbrains_command("clion", line_column_number, full_path)
|
||||
}
|
||||
Editor::RubyMine => Self::jetbrains_command("rubymine", line_column_number, full_path),
|
||||
Editor::PhpStorm => Self::jetbrains_command("phpstorm", line_column_number, full_path),
|
||||
Editor::WebStorm => Self::jetbrains_command("webstorm", line_column_number, full_path),
|
||||
Editor::Sublime4 | Editor::Sublime3 | Editor::Sublime2 => (
|
||||
OpenFileInEditorMethod::Binary("Contents/SharedSupport/bin/subl".to_string()),
|
||||
vec![full_path_with_line_column],
|
||||
),
|
||||
Editor::Atom => (
|
||||
OpenFileInEditorMethod::FromApplicationBundleInfo,
|
||||
vec![full_path_with_line_column],
|
||||
),
|
||||
Editor::Zed => (
|
||||
OpenFileInEditorMethod::AppUrl(Some(Editor::ZED_IDENTIFIER)),
|
||||
vec![format!("zed://file{}", full_path_with_line_column)],
|
||||
),
|
||||
Editor::ZedPreview => (
|
||||
OpenFileInEditorMethod::AppUrl(Some(Editor::ZED_PREVIEW_IDENTIFIER)),
|
||||
vec![format!("zed://file{}", full_path_with_line_column)],
|
||||
),
|
||||
Editor::GoLand => Self::jetbrains_command("goland", line_column_number, full_path),
|
||||
Editor::Rider => Self::jetbrains_command("rider", line_column_number, full_path),
|
||||
Editor::DataSpell => {
|
||||
Self::jetbrains_command("dataspell", line_column_number, full_path)
|
||||
}
|
||||
Editor::DataGrip => Self::jetbrains_command("datagrip", line_column_number, full_path),
|
||||
Editor::AndroidStudio => {
|
||||
Self::jetbrains_command("studio", line_column_number, full_path)
|
||||
}
|
||||
Editor::Cursor => (
|
||||
OpenFileInEditorMethod::AppUrl(None),
|
||||
vec![format!("cursor://file{}", full_path_with_line_column)],
|
||||
),
|
||||
Editor::RustRoverPreview | Editor::RustRover => {
|
||||
Self::jetbrains_command("rustrover", line_column_number, full_path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn jetbrains_command(
|
||||
cli_name: &str,
|
||||
line_column_number: Option<LineAndColumnArg>,
|
||||
full_path: &Path,
|
||||
) -> (OpenFileInEditorMethod, Vec<String>) {
|
||||
let full_path = full_path.to_str().expect("full path exists").to_string();
|
||||
(
|
||||
OpenFileInEditorMethod::Binary(format!("Contents/MacOS/{cli_name}")),
|
||||
if let Some(line_column_number) = line_column_number {
|
||||
vec![
|
||||
"--line".to_string(),
|
||||
line_column_number.line_num.to_string(),
|
||||
full_path,
|
||||
]
|
||||
} else {
|
||||
vec![full_path]
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn open(
|
||||
&self,
|
||||
line_column_number: Option<LineAndColumnArg>,
|
||||
full_path: &Path,
|
||||
ctx: &mut AppContext,
|
||||
) -> bool {
|
||||
let Some(application_bundle_info) = self.application_bundle_info(ctx) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let (executable, arguments) =
|
||||
self.command_executable_and_arguments(line_column_number, full_path);
|
||||
|
||||
// Build the command based on the executable type:
|
||||
// - For AppUrl(Some(bundle_id)): Use `open -b bundle_id` to explicitly specify the app
|
||||
// - For AppUrl(None): Use plain `open` command to let the system handle the URL scheme
|
||||
// - For other methods: Use the standard command creation logic
|
||||
let mut command = match &executable {
|
||||
OpenFileInEditorMethod::AppUrl(Some(bundle_id)) => {
|
||||
let mut cmd = Command::new("/usr/bin/open");
|
||||
cmd.arg("-b").arg(bundle_id);
|
||||
cmd
|
||||
}
|
||||
_ => executable.command(application_bundle_info),
|
||||
};
|
||||
|
||||
match command.args(arguments).spawn() {
|
||||
Ok(mut child) => {
|
||||
ctx.background_executor()
|
||||
.spawn(async move {
|
||||
let now = Instant::now();
|
||||
match child.status().await {
|
||||
Ok(exit_code) => {
|
||||
log::debug!(
|
||||
"process exited after {}ms with exit code: {}",
|
||||
now.elapsed().as_millis(),
|
||||
exit_code
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("unable to await process {err:?}");
|
||||
}
|
||||
};
|
||||
})
|
||||
.detach();
|
||||
log::info!("Successfully launched {self:?}.");
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Error launching {self:?} {e:?}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Given the line column number and the path, format into "path:line:column".
|
||||
fn format_file_path_with_line_and_column(
|
||||
full_path: &Path,
|
||||
line_column_number: Option<LineAndColumnArg>,
|
||||
) -> String {
|
||||
let mut full_path_with_line_column = full_path.to_string_lossy().to_string();
|
||||
|
||||
if let Some(line_column_number) = line_column_number {
|
||||
let _ = write!(
|
||||
&mut full_path_with_line_column,
|
||||
":{}",
|
||||
line_column_number.line_num
|
||||
);
|
||||
|
||||
if let Some(column_num) = line_column_number.column_num {
|
||||
let _ = write!(&mut full_path_with_line_column, ":{column_num}");
|
||||
}
|
||||
}
|
||||
|
||||
full_path_with_line_column
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_file_path_with_line_and_col(
|
||||
line_column_number: Option<LineAndColumnArg>,
|
||||
with_editor: Option<Editor>,
|
||||
full_path: &Path,
|
||||
ctx: &mut AppContext,
|
||||
) {
|
||||
if full_path.is_file() {
|
||||
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) };
|
||||
app_bundle_id
|
||||
.as_deref()
|
||||
.and_then(Editor::new_from_identifier)
|
||||
};
|
||||
|
||||
if let Some(editor) = editor {
|
||||
if editor.open(line_column_number, full_path, ctx) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.open_file_path(full_path);
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac;
|
||||
pub mod settings;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings::EditorChoice;
|
||||
use warp_util::path::LineAndColumnArg;
|
||||
use warpui::{AppContext, SingletonEntity};
|
||||
|
||||
pub use self::settings::{EditorLayout, EditorSettings};
|
||||
|
||||
pub const SUPPORTED_EDITORS: &[Editor] = &[
|
||||
Editor::VSCode,
|
||||
Editor::VSCodeInsiders,
|
||||
Editor::Atom,
|
||||
Editor::CLion,
|
||||
Editor::CLionCE,
|
||||
Editor::RustRoverPreview,
|
||||
Editor::RustRover,
|
||||
Editor::IntelliJ,
|
||||
Editor::IntelliJCE,
|
||||
Editor::PyCharm,
|
||||
Editor::PyCharmCE,
|
||||
Editor::WebStorm,
|
||||
Editor::PhpStorm,
|
||||
Editor::RubyMine,
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
// On Linux, all versions of sublime use the same app-ids, so
|
||||
// we only have one entry
|
||||
Editor::Sublime,
|
||||
#[cfg(target_os = "macos")]
|
||||
Editor::Sublime2,
|
||||
#[cfg(target_os = "macos")]
|
||||
Editor::Sublime3,
|
||||
#[cfg(target_os = "macos")]
|
||||
Editor::Sublime4,
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
// Zed is available on macos and linux
|
||||
Editor::Zed,
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
// Zed Preview is available on macos and linux
|
||||
Editor::ZedPreview,
|
||||
Editor::GoLand,
|
||||
Editor::Rider,
|
||||
Editor::DataSpell,
|
||||
Editor::DataGrip,
|
||||
Editor::AndroidStudio,
|
||||
#[cfg(any(target_os = "macos", windows))]
|
||||
// Cursor *can* run on linux, but does not have a .desktop file
|
||||
Editor::Cursor,
|
||||
Editor::Windsurf,
|
||||
];
|
||||
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
Copy,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Hash,
|
||||
enum_iterator::Sequence,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(description = "An external code editor.", rename_all = "snake_case")]
|
||||
pub enum Editor {
|
||||
VSCode,
|
||||
VSCodeInsiders,
|
||||
PyCharm,
|
||||
PyCharmCE,
|
||||
IntelliJ,
|
||||
IntelliJCE,
|
||||
CLion,
|
||||
CLionCE,
|
||||
RustRoverPreview,
|
||||
RustRover,
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
Sublime,
|
||||
#[cfg(target_os = "macos")]
|
||||
Sublime4,
|
||||
#[cfg(target_os = "macos")]
|
||||
Sublime3,
|
||||
#[cfg(target_os = "macos")]
|
||||
Sublime2,
|
||||
Atom,
|
||||
WebStorm,
|
||||
PhpStorm,
|
||||
RubyMine,
|
||||
Zed,
|
||||
ZedPreview,
|
||||
GoLand,
|
||||
Rider,
|
||||
DataSpell,
|
||||
DataGrip,
|
||||
AndroidStudio,
|
||||
Cursor,
|
||||
Windsurf,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Editor {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}",
|
||||
match self {
|
||||
Editor::VSCode => "VSCode",
|
||||
Editor::VSCodeInsiders => "VSCode Insiders",
|
||||
Editor::PyCharm => "PyCharm",
|
||||
Editor::PyCharmCE => "PyCharm Community Edition",
|
||||
Editor::IntelliJ => "IntelliJ",
|
||||
Editor::IntelliJCE => "IntelliJ Community Edition",
|
||||
Editor::CLion => "CLion",
|
||||
Editor::CLionCE => "CLion Community Edition",
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
Editor::Sublime => "Sublime",
|
||||
#[cfg(target_os = "macos")]
|
||||
Editor::Sublime4 => "Sublime 4",
|
||||
#[cfg(target_os = "macos")]
|
||||
Editor::Sublime3 => "Sublime 3",
|
||||
#[cfg(target_os = "macos")]
|
||||
Editor::Sublime2 => "Sublime 2",
|
||||
Editor::Atom => "Atom",
|
||||
Editor::WebStorm => "WebStorm",
|
||||
Editor::PhpStorm => "PhpStorm",
|
||||
Editor::RubyMine => "RubyMine",
|
||||
Editor::Zed => "Zed",
|
||||
Editor::ZedPreview => "Zed Preview",
|
||||
Editor::GoLand => "GoLand",
|
||||
Editor::Rider => "Rider",
|
||||
Editor::DataSpell => "DataSpell",
|
||||
Editor::DataGrip => "DataGrip",
|
||||
Editor::AndroidStudio => "Android Studio",
|
||||
Editor::Cursor => "Cursor",
|
||||
Editor::RustRoverPreview => "Rust Rover (Preview)",
|
||||
Editor::RustRover => "Rust Rover",
|
||||
Editor::Windsurf => "Windsurf",
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&str> for Editor {
|
||||
type Error = ();
|
||||
|
||||
/// Maps an editor command name to a supported Editor enum if available.
|
||||
/// This allows us to use existing editor integrations instead of shell commands when possible.
|
||||
fn try_from(editor_name: &str) -> Result<Self, Self::Error> {
|
||||
let editor_base = std::path::Path::new(editor_name)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or(editor_name)
|
||||
.to_lowercase();
|
||||
|
||||
match editor_base.as_str() {
|
||||
"code" => Ok(Editor::VSCode),
|
||||
"code-insiders" => Ok(Editor::VSCodeInsiders),
|
||||
"zed" => Ok(Editor::Zed),
|
||||
"zed-preview" => Ok(Editor::ZedPreview),
|
||||
"cursor" => Ok(Editor::Cursor),
|
||||
"windsurf" => Ok(Editor::Windsurf),
|
||||
"clion" => Ok(Editor::CLion),
|
||||
"pycharm" => Ok(Editor::PyCharm),
|
||||
"pycharm-ce" => Ok(Editor::PyCharmCE),
|
||||
"intellij" => Ok(Editor::IntelliJ),
|
||||
"intellij-ce" => Ok(Editor::IntelliJCE),
|
||||
"webstorm" => Ok(Editor::WebStorm),
|
||||
"phpstorm" => Ok(Editor::PhpStorm),
|
||||
"rubymine" => Ok(Editor::RubyMine),
|
||||
"goland" => Ok(Editor::GoLand),
|
||||
"rider" => Ok(Editor::Rider),
|
||||
"datagrip" => Ok(Editor::DataGrip),
|
||||
"dataspell" => Ok(Editor::DataSpell),
|
||||
"android-studio" => Ok(Editor::AndroidStudio),
|
||||
"rustrover" => Ok(Editor::RustRover),
|
||||
"rustrover-preview" => Ok(Editor::RustRoverPreview),
|
||||
"atom" => Ok(Editor::Atom),
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
"sublime" | "subl" => Ok(Editor::Sublime),
|
||||
#[cfg(target_os = "macos")]
|
||||
"sublime" | "subl" => Ok(Editor::Sublime4), // Default to latest on macOS
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate an editor command string using the provided editor (or $EDITOR as fallback)
|
||||
/// and handle line/column positioning for common command-line editors.
|
||||
/// This is primarily used for generating shell commands when opening files with $EDITOR.
|
||||
pub fn generate_editor_command(
|
||||
path: &std::path::Path,
|
||||
line_col: Option<LineAndColumnArg>,
|
||||
editor: Option<&str>,
|
||||
) -> String {
|
||||
let file_path_str = path.to_string_lossy();
|
||||
let quoted_path = shell_words::quote(&file_path_str);
|
||||
|
||||
let editor_cmd = editor.unwrap_or("\"$EDITOR\"").to_owned();
|
||||
|
||||
// Add line/column support for common editors if provided
|
||||
let Some(line_and_col) = line_col else {
|
||||
return format!("{editor_cmd} {quoted_path}");
|
||||
};
|
||||
let Some(editor_name) = editor else {
|
||||
return format!("{editor_cmd} {quoted_path}");
|
||||
};
|
||||
|
||||
let editor_base = std::path::Path::new(editor_name)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or(editor_name)
|
||||
.to_lowercase();
|
||||
|
||||
match editor_base.as_str() {
|
||||
// Vim and Neovim: +line or +line:column
|
||||
"vim" | "nvim" | "neovim" => {
|
||||
let line_arg = if let Some(col) = line_and_col.column_num {
|
||||
format!("+{}:{}", line_and_col.line_num, col)
|
||||
} else {
|
||||
format!("+{}", line_and_col.line_num)
|
||||
};
|
||||
format!("{editor_cmd} {line_arg} {quoted_path}")
|
||||
}
|
||||
// Emacs: +line:column
|
||||
"emacs" => {
|
||||
let line_arg = if let Some(col) = line_and_col.column_num {
|
||||
format!("+{}:{}", line_and_col.line_num, col)
|
||||
} else {
|
||||
format!("+{}", line_and_col.line_num)
|
||||
};
|
||||
format!("{editor_cmd} {line_arg} {quoted_path}")
|
||||
}
|
||||
// Nano: +line,column
|
||||
"nano" => {
|
||||
let line_arg = if let Some(col) = line_and_col.column_num {
|
||||
format!("+{},{}", line_and_col.line_num, col)
|
||||
} else {
|
||||
format!("+{}", line_and_col.line_num)
|
||||
};
|
||||
format!("{editor_cmd} {line_arg} {quoted_path}")
|
||||
}
|
||||
// Pico: +line,column (same as nano)
|
||||
"pico" => {
|
||||
let line_arg = if let Some(col) = line_and_col.column_num {
|
||||
format!("+{},{}", line_and_col.line_num, col)
|
||||
} else {
|
||||
format!("+{}", line_and_col.line_num)
|
||||
};
|
||||
format!("{editor_cmd} {line_arg} {quoted_path}")
|
||||
}
|
||||
// Micro: +line:column
|
||||
"micro" => {
|
||||
let line_arg = if let Some(col) = line_and_col.column_num {
|
||||
format!("+{}:{}", line_and_col.line_num, col)
|
||||
} else {
|
||||
format!("+{}", line_and_col.line_num)
|
||||
};
|
||||
format!("{editor_cmd} {line_arg} {quoted_path}")
|
||||
}
|
||||
// Helix: file:line:column
|
||||
"hx" | "helix" => {
|
||||
let file_with_pos = if let Some(col) = line_and_col.column_num {
|
||||
format!("{}:{}:{}", quoted_path, line_and_col.line_num, col)
|
||||
} else {
|
||||
format!("{}:{}", quoted_path, line_and_col.line_num)
|
||||
};
|
||||
format!("{editor_cmd} {}", shell_words::quote(&file_with_pos))
|
||||
}
|
||||
// VS Code: --goto file:line:column
|
||||
"code" => {
|
||||
let goto_arg = if let Some(col) = line_and_col.column_num {
|
||||
format!("{}:{}:{}", quoted_path, line_and_col.line_num, col)
|
||||
} else {
|
||||
format!("{}:{}", quoted_path, line_and_col.line_num)
|
||||
};
|
||||
format!("{editor_cmd} --goto {}", shell_words::quote(&goto_arg))
|
||||
}
|
||||
// For unknown editors, fall through to basic command without line support
|
||||
_ => format!("{editor_cmd} {quoted_path}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens a file in an external editor, respecting the user's editor settings.
|
||||
/// This reads the configured external editor from EditorSettings and uses it if set,
|
||||
/// otherwise falls back to system default.
|
||||
pub fn open_file_path_in_external_editor(
|
||||
line_column_number: Option<LineAndColumnArg>,
|
||||
full_path: PathBuf,
|
||||
ctx: &mut AppContext,
|
||||
) {
|
||||
let editor = match *EditorSettings::as_ref(ctx).open_file_editor {
|
||||
EditorChoice::ExternalEditor(editor) => Some(editor),
|
||||
_ => None,
|
||||
};
|
||||
open_file_path_with_editor(line_column_number, full_path, editor, ctx);
|
||||
}
|
||||
|
||||
pub fn open_file_path_with_editor(
|
||||
line_column_number: Option<LineAndColumnArg>,
|
||||
full_path: PathBuf,
|
||||
editor: Option<Editor>,
|
||||
ctx: &mut AppContext,
|
||||
) {
|
||||
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")] {
|
||||
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);
|
||||
} else {
|
||||
ctx.open_file_path(&full_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,313 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use warp_util::path::LineAndColumnArg;
|
||||
|
||||
use super::generate_editor_command;
|
||||
|
||||
#[test]
|
||||
fn test_editor_missing_no_line_col() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let result = generate_editor_command(&path, None, None);
|
||||
assert_eq!(result, "\"$EDITOR\" /path/to/file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_editor_missing_with_line_col() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let line_col = Some(LineAndColumnArg {
|
||||
line_num: 42,
|
||||
column_num: Some(10),
|
||||
});
|
||||
let result = generate_editor_command(&path, line_col, None);
|
||||
assert_eq!(result, "\"$EDITOR\" /path/to/file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_editor_present_no_line_col() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let result = generate_editor_command(&path, None, Some("vim"));
|
||||
assert_eq!(result, "vim /path/to/file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_editor_present_line_missing() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let result = generate_editor_command(&path, None, Some("emacs"));
|
||||
assert_eq!(result, "emacs /path/to/file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vim_with_line_only() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let line_col = Some(LineAndColumnArg {
|
||||
line_num: 42,
|
||||
column_num: None,
|
||||
});
|
||||
let result = generate_editor_command(&path, line_col, Some("vim"));
|
||||
assert_eq!(result, "vim +42 /path/to/file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vim_with_line_and_column() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let line_col = Some(LineAndColumnArg {
|
||||
line_num: 42,
|
||||
column_num: Some(10),
|
||||
});
|
||||
let result = generate_editor_command(&path, line_col, Some("vim"));
|
||||
assert_eq!(result, "vim +42:10 /path/to/file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_neovim_with_line_only() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let line_col = Some(LineAndColumnArg {
|
||||
line_num: 100,
|
||||
column_num: None,
|
||||
});
|
||||
let result = generate_editor_command(&path, line_col, Some("nvim"));
|
||||
assert_eq!(result, "nvim +100 /path/to/file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_neovim_with_line_and_column() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let line_col = Some(LineAndColumnArg {
|
||||
line_num: 100,
|
||||
column_num: Some(25),
|
||||
});
|
||||
let result = generate_editor_command(&path, line_col, Some("nvim"));
|
||||
assert_eq!(result, "nvim +100:25 /path/to/file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_emacs_with_line_only() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let line_col = Some(LineAndColumnArg {
|
||||
line_num: 15,
|
||||
column_num: None,
|
||||
});
|
||||
let result = generate_editor_command(&path, line_col, Some("emacs"));
|
||||
assert_eq!(result, "emacs +15 /path/to/file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_emacs_with_line_and_column() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let line_col = Some(LineAndColumnArg {
|
||||
line_num: 15,
|
||||
column_num: Some(5),
|
||||
});
|
||||
let result = generate_editor_command(&path, line_col, Some("emacs"));
|
||||
assert_eq!(result, "emacs +15:5 /path/to/file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nano_with_line_only() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let line_col = Some(LineAndColumnArg {
|
||||
line_num: 20,
|
||||
column_num: None,
|
||||
});
|
||||
let result = generate_editor_command(&path, line_col, Some("nano"));
|
||||
assert_eq!(result, "nano +20 /path/to/file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nano_with_line_and_column() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let line_col = Some(LineAndColumnArg {
|
||||
line_num: 20,
|
||||
column_num: Some(8),
|
||||
});
|
||||
let result = generate_editor_command(&path, line_col, Some("nano"));
|
||||
assert_eq!(result, "nano +20,8 /path/to/file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pico_with_line_only() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let line_col = Some(LineAndColumnArg {
|
||||
line_num: 35,
|
||||
column_num: None,
|
||||
});
|
||||
let result = generate_editor_command(&path, line_col, Some("pico"));
|
||||
assert_eq!(result, "pico +35 /path/to/file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pico_with_line_and_column() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let line_col = Some(LineAndColumnArg {
|
||||
line_num: 35,
|
||||
column_num: Some(12),
|
||||
});
|
||||
let result = generate_editor_command(&path, line_col, Some("pico"));
|
||||
assert_eq!(result, "pico +35,12 /path/to/file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_micro_with_line_only() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let line_col = Some(LineAndColumnArg {
|
||||
line_num: 50,
|
||||
column_num: None,
|
||||
});
|
||||
let result = generate_editor_command(&path, line_col, Some("micro"));
|
||||
assert_eq!(result, "micro +50 /path/to/file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_micro_with_line_and_column() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let line_col = Some(LineAndColumnArg {
|
||||
line_num: 50,
|
||||
column_num: Some(15),
|
||||
});
|
||||
let result = generate_editor_command(&path, line_col, Some("micro"));
|
||||
assert_eq!(result, "micro +50:15 /path/to/file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_helix_with_line_only() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let line_col = Some(LineAndColumnArg {
|
||||
line_num: 75,
|
||||
column_num: None,
|
||||
});
|
||||
let result = generate_editor_command(&path, line_col, Some("hx"));
|
||||
assert_eq!(result, "hx /path/to/file.txt:75");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_helix_with_line_and_column() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let line_col = Some(LineAndColumnArg {
|
||||
line_num: 75,
|
||||
column_num: Some(20),
|
||||
});
|
||||
let result = generate_editor_command(&path, line_col, Some("helix"));
|
||||
assert_eq!(result, "helix /path/to/file.txt:75:20");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vscode_with_line_only() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let line_col = Some(LineAndColumnArg {
|
||||
line_num: 90,
|
||||
column_num: None,
|
||||
});
|
||||
let result = generate_editor_command(&path, line_col, Some("code"));
|
||||
assert_eq!(result, "code --goto /path/to/file.txt:90");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vscode_with_line_and_column() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let line_col = Some(LineAndColumnArg {
|
||||
line_num: 90,
|
||||
column_num: Some(30),
|
||||
});
|
||||
let result = generate_editor_command(&path, line_col, Some("code"));
|
||||
assert_eq!(result, "code --goto /path/to/file.txt:90:30");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_editor_with_line_col() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let line_col = Some(LineAndColumnArg {
|
||||
line_num: 123,
|
||||
column_num: Some(45),
|
||||
});
|
||||
let result = generate_editor_command(&path, line_col, Some("unknown-editor"));
|
||||
assert_eq!(result, "unknown-editor /path/to/file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_with_spaces() {
|
||||
let path = PathBuf::from("/path with spaces/my file.txt");
|
||||
let result = generate_editor_command(&path, None, Some("vim"));
|
||||
assert_eq!(result, "vim '/path with spaces/my file.txt'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_with_special_characters() {
|
||||
let path = PathBuf::from("/path/with$pecial&chars.txt");
|
||||
let line_col = Some(LineAndColumnArg {
|
||||
line_num: 1,
|
||||
column_num: Some(1),
|
||||
});
|
||||
let result = generate_editor_command(&path, line_col, Some("emacs"));
|
||||
assert_eq!(result, "emacs +1:1 '/path/with$pecial&chars.txt'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_editor_with_path() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let line_col = Some(LineAndColumnArg {
|
||||
line_num: 10,
|
||||
column_num: None,
|
||||
});
|
||||
let result = generate_editor_command(&path, line_col, Some("/usr/bin/vim"));
|
||||
assert_eq!(result, "/usr/bin/vim +10 /path/to/file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_case_insensitive_editor_matching() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let line_col = Some(LineAndColumnArg {
|
||||
line_num: 33,
|
||||
column_num: Some(7),
|
||||
});
|
||||
|
||||
// Test uppercase
|
||||
let result = generate_editor_command(&path, line_col, Some("VIM"));
|
||||
assert_eq!(result, "VIM +33:7 /path/to/file.txt");
|
||||
|
||||
// Test mixed case
|
||||
let result = generate_editor_command(&path, line_col, Some("Emacs"));
|
||||
assert_eq!(result, "Emacs +33:7 /path/to/file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_editor_try_from_supported_editors() {
|
||||
use super::Editor;
|
||||
|
||||
// Test VSCode variants
|
||||
assert_eq!(Editor::try_from("code"), Ok(Editor::VSCode));
|
||||
assert_eq!(
|
||||
Editor::try_from("code-insiders"),
|
||||
Ok(Editor::VSCodeInsiders)
|
||||
);
|
||||
|
||||
// Test Zed variants
|
||||
assert_eq!(Editor::try_from("zed"), Ok(Editor::Zed));
|
||||
assert_eq!(Editor::try_from("zed-preview"), Ok(Editor::ZedPreview));
|
||||
|
||||
// Test other popular editors
|
||||
assert_eq!(Editor::try_from("cursor"), Ok(Editor::Cursor));
|
||||
assert_eq!(Editor::try_from("windsurf"), Ok(Editor::Windsurf));
|
||||
assert_eq!(Editor::try_from("clion"), Ok(Editor::CLion));
|
||||
|
||||
// Test with paths
|
||||
assert_eq!(Editor::try_from("/usr/local/bin/code"), Ok(Editor::VSCode));
|
||||
assert_eq!(
|
||||
Editor::try_from("/Applications/Zed.app/Contents/MacOS/zed"),
|
||||
Ok(Editor::Zed)
|
||||
);
|
||||
|
||||
// Test case insensitivity
|
||||
assert_eq!(Editor::try_from("CODE"), Ok(Editor::VSCode));
|
||||
assert_eq!(Editor::try_from("Zed"), Ok(Editor::Zed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_editor_try_from_unsupported_editors() {
|
||||
use super::Editor;
|
||||
|
||||
// Test unsupported terminal editors
|
||||
assert!(Editor::try_from("vim").is_err());
|
||||
assert!(Editor::try_from("emacs").is_err());
|
||||
assert!(Editor::try_from("nano").is_err());
|
||||
assert!(Editor::try_from("unknown-editor").is_err());
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
pub use crate::util::openable_file_type::EditorLayout;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use settings::{
|
||||
macros::define_settings_group, RespectUserSyncSetting, SupportedPlatforms, SyncToCloud,
|
||||
};
|
||||
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
Copy,
|
||||
Serialize,
|
||||
PartialEq,
|
||||
Eq,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(
|
||||
description = "Which editor to use when opening files.",
|
||||
rename_all = "snake_case"
|
||||
)]
|
||||
pub enum EditorChoice {
|
||||
SystemDefault,
|
||||
Warp,
|
||||
EnvEditor,
|
||||
#[schemars(description = "A specific external code editor.")]
|
||||
ExternalEditor(super::Editor),
|
||||
}
|
||||
|
||||
// Custom Deserialize implementation to handle backward compatibility
|
||||
// with the old `Option<Editor>` format
|
||||
impl<'de> Deserialize<'de> for EditorChoice {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum EditorChoiceCompat {
|
||||
// Try new format first
|
||||
New(EditorChoiceInner),
|
||||
// Fall back to old Option<Editor> format
|
||||
Old(Option<super::Editor>),
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
enum EditorChoiceInner {
|
||||
SystemDefault,
|
||||
Warp,
|
||||
EnvEditor,
|
||||
ExternalEditor(super::Editor),
|
||||
}
|
||||
|
||||
match EditorChoiceCompat::deserialize(deserializer)? {
|
||||
EditorChoiceCompat::New(inner) => match inner {
|
||||
EditorChoiceInner::SystemDefault => Ok(EditorChoice::SystemDefault),
|
||||
EditorChoiceInner::Warp => Ok(EditorChoice::Warp),
|
||||
EditorChoiceInner::EnvEditor => Ok(EditorChoice::EnvEditor),
|
||||
EditorChoiceInner::ExternalEditor(editor) => {
|
||||
Ok(EditorChoice::ExternalEditor(editor))
|
||||
}
|
||||
},
|
||||
EditorChoiceCompat::Old(old_value) => match old_value {
|
||||
None => Ok(EditorChoice::SystemDefault),
|
||||
Some(editor) => Ok(EditorChoice::ExternalEditor(editor)),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
define_settings_group!(EditorSettings, settings: [
|
||||
open_file_editor: OpenFileEditor {
|
||||
type: EditorChoice,
|
||||
default: EditorChoice::SystemDefault,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: false,
|
||||
toml_path: "code.editor.open_file_editor",
|
||||
max_table_depth: 0,
|
||||
description: "The editor used to open files.",
|
||||
},
|
||||
open_code_panels_file_editor: OpenCodePanelsFileEditor {
|
||||
type: EditorChoice,
|
||||
default: EditorChoice::Warp,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: false,
|
||||
toml_path: "code.editor.open_code_panels_file_editor",
|
||||
max_table_depth: 0,
|
||||
description: "The editor used to open files from code panels.",
|
||||
},
|
||||
open_file_layout: OpenFileLayout {
|
||||
type: EditorLayout,
|
||||
default: EditorLayout::SplitPane,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "code.editor.open_file_layout",
|
||||
description: "The layout used when opening files in the editor.",
|
||||
},
|
||||
prefer_markdown_viewer: PreferMarkdownViewer {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "code.editor.prefer_markdown_viewer",
|
||||
description: "Whether to use the Markdown viewer when opening Markdown files.",
|
||||
},
|
||||
prefer_tabbed_editor_view: PreferTabbedEditorView {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "code.editor.prefer_tabbed_editor_view",
|
||||
description: "Whether to prefer opening files in a tabbed editor view.",
|
||||
},
|
||||
open_conversation_layout_preference: OpenConversationLayoutPreference {
|
||||
type: OpenConversationPreference,
|
||||
default: OpenConversationPreference::NewTab,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "agents.warp_agent.other.open_conversation_layout_preference",
|
||||
description: "Whether to open agent conversations in a new tab or a split pane.",
|
||||
},
|
||||
]);
|
||||
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
Copy,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
PartialEq,
|
||||
Eq,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(
|
||||
description = "How to open agent conversations.",
|
||||
rename_all = "snake_case"
|
||||
)]
|
||||
pub enum OpenConversationPreference {
|
||||
NewTab,
|
||||
SplitPane,
|
||||
}
|
||||
|
||||
impl OpenConversationPreference {
|
||||
pub fn is_new_tab(&self) -> bool {
|
||||
matches!(self, Self::NewTab)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
//! 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 command::r#async::Command;
|
||||
use enum_iterator::{all, cardinality};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
use warp_util::path::LineAndColumnArg;
|
||||
use warpui::AppContext;
|
||||
use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE};
|
||||
use winreg::RegKey;
|
||||
use winreg::HKEY;
|
||||
|
||||
use super::Editor;
|
||||
|
||||
static INSTALLED_EDITOR_METADATA: OnceLock<HashMap<Editor, EditorMetadata>> = OnceLock::new();
|
||||
|
||||
struct EditorMetadata {
|
||||
#[allow(unused)]
|
||||
executable_path: PathBuf,
|
||||
}
|
||||
|
||||
/// Enum denoting the method to determine the installation location for a supported editor.
|
||||
enum ExecutableLocationMethod {
|
||||
/// Use the "DisplayIcon" Windows registry key to determine where the app was installed.
|
||||
DisplayIcon,
|
||||
/// Use the "InstallLocation" Windows registry key to determine where the app was installed.
|
||||
InstallLocation {
|
||||
/// The path to the _executable_ from the top level directory where the executable is
|
||||
/// installed.
|
||||
path_to_executable: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
impl ExecutableLocationMethod {
|
||||
fn get_executable_path(&self, application_info: RegKey) -> Option<PathBuf> {
|
||||
match self {
|
||||
ExecutableLocationMethod::DisplayIcon => {
|
||||
let display_icon = application_info
|
||||
.get_value::<String, _>("DisplayIcon")
|
||||
.ok()?;
|
||||
|
||||
// Paths for the DisplayIcon key include:
|
||||
// * An icon index after a comma (e.g., "C:\Path\app.exe,0")
|
||||
// * Optionally, surrounding quotes (e.g., ""C:\Path\app.exe",0")
|
||||
// Remove the trailing comma and the surrounding quotes.
|
||||
// This is also the approach GitHub Desktop takes: https://github.com/desktop/desktop/blob/development/app/src/lib/editors/win32.ts#L153.
|
||||
let (path, _) = display_icon.rsplit_once(',')?;
|
||||
Some(path.replace("\"", "").into())
|
||||
}
|
||||
ExecutableLocationMethod::InstallLocation { path_to_executable } => {
|
||||
let install_location = application_info
|
||||
.get_value::<String, _>("InstallLocation")
|
||||
.ok()?;
|
||||
Some(PathBuf::from(install_location).join(path_to_executable))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes a list of installed editors, and any corresponding metadata.
|
||||
fn compute_installed_editors() -> HashMap<Editor, EditorMetadata> {
|
||||
const SCOPES: [(HKEY, &str); 3] = [
|
||||
(
|
||||
HKEY_LOCAL_MACHINE,
|
||||
"SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall",
|
||||
),
|
||||
(
|
||||
HKEY_LOCAL_MACHINE,
|
||||
"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall",
|
||||
),
|
||||
(
|
||||
HKEY_CURRENT_USER,
|
||||
"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall",
|
||||
),
|
||||
];
|
||||
|
||||
let mut installed_editors = HashMap::with_capacity(cardinality::<Editor>());
|
||||
|
||||
// Generate a mapping from each app ID to the editor the app ID corresponds to.
|
||||
let app_id_to_editors: HashMap<&'static str, Editor> = all::<Editor>()
|
||||
.flat_map(|editor| editor.app_ids().iter().map(move |app_id| (*app_id, editor)))
|
||||
.collect();
|
||||
|
||||
// Determine all the installed applications by reading out install metadata from the windows
|
||||
// registry.
|
||||
for (scope, key) in SCOPES {
|
||||
let uninstall_key = match RegKey::predef(scope).open_subkey(key) {
|
||||
Ok(k) => k,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
for application_id in uninstall_key.enum_keys().flatten() {
|
||||
let Some(editor) = app_id_to_editors.get(application_id.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Ok(application_info) = uninstall_key.open_subkey(&application_id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(executable_path) = editor
|
||||
.executable_location_method()
|
||||
.and_then(|key| key.get_executable_path(application_info))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let editor_metadata = EditorMetadata { executable_path };
|
||||
installed_editors.insert(*editor, editor_metadata);
|
||||
}
|
||||
}
|
||||
|
||||
installed_editors
|
||||
}
|
||||
|
||||
impl Editor {
|
||||
pub fn is_installed(&self, _ctx: &mut AppContext) -> bool {
|
||||
INSTALLED_EDITOR_METADATA
|
||||
.get_or_init(compute_installed_editors)
|
||||
.contains_key(self)
|
||||
}
|
||||
|
||||
/// Returns the set of IDs that identify a given Editor.
|
||||
fn app_ids(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Editor::VSCode => {
|
||||
&[
|
||||
// 64-bit version of VSCode (user) - provided by default in 64-bit Windows
|
||||
"{771FD6B0-FA20-440A-A002-3B3BAC16DC50}_is1",
|
||||
// 32-bit version of VSCode (user)
|
||||
"{D628A17A-9713-46BF-8D57-E671B46A741E}_is1",
|
||||
// ARM64 version of VSCode (user)
|
||||
"{D9E514E7-1A56-452D-9337-2990C0DC4310}_is1",
|
||||
// 64-bit version of VSCode (system) - was default before user scope installation
|
||||
"EA457B21-F73E-494C-ACAB-524FDE069978}_is1",
|
||||
// 32-bit version of VSCode (system)
|
||||
"{F8A2A208-72B3-4D61-95FC-8A65D340689B}_is1",
|
||||
// ARM64 version of VSCode (system)
|
||||
"{A5270FC5-65AD-483E-AC30-2C276B63D0AC}_is1",
|
||||
]
|
||||
}
|
||||
Editor::Cursor => &["62625861-8486-5be9-9e46-1da50df5f8ff"],
|
||||
Editor::Windsurf => &["{5A8B7D94-9B5F-4D1F-93FC-5609F7159349}_is1"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
fn executable_location_method(&self) -> Option<ExecutableLocationMethod> {
|
||||
match self {
|
||||
Editor::VSCode => Some(ExecutableLocationMethod::InstallLocation {
|
||||
path_to_executable: Path::new("bin").join("code.exe"),
|
||||
}),
|
||||
Editor::Windsurf => Some(ExecutableLocationMethod::InstallLocation {
|
||||
path_to_executable: Path::new("bin").join("windsurf.exe"),
|
||||
}),
|
||||
Editor::Cursor => Some(ExecutableLocationMethod::DisplayIcon),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn command(
|
||||
&self,
|
||||
line_column_number: Option<LineAndColumnArg>,
|
||||
full_path: &Path,
|
||||
) -> Option<Command> {
|
||||
let command = match self {
|
||||
Editor::VSCode => {
|
||||
let mut command = Command::new("explorer.exe");
|
||||
let suffix = line_column_number
|
||||
.as_ref()
|
||||
.map(LineAndColumnArg::to_string_suffix)
|
||||
.unwrap_or_default();
|
||||
command.arg(format!("vscode://file/{}{suffix}", full_path.display()));
|
||||
command
|
||||
}
|
||||
Editor::Cursor => {
|
||||
let mut command = Command::new("explorer.exe");
|
||||
let suffix = line_column_number
|
||||
.as_ref()
|
||||
.map(LineAndColumnArg::to_string_suffix)
|
||||
.unwrap_or_default();
|
||||
command.arg(format!("cursor://file/{}{suffix}", full_path.display()));
|
||||
command
|
||||
}
|
||||
Editor::Windsurf => {
|
||||
let mut command = Command::new("explorer.exe");
|
||||
let suffix = line_column_number
|
||||
.as_ref()
|
||||
.map(LineAndColumnArg::to_string_suffix)
|
||||
.unwrap_or_default();
|
||||
command.arg(format!("windsurf://file/{}{suffix}", full_path.display()));
|
||||
command
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some(command)
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the given file in the specified editor.
|
||||
///
|
||||
/// If `line_column_number` is `Some`, the file will be opened with the cursor
|
||||
/// at the given location (if supported by the editor).
|
||||
///
|
||||
/// If with_editor is `None`, we attempt to compute the default editor for the
|
||||
/// given file type, and open the file there.
|
||||
pub fn open_file_path_with_line_and_col(
|
||||
line_column_number: Option<LineAndColumnArg>,
|
||||
mut with_editor: Option<Editor>,
|
||||
full_path: &Path,
|
||||
ctx: &mut AppContext,
|
||||
) {
|
||||
if full_path.is_file() {
|
||||
with_editor = with_editor.filter(|editor| editor.is_installed(ctx));
|
||||
if let Some(editor) = with_editor {
|
||||
if let Some(mut command) = editor.command(line_column_number, full_path) {
|
||||
if let Err(err) = command.spawn() {
|
||||
log::error!("Error launching {editor:?}: {err:#}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.open_file_path(full_path);
|
||||
}
|
||||
Reference in New Issue
Block a user