use super::*;
use anyhow::{Context as _, Result, bail};
use base64::{Engine as _, engine::general_purpose::STANDARD};
use bezel::ui::{icons, popover, tooltip::Tooltip};
use std::{process::Command, sync::Arc};
const APPLICATIONS: &str = r#"
ObjC.import('AppKit');
function run(argv) {
const workspace = $.NSWorkspace.sharedWorkspace;
const urls = workspace.URLsForApplicationsToOpenURL($.NSURL.fileURLWithPath(argv[0]));
const apps = [];
const seen = new Set();
function add(path, name, kind) {
if (!path || seen.has(path)) return;
seen.add(path);
let icon = '';
try {
const image = workspace.iconForFile(path);
const bitmap = $.NSBitmapImageRep.imageRepWithData(image.TIFFRepresentation);
icon = ObjC.unwrap(bitmap.representationUsingTypeProperties($.NSBitmapImageFileTypePNG, $({})).base64EncodedStringWithOptions(0));
} catch (_) {}
apps.push({name: name, path: path, kind: kind || '', icon: icon});
}
// Editors need not register for every source-file extension.
const zed = workspace.URLForApplicationWithBundleIdentifier('dev.zed.Zed');
if (zed && !zed.isNil()) add(ObjC.unwrap(zed.path), 'Zed');
const roots = ['/Applications', ObjC.unwrap($.NSHomeDirectory()) + '/Applications'];
const editors = ['Zed', 'Zed Preview', 'Visual Studio Code', 'Visual Studio Code - Insiders', 'Cursor', 'Windsurf', 'Sublime Text', 'Nova', 'BBEdit', 'Xcode'];
for (const root of roots) {
for (const name of editors) {
const path = root + '/' + name + '.app';
if ($.NSFileManager.defaultManager.fileExistsAtPath(path)) add(path, name);
}
}
for (let i = 0; i < urls.count; i++) {
const url = urls.objectAtIndex(i);
add(ObjC.unwrap(url.path), ObjC.unwrap(url.lastPathComponent.stringByDeletingPathExtension));
}
add('/System/Library/CoreServices/Finder.app', 'Default app', 'default');
add('/System/Applications/Utilities/Terminal.app', 'Terminal', 'terminal');
return JSON.stringify(apps);
}
"#;
#[derive(Clone, serde::Deserialize)]
pub(super) struct Application {
name: String,
path: PathBuf,
#[serde(default)]
kind: String,
#[serde(default)]
icon: String,
#[serde(skip)]
image: Option<Arc<gpui::Image>>,
}
impl Application {
fn target(&self) -> Target {
match self.kind.as_str() {
"default" => Target::Default,
"terminal" => Target::Terminal,
_ => Target::Application(self.path.clone()),
}
}
fn icon(&self) -> gpui::AnyElement {
if let Some(image) = &self.image {
gpui::img(image.clone()).size(px(18.)).into_any_element()
} else {
icons::icon(icons::files::File)
.size(px(18.))
.into_any_element()
}
}
}
#[derive(Default)]
pub(super) struct Menu {
open: bool,
pressed: bool,
loaded: bool,
loading: bool,
apps: Vec<Application>,
}
#[derive(Clone)]
pub(super) enum Target {
Application(PathBuf),
Default,
Terminal,
Folder,
}
fn open_command(file: &Path, target: &Target) -> Command {
let mut command = Command::new("/usr/bin/open");
let path = match target {
Target::Application(app) => {
command.arg("-a").arg(app);
file
}
Target::Default => file,
Target::Terminal => {
command.args(["-a", "Terminal"]);
file.parent().unwrap_or(file)
}
Target::Folder => {
command.arg("-R");
file
}
};
command.arg("--").arg(path);
command
}
fn output(result: std::process::Output) -> Result<String> {
if !result.status.success() {
bail!("{}", String::from_utf8_lossy(&result.stderr).trim());
}
Ok(String::from_utf8(result.stdout)?
.trim_end_matches('\n')
.to_owned())
}
fn applications(file: &Path) -> Result<Vec<Application>> {
let json = output(
Command::new("/usr/bin/osascript")
.args(["-l", "JavaScript", "-e", APPLICATIONS])
.arg(file)
.output()
.context("Could not find applications")?,
)?;
let mut apps: Vec<Application> = serde_json::from_str(&json)?;
apps.retain(|app| !app.name.eq_ignore_ascii_case("cydonia"));
apps.sort_by_cached_key(|app| {
(
!app.kind.is_empty(),
app.name != "Zed",
app.name.to_lowercase(),
)
});
for app in &mut apps {
app.image = STANDARD
.decode(std::mem::take(&mut app.icon))
.ok()
.filter(|bytes| !bytes.is_empty())
.map(|bytes| Arc::new(gpui::Image::from_bytes(gpui::ImageFormat::Png, bytes)));
}
apps.dedup_by(|a, b| a.path == b.path);
Ok(apps)
}
pub(super) fn open(file: &Path, target: &Target) -> Result<()> {
output(
open_command(file, target)
.output()
.context("Could not open the selected destination")?,
)?;
Ok(())
}
impl FileView {
pub(super) fn external_button(&mut self, cx: &mut Context<Self>) -> gpui::AnyElement {
if !self.external_menu.loaded && !self.external_menu.loading {
self.external_menu.loading = true;
let path = self.path.clone();
cx.spawn(async move |this, cx| {
let result = cx
.background_executor()
.spawn(async move { applications(&path) })
.await;
let _ = this.update(cx, |this, cx| {
this.external_menu.loading = false;
this.external_menu.loaded = true;
match result {
Ok(apps) => {
this.external_menu.apps = apps;
}
Err(error) => {
this.external_error =
Some(format!("Could not list applications: {error:#}"))
}
}
cx.notify();
});
})
.detach();
}
let theme = Theme::of(cx).clone();
let tooltip = if self.opening_external {
"Opening…"
} else if self.dirty(cx) {
"Open externally (saves edits first)"
} else {
"Open externally"
};
let popup = self.external_menu.open.then(|| {
let mut card = popover::popover_card(&theme)
.min_w(px(210.))
.on_mouse_down_out(cx.listener(|this, _, _, cx| {
this.external_menu.open = false;
cx.notify();
cx.stop_propagation();
}));
for (index, app) in self.external_menu.apps.iter().enumerate() {
let app = app.clone();
card = card.child(
popover::menu_row(&theme, false, None)
.id(("external-app", index))
.hover(|row| row.bg(theme.element_hover))
.child(app.icon())
.child(app.name.clone())
.on_click(cx.listener(move |this, _, _, cx| {
this.external_menu.open = false;
this.open_external(app.target(), cx);
cx.notify();
})),
);
}
if self.external_menu.loading {
card = card.child(div().p(px(8.)).child("Loading apps…"));
}
card = card.child(popover::divider()).child(
popover::menu_row(&theme, false, None)
.id("external-folder")
.hover(|row| row.bg(theme.element_hover))
.child("Open in folder")
.on_click(cx.listener(|this, _, _, cx| {
this.external_menu.open = false;
this.open_external(Target::Folder, cx);
cx.notify();
})),
);
popover::anchored_menu_above_end("file-open-menu", card.into_any_element(), None)
});
div()
.relative()
.flex_none()
.flex()
.items_center()
.child(
div()
.id("file-open-with")
.debug_selector(|| "file-open-with".into())
.w(px(20.))
.h(px(22.))
.flex()
.items_center()
.justify_center()
.rounded(px(6.))
.cursor_pointer()
.hover(|button| button.bg(theme.element_hover))
.when(self.opening_external, |button| button.opacity(0.5))
.tooltip(move |window, cx| Tooltip::text(tooltip, window, cx))
.child(
icons::icon(icons::glyph::Dock)
.size(px(14.))
.text_color(theme.text_muted),
)
.capture_any_mouse_down(cx.listener(|this, _, _, _| {
this.external_menu.pressed = this.external_menu.open
}))
.on_click(cx.listener(|this, _, _, cx| {
this.external_menu.open =
!(std::mem::take(&mut this.external_menu.pressed)
|| this.external_menu.open);
cx.notify();
})),
)
.children(popup)
.into_any_element()
}
}
#[cfg(test)]
#[path = "../../../../tests/unit/external_apps.rs"]
mod tests;