use crate::theme;
use ratatui::buffer::{Buffer, Cell};
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier};
use std::path::Path;
pub fn link_paths_in_buffer(buf: &mut Buffer, area: Rect, project_root: &Path) {
for y in area.top()..area.bottom() {
link_paths_in_row(buf, area, y, project_root);
}
}
fn link_paths_in_row(buf: &mut Buffer, area: Rect, y: u16, project_root: &Path) {
let mut x = area.left();
while x < area.right() {
if !is_path_cell(&buf[(x, y)]) {
x += 1;
continue;
}
let start = x;
let mut text = String::new();
while x < area.right() && is_path_cell(&buf[(x, y)]) {
text.push_str(buf[(x, y)].symbol());
x += 1;
}
let end = x;
let uri = match resolve_uri(text.trim(), project_root) {
Some(u) => u,
None => continue,
};
wrap_run_with_osc8(buf, y, start, end, &uri);
}
}
fn is_path_cell(cell: &Cell) -> bool {
cell.fg == theme::PATH.fg.unwrap_or(Color::Reset)
&& cell.modifier.contains(Modifier::UNDERLINED)
}
fn resolve_uri(text: &str, project_root: &Path) -> Option<String> {
if text.is_empty() {
return None;
}
if text.starts_with("http://") || text.starts_with("https://") {
return Some(escape_url(text));
}
let abs: std::path::PathBuf = if text.starts_with('/') {
Path::new(text).to_path_buf()
} else {
project_root.join(text)
};
let abs_str = abs.to_string_lossy();
let mut uri = String::with_capacity(abs_str.len() + 8);
uri.push_str("file://");
if !abs_str.starts_with('/') {
uri.push('/');
}
for ch in abs_str.chars() {
match ch {
' ' => uri.push_str("%20"),
'(' => uri.push_str("%28"),
')' => uri.push_str("%29"),
'[' => uri.push_str("%5B"),
']' => uri.push_str("%5D"),
_ => uri.push(ch),
}
}
Some(escape_url(&uri))
}
fn escape_url(raw: &str) -> String {
let cleaned: String = raw
.chars()
.filter(|&c| c != '\x1B' && c != '\x07')
.collect();
if cleaned.len() != raw.len() {
tracing::debug!(
stripped = raw.len() - cleaned.len(),
"escape_url: stripped ESC/BEL bytes from URI"
);
}
cleaned
}
fn wrap_run_with_osc8(buf: &mut Buffer, y: u16, start: u16, end: u16, uri: &str) {
for x in start..end {
let cell = &mut buf[(x, y)];
let sym = cell.symbol().to_string();
if sym.is_empty() {
continue;
}
cell.set_symbol(&format!("\x1B]8;;{uri}\x07{sym}\x1B]8;;\x07"));
}
}
#[cfg(test)]
pub fn strip_osc8(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\x1B' && chars.peek() == Some(&']') {
chars.next();
for c in chars.by_ref() {
if c == '\x07' {
break;
}
}
continue;
}
out.push(ch);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme;
use ratatui::style::Style;
fn buffer_with_styled_text(text: &str, style: Style) -> (Buffer, Rect) {
let area = Rect::new(0, 0, text.chars().count() as u16, 1);
let mut buf = Buffer::empty(area);
for (i, ch) in text.chars().enumerate() {
let x = i as u16;
buf[(x, 0)].set_symbol(&ch.to_string());
buf[(x, 0)].set_style(style);
}
(buf, area)
}
fn row_symbols(buf: &Buffer, area: Rect) -> String {
let mut out = String::new();
for x in area.left()..area.right() {
out.push_str(buf[(x, 0)].symbol());
}
out
}
#[test]
fn relative_path_resolves_under_project_root() {
let (mut buf, area) = buffer_with_styled_text("src/main.rs", theme::PATH);
link_paths_in_buffer(&mut buf, area, Path::new("/home/me/proj"));
let raw = row_symbols(&buf, area);
assert!(
raw.contains("\x1B]8;;file:///home/me/proj/src/main.rs\x07"),
"expected OSC 8 with absolute file URI, got: {raw:?}"
);
assert_eq!(strip_osc8(&raw), "src/main.rs");
}
#[test]
fn absolute_path_passes_through_root() {
let (mut buf, area) = buffer_with_styled_text("/etc/hosts", theme::PATH);
link_paths_in_buffer(&mut buf, area, Path::new("/proj"));
let raw = row_symbols(&buf, area);
assert!(raw.contains("\x1B]8;;file:///etc/hosts\x07"), "{raw:?}");
}
#[test]
fn http_url_passes_through_unchanged() {
let (mut buf, area) = buffer_with_styled_text("https://example.com/x", theme::PATH);
link_paths_in_buffer(&mut buf, area, Path::new("/proj"));
let raw = row_symbols(&buf, area);
assert!(
raw.contains("\x1B]8;;https://example.com/x\x07"),
"URL should be linked verbatim, got: {raw:?}"
);
}
#[test]
fn unstyled_cells_are_left_alone() {
let style = Style::default();
let (mut buf, area) = buffer_with_styled_text("nothing here", style);
link_paths_in_buffer(&mut buf, area, Path::new("/proj"));
let raw = row_symbols(&buf, area);
assert_eq!(raw, "nothing here");
assert!(!raw.contains('\x1B'));
}
#[test]
fn cyan_without_underline_is_not_linked() {
let style = Style::new().fg(Color::Cyan);
let (mut buf, area) = buffer_with_styled_text("not a path", style);
link_paths_in_buffer(&mut buf, area, Path::new("/proj"));
let raw = row_symbols(&buf, area);
assert!(!raw.contains('\x1B'), "got: {raw:?}");
}
#[test]
fn underline_without_cyan_is_not_linked() {
let style = Style::new().add_modifier(Modifier::UNDERLINED);
let (mut buf, area) = buffer_with_styled_text("emphasized", style);
link_paths_in_buffer(&mut buf, area, Path::new("/proj"));
let raw = row_symbols(&buf, area);
assert!(!raw.contains('\x1B'), "got: {raw:?}");
}
#[test]
fn escape_injection_in_path_text_is_neutered() {
let nasty = "a\x1Bb"; let (mut buf, area) = buffer_with_styled_text(nasty, theme::PATH);
link_paths_in_buffer(&mut buf, area, Path::new("/proj"));
let raw = row_symbols(&buf, area);
assert!(
!raw.contains("\x1Bb"),
"raw ESC byte must not survive in the URI payload: {raw:?}"
);
}
#[test]
fn empty_run_is_skipped() {
let (mut buf, area) = buffer_with_styled_text(" ", theme::PATH);
link_paths_in_buffer(&mut buf, area, Path::new("/proj"));
let raw = row_symbols(&buf, area);
assert!(!raw.contains('\x1B'), "got: {raw:?}");
}
#[test]
fn two_separate_paths_in_one_row_are_linked_independently() {
let area = Rect::new(0, 0, 10, 1);
let mut buf = Buffer::empty(area);
let path_style = theme::PATH;
let plain = Style::default();
for (i, ch) in "a.rs".chars().enumerate() {
buf[(i as u16, 0)].set_symbol(&ch.to_string());
buf[(i as u16, 0)].set_style(path_style);
}
for (i, ch) in " ".chars().enumerate() {
let x = (4 + i) as u16;
buf[(x, 0)].set_symbol(&ch.to_string());
buf[(x, 0)].set_style(plain);
}
for (i, ch) in "b.rs".chars().enumerate() {
let x = (6 + i) as u16;
buf[(x, 0)].set_symbol(&ch.to_string());
buf[(x, 0)].set_style(path_style);
}
link_paths_in_buffer(&mut buf, area, Path::new("/p"));
let raw = row_symbols(&buf, area);
assert!(raw.contains("\x1B]8;;file:///p/a.rs\x07"), "{raw:?}");
assert!(raw.contains("\x1B]8;;file:///p/b.rs\x07"), "{raw:?}");
assert_eq!(strip_osc8(&raw), "a.rs b.rs");
}
#[test]
fn space_in_path_is_percent_encoded() {
let (mut buf, area) = buffer_with_styled_text("My File.rs", theme::PATH);
link_paths_in_buffer(&mut buf, area, Path::new("/proj"));
let raw = row_symbols(&buf, area);
assert!(
raw.contains("file:///proj/My%20File.rs"),
"spaces must be percent-encoded, got: {raw:?}"
);
}
#[test]
fn strip_osc8_removes_open_and_close() {
let s = "\x1B]8;;file:///x\x07hello\x1B]8;;\x07";
assert_eq!(strip_osc8(s), "hello");
}
#[test]
fn escape_url_strips_esc_and_bel() {
assert_eq!(escape_url("a\x1Bb\x07c"), "abc");
}
#[test]
fn end_to_end_paragraph_then_hook_links_styled_span() {
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Widget};
let area = Rect::new(0, 0, 30, 1);
let mut buf = Buffer::empty(area);
let line = Line::from(vec![
Span::raw("\u{25cf} Read "),
Span::styled("src/main.rs", theme::PATH),
]);
Paragraph::new(line).render(area, &mut buf);
link_paths_in_buffer(&mut buf, area, Path::new("/proj"));
let mut raw = String::new();
for x in 0..30 {
raw.push_str(buf[(x, 0)].symbol());
}
assert!(
raw.contains("\x1B]8;;file:///proj/src/main.rs\x07"),
"end-to-end OSC 8 missing, got: {raw:?}"
);
let visible = strip_osc8(&raw);
assert!(visible.contains("\u{25cf} Read src/main.rs"));
}
}