573 lines
22 KiB
Rust
573 lines
22 KiB
Rust
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;
|