use std::path::{Path, PathBuf};
use std::sync::Mutex;
use fluidattacks_designs_domain::file_picker::Request;
use futures_channel::oneshot;
use gpui::{App, Task};
use objc2::rc::Retained;
use objc2::MainThreadMarker;
use objc2_app_kit::{NSModalResponseOK, NSOpenPanel};
use objc2_foundation::{NSArray, NSString, NSURL};
use objc2_uniform_type_identifiers::UTType;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub enum Picked {
Paths(Vec<PathBuf>),
#[default]
Cancelled,
}
impl Picked {
#[must_use]
pub fn paths(&self) -> &[PathBuf] {
match self {
Self::Paths(paths) => paths,
Self::Cancelled => &[],
}
}
#[must_use]
pub fn path(&self) -> Option<&Path> {
self.paths().first().map(PathBuf::as_path)
}
#[must_use]
pub const fn cancelled(&self) -> bool {
matches!(self, Self::Cancelled)
}
}
struct Owned {
files: bool,
directories: bool,
multiple: bool,
extensions: Vec<String>,
title: Option<String>,
start_in: Option<String>,
}
impl From<&Request<'_>> for Owned {
fn from(request: &Request<'_>) -> Self {
let selection = request.selection();
Self {
files: selection.files(),
directories: selection.directories(),
multiple: selection.multiple(),
extensions: request.accepted().map(String::from).collect(),
title: request.title_text().map(String::from),
start_in: request.starting_directory().map(String::from),
}
}
}
pub fn open(request: &Request<'_>, cx: &App) -> Task<Picked> {
let owned = Owned::from(request);
let (send, receive) = oneshot::channel();
cx.foreground_executor()
.spawn(async move {
present(&owned, send);
})
.detach();
cx.background_executor()
.spawn(async move { receive.await.unwrap_or_default() })
}
fn present(owned: &Owned, send: oneshot::Sender<Picked>) {
let Some(marker) = MainThreadMarker::new() else {
let _ = send.send(Picked::Cancelled);
return;
};
let panel = NSOpenPanel::openPanel(marker);
panel.setCanChooseFiles(owned.files);
panel.setCanChooseDirectories(owned.directories);
panel.setAllowsMultipleSelection(owned.multiple);
if let Some(title) = owned.title.as_deref() {
panel.setMessage(Some(&NSString::from_str(title)));
}
if let Some(start_in) = owned.start_in.as_deref() {
let url = NSURL::fileURLWithPath_isDirectory(&NSString::from_str(start_in), true);
panel.setDirectoryURL(Some(&url));
}
let accepted = content_types(&owned.extensions);
if !accepted.is_empty() {
panel.setAllowedContentTypes(&NSArray::from_retained_slice(&accepted));
}
let send = Mutex::new(Some(send));
let dismissed = Retained::clone(&panel);
let handler = block2::RcBlock::new(move |response| {
if let Some(reply) = send.lock().ok().and_then(|mut slot| slot.take()) {
let _ = reply.send(chosen(&dismissed, response));
}
});
panel.beginWithCompletionHandler(&handler);
}
fn content_types(extensions: &[String]) -> Vec<Retained<UTType>> {
extensions
.iter()
.filter_map(|extension| UTType::typeWithFilenameExtension(&NSString::from_str(extension)))
.collect()
}
fn chosen(panel: &NSOpenPanel, response: objc2_app_kit::NSModalResponse) -> Picked {
if response != NSModalResponseOK {
return Picked::Cancelled;
}
let paths: Vec<PathBuf> = panel
.URLs()
.iter()
.filter_map(|url| url.path())
.map(|path| PathBuf::from(path.to_string()))
.collect();
if paths.is_empty() {
return Picked::Cancelled;
}
Picked::Paths(paths)
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use fluidattacks_designs_domain::file_picker::{Request, Selection};
use super::{Owned, Picked};
#[test]
fn a_cancelled_pick_carries_no_path() {
let picked = Picked::Cancelled;
assert!(picked.cancelled());
assert!(picked.paths().is_empty());
assert_eq!(picked.path(), None);
}
#[test]
fn a_pick_carries_every_path_the_reader_chose() {
let picked = Picked::Paths(vec![
PathBuf::from("/tmp/a.csv"),
PathBuf::from("/tmp/b.csv"),
]);
assert!(!picked.cancelled());
assert_eq!(picked.paths().len(), 2);
assert_eq!(picked.path(), Some(Path::new("/tmp/a.csv")));
}
#[test]
fn a_pick_defaults_to_cancelled() {
assert!(Picked::default().cancelled());
}
#[test]
fn the_panel_owns_what_the_request_only_borrowed() {
let owned = Owned::from(&Request::new(Selection::Files).extensions(&["*.png", "*"]));
assert!(owned.files && owned.multiple && !owned.directories);
assert_eq!(owned.extensions, ["png"]);
assert_eq!(owned.title, None);
assert_eq!(owned.start_in, None);
}
#[test]
fn a_directory_request_reaches_the_panel_as_one() {
let owned = Owned::from(
&Request::new(Selection::Directory)
.title("Select Directory")
.start_in("/var/log"),
);
assert!(owned.directories && !owned.files && !owned.multiple);
assert_eq!(owned.title.as_deref(), Some("Select Directory"));
assert_eq!(owned.start_in.as_deref(), Some("/var/log"));
}
}