Files
galaxy/app/src/notebooks/link.rs
T

466 lines
17 KiB
Rust

//! Link-opening behavior for notebooks.
use std::{
borrow::Cow,
fmt,
future::{self, Future},
net::IpAddr,
path::{Path, PathBuf},
sync::Arc,
};
use futures_util::future::Either;
use galaxy_util::path::{CleanPathResult, LineAndColumnArg};
use galaxyui::{
r#async::SpawnedFutureHandle, AppContext, Entity, ModelContext, ModelHandle, SingletonEntity,
WindowId,
};
use url::Url;
#[cfg(feature = "local_fs")]
use crate::util::file::external_editor::EditorSettings;
#[cfg(feature = "local_fs")]
use crate::util::openable_file_type::{is_supported_image_file, resolve_file_target, FileTarget};
use crate::{
drive::OpenWarpDriveObjectArgs,
terminal::model::session::Session,
uri::parse_url_paths::{get_item_data_from_warp_link, WarpWebLink},
workspace::ActiveSession,
};
use super::file::is_markdown_file;
#[cfg(test)]
#[path = "link_tests.rs"]
mod tests;
/// The target of a notebook link.
#[derive(Debug, Clone)]
pub enum LinkTarget {
Url(Url),
LocalFile {
path: PathBuf,
line_and_column: Option<LineAndColumnArg>,
/// The base session when the link was resolved. It's stored here in case it changes
/// between resolving and opening the link.
session: Arc<Session>,
/// Whether or not this file is a Markdown file viewable in Warp.
is_markdown: bool,
},
LocalDirectory {
path: PathBuf,
},
}
impl LinkTarget {
/// A secondary action to show in the tooltip for this link.
pub fn secondary_action(&self) -> Option<SecondaryAction> {
match self {
LinkTarget::LocalDirectory { .. } => Some(SecondaryAction {
label: "New session".into(),
tooltip: Some("Open a new terminal session in this directory".into()),
accessibility_content: "Open in terminal session".into(),
}),
LinkTarget::LocalFile {
is_markdown: true, ..
} => Some(SecondaryAction {
label: "Open in editor".into(),
tooltip: None,
accessibility_content: "Edit Markdown file".into(),
}),
LinkTarget::Url(_) | LinkTarget::LocalFile { .. } => None,
}
}
}
impl PartialEq for LinkTarget {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::Url(my_url), Self::Url(other_url)) => my_url == other_url,
(
Self::LocalFile {
path: my_path,
line_and_column: my_location,
session: my_session,
..
},
Self::LocalFile {
path: other_path,
line_and_column: other_location,
session: other_session,
..
},
) => {
my_path == other_path
&& my_location == other_location
&& Arc::ptr_eq(my_session, other_session)
}
(Self::LocalDirectory { path: my_path }, Self::LocalDirectory { path: other_path }) => {
my_path == other_path
}
_ => false,
}
}
}
impl fmt::Display for LinkTarget {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
LinkTarget::Url(url) => url.fmt(f),
LinkTarget::LocalFile { path, .. } => path.display().fmt(f),
LinkTarget::LocalDirectory { path, .. } => path.display().fmt(f),
}
}
}
/// Model for resolving and opening links in a notebook, taking into account their context (for
/// example, resolving relative file paths).
pub struct NotebookLinks {
session_source: SessionSource,
}
impl NotebookLinks {
pub fn new(session_source: SessionSource, ctx: &mut ModelContext<Self>) -> Self {
ctx.observe(
&ActiveSession::handle(ctx),
Self::handle_active_session_change,
);
Self { session_source }
}
/// Resolve a link target. If the link is a valid URL or starts with a potential domain name,
/// it's treated as an URL. Otherwise, it's treated as a local file path, possibly with a line
/// and column number. This returns `None` if the link is known to be invalid (for example, it
/// resolves to a nonexistent file path).
pub fn resolve(
&self,
link: &str,
ctx: &AppContext,
) -> impl Future<Output = Result<LinkTarget, ResolveError>> {
if let Ok(url) = Url::parse(link) {
// The `url` crate only provides `to_file_path` on certain platforms.
#[cfg(feature = "local_fs")]
if url.scheme() == "file" {
// Unlike below, if there's missing information, we can still fall back to the
// system for file:// URL handling.
if let Some(session) = self.session_source.session(ctx) {
if let Ok(file) = url.to_file_path() {
// TODO(ben): Support line and column in file:// URLs.
return Either::Left(Self::resolve_file(file, session, None));
}
}
}
return Either::Right(future::ready(Ok(LinkTarget::Url(url))));
}
// If parsing failed, see if this is a web URL without a scheme.
// The heuristic we use is to take the substring up to the first slash (if present), and
// check for a valid public domain name or IP address.
let maybe_domain = link.split_once('/').map_or(link, |(start, _)| start);
if addr::parse_domain_name(maybe_domain)
.is_ok_and(|domain| domain.has_known_suffix() && domain.root().is_some())
|| maybe_domain.parse::<IpAddr>().is_ok()
{
if let Ok(url) = Url::parse(&format!("http://{link}")) {
return Either::Right(future::ready(Ok(LinkTarget::Url(url))));
}
}
// At this point, we can only resolve file targets, which require a session.
match self.session_source.session(ctx) {
Some(session) if session.launch_data().is_some() => {
let launch_data = session
.launch_data()
.expect("Session launch data should exist");
let clean_path = CleanPathResult::with_line_and_column_number(link);
let path = match self.session_source.base_directory(ctx) {
Some(base_directory) => {
cfg_if::cfg_if! {
if #[cfg(feature = "local_fs")] {
let Some(path) = crate::util::file::absolute_path_if_valid(
&clean_path,
crate::util::file::ShellPathType::PlatformNative(base_directory.to_path_buf()),
Some(launch_data),
) else {
return Either::Right(future::ready(Err(ResolveError::FileNotFound)));
};
path
} else {
// If we don't have a local filesystem, we append the path naively.
base_directory.join(clean_path.path)
}
}
}
None => {
let Some(path) = launch_data.maybe_convert_absolute_path(&clean_path.path)
else {
return Either::Right(future::ready(Err(ResolveError::MissingContext)));
};
// To open a relative path, we must have a base directory. Otherwise, we don't know for
// sure how the path will be resolved.
if path.is_relative() {
return Either::Right(future::ready(Err(ResolveError::MissingContext)));
}
path
}
};
Either::Left(Self::resolve_file(
path,
session,
clean_path.line_and_column_num,
))
}
Some(session) => {
let clean_path_result = CleanPathResult::with_line_and_column_number(link);
let clean_path = Path::new(&clean_path_result.path);
let path = if clean_path.is_relative() {
// To open a relative path, we must have a base directory. Otherwise, we don't know for
// sure how the path will be resolved.
match self.session_source.base_directory(ctx) {
Some(directory) => directory.join(clean_path),
None => {
return Either::Right(future::ready(Err(ResolveError::MissingContext)))
}
}
} else {
clean_path.to_path_buf()
};
Either::Left(Self::resolve_file(
path,
session,
clean_path_result.line_and_column_num,
))
}
None => Either::Right(future::ready(Err(ResolveError::MissingContext))),
}
}
/// Resolve a file path into a [`LinkTarget`], checking if it exists.
async fn resolve_file(
path: PathBuf,
session: Arc<Session>,
line_and_column: Option<LineAndColumnArg>,
) -> Result<LinkTarget, ResolveError> {
let metadata = async_fs::metadata(&path).await?;
Ok(if metadata.is_dir() {
// Discard line/column information, which doesn't make sense for a directory.
LinkTarget::LocalDirectory { path }
} else {
LinkTarget::LocalFile {
is_markdown: is_markdown_file(&path),
path,
line_and_column,
session,
}
})
}
/// Open a resolved link:
/// * URLs are opened in the web browser or system-default application.
/// * Markdown files are opened in Warp (if the `FileNotebooks` feature flag is enabled).
/// * Other files are opened in the configured editor or system-default application.
pub fn open(&self, link: LinkTarget, ctx: &mut ModelContext<Self>) {
match link {
LinkTarget::Url(url) => {
if let Some(WarpWebLink::DriveObject(args)) = get_item_data_from_warp_link(&url) {
return ctx.emit(LinkEvent::OpenWarpDriveLink {
open_warp_drive_args: *args,
});
}
ctx.open_url(url.as_str())
}
LinkTarget::LocalFile {
path,
session,
is_markdown: true,
..
} => {
ctx.emit(LinkEvent::OpenFileNotebook { path, session });
}
LinkTarget::LocalFile {
path,
line_and_column,
..
} => open_file(path, line_and_column, ctx),
LinkTarget::LocalDirectory { path, .. } => ctx.open_file_path(&path),
}
}
/// Perform the secondary action for this link.
pub fn secondary_action(&self, link: &LinkTarget, ctx: &mut ModelContext<Self>) {
match link {
LinkTarget::LocalDirectory { path } => {
ctx.emit(LinkEvent::StartLocalSession { path: path.clone() })
}
LinkTarget::LocalFile {
path,
line_and_column,
is_markdown: true,
..
} => {
// The default action for Markdown file links is to open them in Warp. As a
// secondary action, open them in an external app.
open_file(path.clone(), *line_and_column, ctx)
}
_ => (),
}
}
/// Asynchronously resolve and open a link.
pub fn resolve_and_open(
&self,
link: &str,
ctx: &mut ModelContext<Self>,
) -> SpawnedFutureHandle {
ctx.spawn(self.resolve(link, ctx), |me, resolved, ctx| {
if let Ok(link) = resolved {
me.open(link, ctx);
}
})
}
pub fn set_session_source(&mut self, source: SessionSource, ctx: &mut ModelContext<Self>) {
self.session_source = source;
ctx.emit(LinkEvent::RefreshLinks);
}
/// Listen for session changes that might invalidate resolved links.
fn handle_active_session_change(
&mut self,
_handle: ModelHandle<ActiveSession>,
ctx: &mut ModelContext<Self>,
) {
// Re-resolve links against the new session info, especially if the working directory
// changed.
if matches!(self.session_source, SessionSource::Active(_)) {
ctx.emit(LinkEvent::RefreshLinks);
}
}
}
/// Open a file respecting user's editor settings.
// The `line_and_column` argument is unused when there is no local filesystem.
#[cfg_attr(not(feature = "local_fs"), allow(unused_variables))]
fn open_file(
path: PathBuf,
line_and_column: Option<LineAndColumnArg>,
ctx: &mut ModelContext<NotebookLinks>,
) {
#[cfg(feature = "local_fs")]
{
let target = if is_supported_image_file(&path) {
FileTarget::SystemGeneric
} else {
let settings = EditorSettings::as_ref(ctx);
resolve_file_target(&path, settings, None)
};
ctx.emit(LinkEvent::OpenFileWithTarget {
path,
target,
line_col: line_and_column,
});
}
#[cfg(not(feature = "local_fs"))]
ctx.open_file_path(&path);
}
impl Entity for NotebookLinks {
type Event = LinkEvent;
}
/// An error resolving a file link.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResolveError {
/// The target file does not exist.
FileNotFound,
/// The context needed to resolve a file is missing.
MissingContext,
Unknown,
}
impl From<std::io::Error> for ResolveError {
fn from(err: std::io::Error) -> Self {
if err.kind() == std::io::ErrorKind::NotFound {
ResolveError::FileNotFound
} else {
ResolveError::Unknown
}
}
}
impl fmt::Display for ResolveError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ResolveError::FileNotFound => f.write_str("File not found"),
ResolveError::MissingContext => f.write_str("No base directory"),
ResolveError::Unknown => f.write_str("Broken file link"),
}
}
}
#[derive(Debug, Clone)]
pub enum LinkEvent {
/// Emitted when the view should open a Markdown file as a notebook.
OpenFileNotebook {
path: PathBuf,
session: Arc<Session>,
},
OpenWarpDriveLink {
open_warp_drive_args: OpenWarpDriveObjectArgs,
},
/// This event tells the parent pane group to open a new terminal session in the given
/// directory.
StartLocalSession { path: PathBuf },
/// Signal to views that they should re-resolve links because the backing context for
/// resolution has changed.
RefreshLinks,
#[cfg(feature = "local_fs")]
/// Emitted when a file should be opened in Warp (code editor or markdown viewer).
OpenFileWithTarget {
path: PathBuf,
target: FileTarget,
line_col: Option<LineAndColumnArg>,
},
}
/// A secondary action for a link, besides opening it.
#[derive(Debug, Clone)]
pub struct SecondaryAction {
pub label: Cow<'static, str>,
pub tooltip: Option<Cow<'static, str>>,
pub accessibility_content: Cow<'static, str>,
}
/// Source for the [`Session`] and working directory to use when opening Markdown files as notebooks.
pub enum SessionSource {
/// Use the specific target session and directory.
Target {
session: Arc<Session>,
base_directory: PathBuf,
},
/// Use the window's active session and working directory.
Active(WindowId),
}
impl SessionSource {
fn session(&self, ctx: &AppContext) -> Option<Arc<Session>> {
match self {
SessionSource::Target { session, .. } => Some(session.clone()),
SessionSource::Active(window_id) => ActiveSession::as_ref(ctx).session(*window_id),
}
}
fn base_directory<'a>(&'a self, ctx: &'a AppContext) -> Option<&'a Path> {
match self {
SessionSource::Target { base_directory, .. } => Some(base_directory.as_path()),
SessionSource::Active(window_id) => {
ActiveSession::as_ref(ctx).path_if_local(*window_id)
}
}
}
}