1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
//! Ctrl+Click / Ctrl+hover to open file paths printed in the embedded terminal.
//!
//! When the focused terminal is in live mode (not an alternate-screen program
//! like vim), a Ctrl+Click on text that resolves to a real file opens it in
//! Fresh, jumping to any `:line:col` the text encoded. Ctrl+hover underlines
//! such a path to signal it's clickable.
//!
//! Relative paths are resolved against, in order: the terminal's working
//! directory as reported via OSC 7 (tracks `cd`), then Fresh's working
//! directory. Resolution and the existence check go through the editor's
//! [`FileSystem`] so it works transparently on remote (SSH) hosts.
//!
//! [`FileSystem`]: crate::model::filesystem::FileSystem
use crate::app::Editor;
use crate::primitives::path_utils::expand_tilde;
use anyhow::Result as AnyhowResult;
use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use std::path::{Path, PathBuf};
impl Editor {
/// If this is a Ctrl+Left-click over a resolvable path in the live
/// terminal, open the file and return `Some(Ok(true))` (event handled).
/// Returns `None` to let normal mouse handling proceed.
pub(crate) fn try_open_terminal_link(
&mut self,
col: u16,
row: u16,
mouse_event: MouseEvent,
) -> Option<AnyhowResult<bool>> {
if !matches!(mouse_event.kind, MouseEventKind::Down(MouseButton::Left))
|| !mouse_event.modifiers.contains(KeyModifiers::CONTROL)
{
return None;
}
// Try the live grid first, then the scrollback buffer view (only one
// of the two is showing for any given terminal).
let (link, term_cwd) = self
.active_window()
.detect_terminal_link_at(col, row)
.map(|(_, _, link, cwd)| (link, cwd))
.or_else(|| {
self.active_window()
.detect_terminal_scrollback_link_at(col, row)
.map(|(_, link, cwd)| (link, cwd))
})?;
let resolved = self.resolve_terminal_path(&link.path, term_cwd.as_deref())?;
// Clear the hover highlight now that we're acting on it.
self.active_window_mut().terminal_link_hover = None;
Some(
self.handle_open_file_at_location(resolved, link.line, link.column)
.map(|()| true),
)
}
/// Update the Ctrl+hover path-link highlight for the live terminal.
///
/// Returns true if the highlighted span changed (a re-render is needed).
/// The highlight is shown only while Ctrl is held over a path that
/// resolves to a real file.
pub(crate) fn update_terminal_link_hover(
&mut self,
col: u16,
row: u16,
modifiers: KeyModifiers,
) -> bool {
let new_hover = if modifiers.contains(KeyModifiers::CONTROL) {
self.compute_terminal_link_hover(col, row)
} else {
None
};
if self.active_window().terminal_link_hover != new_hover {
self.active_window_mut().terminal_link_hover = new_hover;
true
} else {
false
}
}
/// Compute the hover highlight (buffer + grid row + column span) for a path
/// link at the given screen position, if it resolves to a real file.
fn compute_terminal_link_hover(
&self,
col: u16,
row: u16,
) -> Option<crate::app::window::TerminalLinkHover> {
let (buffer_id, term_row, link, term_cwd) =
self.active_window().detect_terminal_link_at(col, row)?;
// Only highlight paths that actually resolve — otherwise the underline
// would promise a link that clicking can't honor.
self.resolve_terminal_path(&link.path, term_cwd.as_deref())?;
Some(crate::app::window::TerminalLinkHover {
buffer_id,
row: term_row,
cols: link.range,
})
}
/// Resolve a path printed by a terminal program to an existing file.
///
/// Tries, in order: the path as-is if absolute (after `~` expansion), then
/// joined against the terminal's OSC 7 cwd, then against Fresh's working
/// directory. Returns the first candidate that exists and is a regular
/// file. Directories and non-existent paths yield `None` (so the link is
/// inert).
fn resolve_terminal_path(&self, raw: &str, term_cwd: Option<&Path>) -> Option<PathBuf> {
let expanded = expand_tilde(raw);
let candidates: Vec<PathBuf> = if expanded.is_absolute() {
vec![expanded]
} else {
let mut v = Vec::new();
if let Some(cwd) = term_cwd {
v.push(cwd.join(&expanded));
}
v.push(self.working_dir().join(&expanded));
v
};
let fs = &self.authority().filesystem;
candidates
.into_iter()
.find(|p| fs.is_file(p).unwrap_or(false))
}
}