fluidattacks-designs 0.11.0

Fluid Attacks design system, GPUI variant: desktop widgets over the shared tokens
//! The native file picker — one typed call over the host's own open panel.
//!
//! Not a widget: the dialog belongs to the operating system, so there is
//! nothing to style, and going native is what keeps the sandbox and permission
//! behaviour a design-system dialog would throw away. The request values are
//! toolkit-free and live in [`crate::tokens::file_picker`]; this module is only the
//! platform call.
//!
//! It reaches `NSOpenPanel` directly rather than through gpui's
//! `prompt_for_paths`, whose `PathPromptOptions` carries neither an extension
//! filter nor a starting directory — the two things every consuming flow asks
//! for. objc2's typed bindings mark the panel's methods safe, so this needs no
//! `unsafe`, the same trade `develops/mrq/gpui-mac` makes for the `AppKit` calls
//! gpui does not expose.
//!
//! The panel is modeless and driven by `AppKit`'s own event loop, so the call
//! returns a [`gpui::Task`] rather than blocking: the reader browsing for a file
//! must not stop the app behind it from painting. Nothing here throttles it,
//! so a caller that opens a panel per click opens that many panels — raise one
//! at a time.
//!
//! Two limits worth knowing before reading a path back:
//!
//! - Paths come back through `NSString`, so a name macOS holds as bytes but
//!   cannot represent as UTF-8 does not survive the trip and is dropped. Taking
//!   the bytes instead needs `unsafe`, which this workspace denies.
//! - The extension filter is a browser affordance, not validation. It greys out
//!   what the reader can click and says nothing about what a path holds: it may
//!   be a symlink, a device node, a FIFO that blocks on read, or a renamed
//!   archive, and it can be swapped between the panel closing and the file
//!   opening. Verify type and size on the opened handle, never on the path.

use std::path::{Path, PathBuf};
use std::sync::{Mutex, PoisonError};

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;

/// What the reader did with the panel.
///
/// Cancelling is an outcome, not an error: a reader who changes their mind has
/// done nothing wrong, and callers that had to tell it apart from a failure got
/// it wrong inconsistently.
///
/// A `Paths` always holds at least one path — a panel dismissed with nothing
/// readable behind it is a `Cancelled`, so no caller has to handle an empty
/// list as a third case.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub enum Picked {
    Paths(Vec<PathBuf>),
    #[default]
    Cancelled,
}

impl Picked {
    /// Every path chosen — empty when the reader cancelled.
    #[must_use]
    pub fn paths(&self) -> &[PathBuf] {
        match self {
            Self::Paths(paths) => paths,
            Self::Cancelled => &[],
        }
    }

    /// The one path chosen, for the single-file and directory requests that
    /// cannot receive more than one.
    #[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)
    }
}

/// What the panel needs, owned, so it outlives the borrowed [`Request`] that
/// described it.
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),
        }
    }
}

/// Ask the reader for a path, or several, or a folder.
///
/// ```no_run
/// # use fluidattacks_designs::{file_picker, tokens::file_picker::{Request, Selection}};
/// # fn pick(cx: &mut gpui::App) {
/// let request = Request::new(Selection::File)
///     .extensions(&["csv"])
///     .title("Select CSV file");
/// let task = file_picker::open(&request, cx);
/// # }
/// ```
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() })
}

/// Configure and show the panel. Runs on the foreground executor, which is the
/// main thread `AppKit` requires; a marker it cannot get is a process where no
/// panel can open, so the reader is reported as having cancelled.
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));
    }

    // `AppKit` calls the handler at most once but types it as reusable, so a
    // sender that can only be used once has to be taken out of something. The
    // handler runs on this same main thread, so the lock never contends; it is
    // here because the block's signature is `Fn`, not for concurrency — hence
    // taking the sender through a poisoned lock too, since holding it back
    // would leave the awaiting task pending instead of reading a cancel.
    let send = Mutex::new(Some(send));
    // a retain, not a copy: the handler reads the same panel it was attached to
    let dismissed = Retained::clone(&panel);
    let handler = block2::RcBlock::new(move |response| {
        let once = send.lock().unwrap_or_else(PoisonError::into_inner).take();
        if let Some(reply) = once {
            let _ = reply.send(chosen(&dismissed, response));
        }
    });
    panel.beginWithCompletionHandler(&handler);
}

/// The panel's filter, as the content types macOS matches against.
///
/// An extension macOS does not recognise still yields a type — a dynamic one
/// matching nothing on disk — so a typo greys out the whole browser rather than
/// being ignored. Only an extension the system rejects outright yields nothing,
/// and that one is dropped.
fn content_types(extensions: &[String]) -> Vec<Retained<UTType>> {
    extensions
        .iter()
        .filter_map(|extension| UTType::typeWithFilenameExtension(&NSString::from_str(extension)))
        .collect()
}

/// The paths behind a dismissed panel. Anything but OK is the reader backing
/// out, including the abort macOS reports when the panel cannot be shown.
///
/// A path that will not cross into UTF-8 is dropped, and a selection that is
/// entirely such paths reads as a cancel — the alternative is handing back a
/// list shorter than what the reader picked and letting the caller act on it.
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};

    /// A cancelled pick answers every accessor the way an empty one does, so a
    /// caller that forgets to match on it still cannot read a path that was
    /// never chosen.
    #[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());
    }

    /// The panel is configured from owned copies, because it outlives the
    /// borrowed request: a panel reading the caller's slices would be reading
    /// freed memory by the time the reader dismisses it.
    #[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"));
    }
}