//! 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, /// The base session when the link was resolved. It's stored here in case it changes /// between resolving and opening the link. session: Arc, /// 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 { 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 { 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> { 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::().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, line_and_column: Option, ) -> Result { 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) { 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) { 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, ) -> 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.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, ctx: &mut ModelContext, ) { // 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, ctx: &mut ModelContext, ) { #[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 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, }, 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, }, } /// A secondary action for a link, besides opening it. #[derive(Debug, Clone)] pub struct SecondaryAction { pub label: Cow<'static, str>, pub tooltip: Option>, 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, base_directory: PathBuf, }, /// Use the window's active session and working directory. Active(WindowId), } impl SessionSource { fn session(&self, ctx: &AppContext) -> Option> { 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) } } } }