Files
galaxy/app/src/terminal/view/link_detection.rs
T

636 lines
22 KiB
Rust

use std::ops::Deref;
use serde::{Serialize, Serializer};
use galaxyui::{platform::Cursor, ViewContext};
use crate::{
send_telemetry_from_ctx,
server::telemetry::{LinkOpenMethod, TelemetryEvent},
terminal::{
model::{
grid::grid_handler::Link,
index::Point,
terminal_model::{WithinBlock, WithinModel},
RespectObfuscatedSecrets,
},
TerminalModel,
},
};
cfg_if::cfg_if! {
if #[cfg(feature = "local_fs")] {
use crate::{
terminal::model::grid::grid_handler,
terminal::ShellLaunchData,
util::file::{FileLink, absolute_path_if_valid, ShellPathType},
util::openable_file_type::FileTarget,
};
use std::path::PathBuf;
use galaxy_util::path::CleanPathResult;
use galaxy_util::path::LineAndColumnArg;
}
}
use super::{FindLinkArg, TerminalEditor};
// "a/" and "b/" are prefixes specific to Git Diff
#[cfg(feature = "local_fs")]
const PREFIXES_TO_REMOVE: [&str; 2] = ["a/", "b/"];
/// "@" is a suffix that can be added to symlinks. It appears in Git Bash's default configuration
/// for `ls`.
#[cfg(feature = "local_fs")]
const SUFFIXES_TO_REMOVE: [&str; 1] = ["@"];
/// Highlighted link within a terminal model grid.
#[derive(Debug, Clone)]
pub enum GridHighlightedLink {
Url(WithinModel<Link>),
#[cfg(feature = "local_fs")]
File(WithinModel<FileLink>),
}
impl GridHighlightedLink {
pub fn contains(&self, position: &WithinModel<Point>) -> bool {
match self {
GridHighlightedLink::Url(url) => url.contains(position),
#[cfg(feature = "local_fs")]
GridHighlightedLink::File(file_link) => file_link.contains(position),
}
}
pub fn tooltip_text(&self) -> &'static str {
match &self {
#[cfg(feature = "local_fs")]
GridHighlightedLink::File(file_link)
if file_link
.get_inner()
.absolute_path()
.map(|path| path.is_dir())
.unwrap_or(false) =>
{
"Open folder"
}
#[cfg(feature = "local_fs")]
GridHighlightedLink::File(_) => "Open file",
GridHighlightedLink::Url(_) => "Open link",
}
}
}
impl Serialize for GridHighlightedLink {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match &self {
GridHighlightedLink::Url(_) => {
serializer.serialize_unit_variant("HighlightedLink", 0, "Url")
}
#[cfg(feature = "local_fs")]
GridHighlightedLink::File(_) => {
serializer.serialize_unit_variant("HighlightedLink", 1, "File")
}
}
}
}
impl TryFrom<GridHighlightedLink> for Link {
type Error = anyhow::Error;
fn try_from(value: GridHighlightedLink) -> Result<Self, Self::Error> {
match value {
GridHighlightedLink::Url(WithinModel::AltScreen(url)) => Ok(url),
#[cfg(feature = "local_fs")]
GridHighlightedLink::File(WithinModel::AltScreen(file_link)) => Ok(file_link.link),
_ => Err(anyhow::anyhow!(
"HighlightedLink is not within the alt screen"
)),
}
}
}
impl TryFrom<GridHighlightedLink> for WithinBlock<Link> {
type Error = anyhow::Error;
fn try_from(value: GridHighlightedLink) -> Result<Self, Self::Error> {
match value {
GridHighlightedLink::Url(WithinModel::BlockList(url)) => Ok(url),
#[cfg(feature = "local_fs")]
GridHighlightedLink::File(WithinModel::BlockList(file_link)) => {
Ok(file_link.map(|file_link| file_link.link))
}
_ => Err(anyhow::anyhow!(
"HighlightedLink is not within the block list"
)),
}
}
}
/// The highlighted_link state is synced with both the BlockList and AltScreen so that they can
/// use the highlighted_link to override the normal smart-selection behavior. The
/// highlighted_link can, for example, verify that a file path actually exists on disk, and
/// include file paths with spaces. Smart-select can do neither of those things.
/// Since this value must be kept in sync, we need to prevent any mutation of the value outside
/// of this wrapper.
#[derive(Debug, Default)]
pub struct HighlightedLinkOption {
inner: Option<GridHighlightedLink>,
/// True if the underlying content has changed such that the link may no longer be valid.
invalidated: bool,
}
#[derive(Clone, Debug)]
pub enum RichContentLink {
Url(String),
#[cfg(feature = "local_fs")]
FilePath {
absolute_path: PathBuf,
line_and_column_num: Option<LineAndColumnArg>,
target_override: Option<FileTarget>,
},
}
impl RichContentLink {
pub fn tooltip_text(&self) -> &'static str {
match &self {
#[cfg(feature = "local_fs")]
RichContentLink::FilePath { absolute_path, .. } if absolute_path.is_dir() => {
"Open folder"
}
#[cfg(feature = "local_fs")]
RichContentLink::FilePath { .. } => "Open file",
RichContentLink::Url(_) => "Open link",
}
}
}
#[derive(Clone, Debug)]
pub struct RichContentLinkTooltipInfo {
pub link: RichContentLink,
pub position_id: String,
}
impl HighlightedLinkOption {
/// Assigns the inner value and syncs it with the BlockList and AltScreen
pub fn set(&mut self, link: GridHighlightedLink, model: &mut TerminalModel) {
match &link {
GridHighlightedLink::Url(within_model) => match within_model {
WithinModel::BlockList(within_block) => {
let point_range = WithinBlock::new(
within_block.inner.range.clone(),
within_block.block_index,
within_block.grid,
);
model
.block_list_mut()
.set_smart_select_override(point_range);
}
WithinModel::AltScreen(link) => {
model
.alt_screen_mut()
.set_smart_select_override(link.range.clone());
}
},
#[cfg(feature = "local_fs")]
GridHighlightedLink::File(within_model) => match within_model {
WithinModel::BlockList(within_block) => {
let point_range = WithinBlock::new(
within_block.inner.link.range.clone(),
within_block.block_index,
within_block.grid,
);
model
.block_list_mut()
.set_smart_select_override(point_range);
}
WithinModel::AltScreen(file_link) => {
model
.alt_screen_mut()
.set_smart_select_override(file_link.link.range.clone());
}
},
}
self.inner = Some(link);
}
/// Wrapper method for Option::take that also keeps the derived state in the BlockList and
/// AltScreen in sync
pub fn take(&mut self, model: &mut TerminalModel) -> Option<GridHighlightedLink> {
model.block_list_mut().clear_smart_select_override();
model.alt_screen_mut().clear_smart_select_override();
self.invalidated = false;
self.inner.take()
}
pub fn invalidate(&mut self) {
self.invalidated = true;
}
pub fn is_invalidated(&self) -> bool {
self.invalidated
}
pub fn clone_inner(&self) -> Option<GridHighlightedLink> {
self.inner.clone()
}
}
impl Deref for HighlightedLinkOption {
type Target = Option<GridHighlightedLink>;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl super::TerminalView {
pub(super) fn maybe_link_hover(
&mut self,
position: &Option<WithinModel<Point>>,
from_editor: TerminalEditor,
ctx: &mut ViewContext<Self>,
) {
// Do not highlight the url while selecting text or blocks, or if the window is not active.
if self.terminal_is_selecting(&self.model.lock(), ctx)
|| self.is_navigated_away_from_window(ctx)
{
if self.highlighted_link.take(&mut self.model.lock()).is_some() {
ctx.reset_cursor();
ctx.notify();
}
return;
}
// If the mouse isn't in the terminal view, we're not hovering any link.
let Some(position) = position else {
if self.highlighted_link.take(&mut self.model.lock()).is_some() {
ctx.reset_cursor();
// Clear last_hover_fragment_boundary when mouse is out of block bounds.
self.last_hover_fragment_boundary = None;
ctx.notify();
}
return;
};
// If the mouse is still on top of the previous highlighted link and that link is
// still valid, we can keep highlighting it.
if let Some(link) = self.highlighted_link.as_ref() {
if link.contains(position) && !self.highlighted_link.is_invalidated() {
// If already hovering on a highlighted link, return.
return;
}
}
// Updating the cursor shape repeatedly can cause flashing, so we only set it once, and only
// when necessary.
let mut new_cursor_shape = None;
// If a link is highlighted and it's invalidated or we're not hovering it, remove that
// hover and look for a new one.
if self.highlighted_link.is_some() {
// Remove the current highlighted link because we are no longer
// hovering over it.
self.highlighted_link.take(&mut self.model.lock());
new_cursor_shape = Some(Cursor::Arrow);
}
let (url_at_point, new_fragment_boundary) = {
let model = self.model.lock();
(
model.url_at_point(position),
model.fragment_boundary_at_point(position),
)
};
match (url_at_point, &self.last_hover_fragment_boundary) {
(Some(url), _) => {
self.highlighted_link
.set(GridHighlightedLink::Url(url), &mut self.model.lock());
new_cursor_shape = Some(Cursor::PointingHand);
}
// Only scan for links if the mouse hovered on a new word.
(_, Some(last_hover_fragment_boundary))
if !last_hover_fragment_boundary.contains(position) =>
{
// Use try_send to return an error directly when the channel is full
// instead of blocking main thread.
let _ = self.find_link_tx.try_send(FindLinkArg {
position: *position,
from_editor,
});
}
// If there's no last hover fragment boundary, we scan for links.
(_, None) => {
let _ = self.find_link_tx.try_send(FindLinkArg {
position: *position,
from_editor,
});
}
_ => (),
};
if let Some(new_cursor_shape) = new_cursor_shape {
ctx.set_cursor_shape(new_cursor_shape);
ctx.notify();
}
self.last_hover_fragment_boundary = Some(new_fragment_boundary);
}
#[cfg_attr(not(feature = "local_fs"), allow(unused_variables))]
pub(super) fn handle_find_link(
&mut self,
find_link_arg: FindLinkArg,
ctx: &mut ViewContext<Self>,
) {
let FindLinkArg {
position,
from_editor,
} = find_link_arg;
// Already highlighted the hovered link, returning.
if self
.highlighted_link
.as_ref()
.is_some_and(|url| url.contains(&position))
{
#[cfg_attr(not(feature = "local_fs"), allow(clippy::needless_return))]
return;
}
#[cfg(feature = "local_fs")]
self.scan_for_file_path(position, from_editor, ctx);
}
pub(super) fn open_highlighted_link(
&mut self,
link: &GridHighlightedLink,
ctx: &mut ViewContext<Self>,
) {
self.dismiss_tooltips(ctx);
ctx.focus(&self.input);
ctx.notify();
send_telemetry_from_ctx!(
TelemetryEvent::OpenLink {
link: link.clone(),
open_with: LinkOpenMethod::ToolTip
},
ctx
);
match link {
#[cfg(feature = "local_fs")]
GridHighlightedLink::File(link) => {
let link = link.get_inner();
if let Some(path) = link.absolute_path() {
self.open_file_path(path.clone(), link.line_and_column_num, ctx);
}
}
GridHighlightedLink::Url(url) => {
let model = self.model.lock();
ctx.open_url(&model.link_at_range(url, RespectObfuscatedSecrets::No));
}
};
}
pub(super) fn open_rich_content_link(
&mut self,
link: &RichContentLink,
ctx: &mut ViewContext<Self>,
) {
self.dismiss_tooltips(ctx);
ctx.focus(&self.input);
ctx.notify();
match link {
#[cfg(feature = "local_fs")]
RichContentLink::FilePath {
absolute_path,
line_and_column_num,
target_override,
} => {
if let Some(target_override) = target_override {
self.open_file_path_with_target(
absolute_path.clone(),
target_override.clone(),
*line_and_column_num,
ctx,
);
} else {
self.open_file_path(absolute_path.clone(), *line_and_column_num, ctx);
}
}
RichContentLink::Url(url) => {
ctx.open_url(url);
}
};
}
}
// A collection of link detection functions that are only valid on platforms
// where we can spawn a local tty.
#[cfg(feature = "local_fs")]
impl super::TerminalView {
/// Scans the terminal model at the given position to see if it is
/// contained within a path that should be linkified.
fn scan_for_file_path(
&mut self,
position: WithinModel<Point>,
from_editor: TerminalEditor,
ctx: &mut ViewContext<Self>,
) {
// For AltScreen we scan for relative path with the current working directory.
// For BlockList we scan for relative path with the pwd of the hovered block.
let pwd_to_scan_for = match position {
WithinModel::AltScreen(_) => self.pwd_if_local(ctx),
WithinModel::BlockList(inner) => self
.model
.lock()
.block_list()
.block_at(inner.block_index)
.filter(|block| !self.is_block_considered_remote(block.session_id(), None, ctx)) // Don't scan for file links if the block is on remote sessions
.and_then(|block| block.pwd().map(String::from)),
};
match pwd_to_scan_for {
// Check if we are hovering on any file path. Don't scan for file path
// if user is hovering from an editor like vim or nano.
Some(path) if matches!(from_editor, TerminalEditor::No) => {
let possible_paths = self.model.lock().possible_file_paths_at_point(position);
let max_columns = self.size_info.columns;
let shell_launch_data = self
.active_block_session_id()
.and_then(|active_session_id| self.sessions.as_ref(ctx).get(active_session_id))
.and_then(|active_session| active_session.launch_data().cloned());
// Using the thread builder instead of ctx.spawn here so that the previous
// scanning job will be dropped once there is a new scanning job created.
let (tx, rx) = futures::channel::oneshot::channel();
self.file_link_scanning_join_handle = std::thread::Builder::new()
.name("Compute file paths".into())
.spawn(move || {
let paths = Self::compute_valid_paths(
&path,
possible_paths,
max_columns,
shell_launch_data,
);
let _ = tx.send(paths);
})
.map_err(|e| {
log::error!("Unable to spawn thread {e:?}");
})
.ok();
let _ = ctx.spawn(
async move { rx.await.ok().flatten() },
Self::handle_file_link_completed,
);
}
_ if self.highlighted_link.take(&mut self.model.lock()).is_some() => {
ctx.reset_cursor();
ctx.notify();
}
_ => (),
};
}
fn compute_valid_paths(
working_directory: &str,
possible_paths: impl Iterator<Item = WithinModel<grid_handler::PossiblePath>>,
max_columns: usize,
shell_launch_data: Option<ShellLaunchData>,
) -> Option<GridHighlightedLink> {
let mut link = None;
'path_loop: for within_model_possible_path in possible_paths {
let possible_path = within_model_possible_path.get_inner();
// We want to check if the clean path result is a valid path and get the canonical
// absolute path back.
let absolute_path = absolute_path_if_valid(
&possible_path.path,
ShellPathType::ShellNative(working_directory.to_string()),
shell_launch_data.as_ref(),
);
if let Some(absolute_path) = absolute_path {
link = Some(Self::create_valid_link(
absolute_path,
possible_path.path.line_and_column_num,
possible_path.range.clone(),
&within_model_possible_path,
));
break;
}
for prefix in PREFIXES_TO_REMOVE {
if let Some(new_possible_path) = possible_path.path.path.strip_prefix(prefix) {
let new_possible_cleaned_path = CleanPathResult {
path: new_possible_path.into(),
line_and_column_num: possible_path.path.line_and_column_num,
};
let absolute_path = absolute_path_if_valid(
&new_possible_cleaned_path,
ShellPathType::ShellNative(working_directory.to_string()),
shell_launch_data.as_ref(),
);
// check if new_possible_path is valid
if let Some(absolute_path) = absolute_path {
let new_start_point = possible_path
.range
.start()
.wrapping_add(max_columns, prefix.len());
link = Some(Self::create_valid_link(
absolute_path,
new_possible_cleaned_path.line_and_column_num,
new_start_point..=*possible_path.range.end(),
&within_model_possible_path,
));
// break outer_loop
break 'path_loop;
}
}
}
for suffix in SUFFIXES_TO_REMOVE {
if let Some(new_possible_path) = possible_path.path.path.strip_suffix(suffix) {
let new_possible_cleaned_path = CleanPathResult {
path: new_possible_path.into(),
line_and_column_num: possible_path.path.line_and_column_num,
};
let absolute_path = absolute_path_if_valid(
&new_possible_cleaned_path,
ShellPathType::ShellNative(working_directory.to_string()),
shell_launch_data.as_ref(),
);
// check if new_possible_path is valid
if let Some(absolute_path) = absolute_path {
let new_end_point = possible_path
.range
.end()
.wrapping_sub(max_columns, suffix.len());
link = Some(Self::create_valid_link(
absolute_path,
new_possible_cleaned_path.line_and_column_num,
*possible_path.range.start()..=new_end_point,
&within_model_possible_path,
));
// break outer_loop
break 'path_loop;
}
}
}
}
link.map(GridHighlightedLink::File)
}
fn create_valid_link(
absolute_path: PathBuf,
line_and_column_num: Option<LineAndColumnArg>,
path_range: std::ops::RangeInclusive<Point>,
possible_path: &WithinModel<grid_handler::PossiblePath>,
) -> WithinModel<FileLink> {
let inner_link = FileLink {
link: Link {
range: path_range,
is_empty: false,
},
absolute_path,
line_and_column_num,
};
match possible_path {
WithinModel::AltScreen(_) => WithinModel::AltScreen(inner_link),
WithinModel::BlockList(inner) => {
WithinModel::BlockList(WithinBlock::new(inner_link, inner.block_index, inner.grid))
}
}
}
fn handle_file_link_completed(
&mut self,
link_result: Option<GridHighlightedLink>,
ctx: &mut ViewContext<Self>,
) {
let mut model = self.model.lock();
if self.highlighted_link.take(&mut model).is_some() {
ctx.reset_cursor();
ctx.notify();
}
if let Some(new_link) = link_result {
self.highlighted_link.set(new_link, &mut model);
ctx.set_cursor_shape(Cursor::PointingHand);
ctx.notify();
}
}
}