diff --git a/src/terminal/element.rs b/src/terminal/element.rs index 0e6f37a8..979f10ee 100644 --- a/src/terminal/element.rs +++ b/src/terminal/element.rs @@ -1273,15 +1273,32 @@ impl TerminalElement { let Some(link) = self.view.read(cx).hovered_link.as_ref() else { return; }; - let row = link.line + display_offset; - if row < 0 || row as usize >= rows { - return; - } - let row = row as usize; - let mut col = link.start; - while col <= link.end && col < cols { - buf[row * cols + col].link_hover = true; - col += 1; + // The link may span several rows — a soft wrap, or a URL a program + // split with a hard newline. Paint every covered cell: full columns on + // the interior rows, clamped to `start`/`end` on the first and last. + let (start, end) = (link.start, link.end); + let mut line = start.line.0; + while line <= end.line.0 { + let grid_row = line + display_offset; + if grid_row >= 0 && (grid_row as usize) < rows { + let grid_row = grid_row as usize; + let col_start = if line == start.line.0 { + start.column.0 + } else { + 0 + }; + let col_end = if line == end.line.0 { + end.column.0 + } else { + cols.saturating_sub(1) + }; + let mut col = col_start; + while col <= col_end && col < cols { + buf[grid_row * cols + col].link_hover = true; + col += 1; + } + } + line += 1; } } diff --git a/src/terminal/search.rs b/src/terminal/search.rs index b1fb1166..15787727 100644 --- a/src/terminal/search.rs +++ b/src/terminal/search.rs @@ -861,7 +861,7 @@ fn truncate_at_unbalanced_close(token: &mut String) { /// Whether `c` may appear inside a URL per RFC 3986 (unreserved + reserved + `%`). /// Every such character is ASCII, so any CJK character, full-width bracket, arrow or /// emoji is rejected — which is what lets a URL be cut off from trailing CJK prose. -fn is_url_char(c: char) -> bool { +pub(super) fn is_url_char(c: char) -> bool { c.is_ascii_alphanumeric() || matches!( c, diff --git a/src/terminal/smart_select.rs b/src/terminal/smart_select.rs index e36ab0f5..410eb26f 100644 --- a/src/terminal/smart_select.rs +++ b/src/terminal/smart_select.rs @@ -91,7 +91,7 @@ pub(super) fn grid_smart_range( }); } - let (text, points, click_idx) = logical_line_at(term, click)?; + let (text, points, click_idx) = logical_line_at(term, click, false)?; let chars: Vec = text.chars().collect(); let separators = term.semantic_escape_chars(); // A span whose flanks are separator chars ends exactly where alacritty's @@ -305,7 +305,10 @@ mod tokenizer { /// The contiguous run of cells carrying the same OSC 8 hyperlink URI as the /// clicked cell, following soft wraps in both directions (a long link wraps /// across rows; stopping at the row edge would truncate the selection). -fn hyperlink_run(term: &Term, click: Point) -> Option<(Point, Point)> { +pub(super) fn hyperlink_run( + term: &Term, + click: Point, +) -> Option<(Point, Point)> { let grid = term.grid(); let cols = term.columns(); if click.column.0 >= cols { @@ -363,9 +366,17 @@ fn hyperlink_run(term: &Term, click: Point) -> Option<(Poin /// the text with wide-char spacers dropped, a per-char grid point, and the /// char index the click landed on. `None` when the click maps to no char /// (out-of-bounds column). -fn logical_line_at( +/// +/// When `bridge_hard_wrap` is set, rows are also joined across a *producer* +/// hard newline (no `WRAPLINE` flag) when the row is filled to the right edge +/// with a link char that continues into the first column of the next row. This +/// lets link resolution recover a URL a printing program split with a literal +/// `\n`, while double-click smart-select (which passes `false`) keeps its +/// word/semantic boundaries and never glues separate output lines together. +pub(super) fn logical_line_at( term: &Term, click: Point, + bridge_hard_wrap: bool, ) -> Option<(String, Vec, usize)> { let cols = term.columns(); if click.column.0 >= cols { @@ -373,19 +384,46 @@ fn logical_line_at( } let grid = term.grid(); let last_col = Column(cols - 1); + let top = term.topmost_line(); + let bottom = term.bottommost_line(); let wraps = |line: Line| grid[line][last_col].flags.contains(Flags::WRAPLINE); + // A hard bridge joins `line` to `line + 1` when the row is full to the + // right edge with a link char and the next row opens with one too, which + // rules out gluing an ordinary short line onto the following paragraph. + // + // It cannot rule out the converse: a hard newline carries no signal about + // whether the producer split a URL, so a *complete* URL that happens to end + // exactly at the right edge is bridged onto whatever the next row starts + // with (`…/a` + `README.md` resolves as `…/aREADME.md`). There is no + // reliable test for that — the head of a genuinely split URL is itself a + // valid URL — so we accept the false positive: the address bar shows the + // mistake and the user is one glance from spotting it. + // + // What we do not accept is the same accident promoting the *second* row to + // the authority. `https://good.com` + `@evil.com/x` parses as userinfo, so + // the real host becomes `evil.com` while the underline still reads + // `good.com` — a phishing hop wearing a trusted label. Never bridge into + // one. + let is_link_char = |c: char| super::search::is_url_char(c); + let hard = |line: Line| { + // `line < bottom` must stay ahead of the `line + 1` lookup — the last + // grid line has no successor to index. + bridge_hard_wrap && line < bottom && is_link_char(grid[line][last_col].c) && { + let next = grid[Line(line.0 + 1)][Column(0)].c; + is_link_char(next) && next != '@' + } + }; + let continues = |line: Line| wraps(line) || hard(line); let mut start_line = click.line; - let top = term.topmost_line(); let mut guard = 0; - while start_line > top && guard < MAX_WRAP_ROWS && wraps(start_line - 1) { + while start_line > top && guard < MAX_WRAP_ROWS && continues(start_line - 1) { start_line -= 1; guard += 1; } let mut end_line = click.line; - let bottom = term.bottommost_line(); guard = 0; - while end_line < bottom && guard < MAX_WRAP_ROWS && wraps(end_line) { + while end_line < bottom && guard < MAX_WRAP_ROWS && continues(end_line) { end_line += 1; guard += 1; } @@ -770,6 +808,90 @@ mod tests { ); } + #[test] + fn hard_wrapped_url_is_bridged_only_for_links() { + // A printing program emitted a literal `\n` mid-URL: the head fills + // row 0 exactly (20 chars, no WRAPLINE flag) and the tail lands on + // row 1. Soft-wrap stitching can't see across this gap; the hard + // bridge in link mode joins them, while smart-select stays put. + let term = term_with(20, 4, "https://example.com/\r\ndeep/path/seg rest"); + // The break carries no WRAPLINE flag — this is a producer hard newline, + // not a terminal soft wrap. + assert!( + !term.grid()[Line(0)][Column(19)] + .flags + .contains(Flags::WRAPLINE), + "fixture must be a hard newline, not a soft wrap" + ); + + // Link mode (bridge_hard_wrap = true) recovers the whole URL spanning + // both rows. + let click = Point::new(Line(0), Column(3)); + let (text, _points, _idx) = + logical_line_at(&term, click, true).expect("logical line under click"); + let idx = text.find("https").expect("url in bridged line"); + let (_s, _e, url) = + crate::terminal::search::url_span_at(&text, idx + 2).expect("url span in bridged line"); + assert_eq!(url, "https://example.com/deep/path/seg"); + + // Smart-select mode (bridge_hard_wrap = false) must NOT glue the two + // output lines together. + let (text, _points, _idx) = + logical_line_at(&term, click, false).expect("logical line under click"); + assert!( + !text.contains("deep"), + "double-click must not bridge a hard newline: {text:?}" + ); + } + + #[test] + fn a_hard_break_before_userinfo_is_never_bridged() { + // Row 0 ends with a bare host that fills the row exactly, row 1 opens + // with `@`. Bridging would resolve `https://good.com@evil.com/x`, whose + // authority per RFC 3986 is `evil.com` — the underline would read + // `good.com` while the click navigated elsewhere. The hard bridge must + // refuse this one even though the row shape otherwise invites it. + let term = term_with(20, 4, "go1 https://good.com\r\n@evil.com/x rest"); + assert!( + !term.grid()[Line(0)][Column(19)] + .flags + .contains(Flags::WRAPLINE), + "fixture must be a hard newline, not a soft wrap" + ); + + let click = Point::new(Line(0), Column(8)); + let (text, _points, idx) = + logical_line_at(&term, click, true).expect("logical line under click"); + assert!( + !text.contains("evil"), + "a hard break before `@` must not bridge: {text:?}" + ); + let (_s, _e, url) = + crate::terminal::search::url_span_at(&text, idx).expect("url span under click"); + assert_eq!(url, "https://good.com"); + } + + #[test] + fn a_soft_wrap_before_userinfo_still_stitches() { + // The `@` guard is about the *ambiguity* of a hard newline. A soft wrap + // is the terminal folding one logical line, so the continuation is + // certain and a userinfo URL must still resolve whole. + let term = term_with(20, 4, "see https://user1234@ex.com/z rest"); + assert!( + term.grid()[Line(0)][Column(19)] + .flags + .contains(Flags::WRAPLINE), + "fixture must be a soft wrap, not a hard newline" + ); + + let click = Point::new(Line(0), Column(10)); + let (text, _points, idx) = + logical_line_at(&term, click, true).expect("logical line under click"); + let (_s, _e, url) = + crate::terminal::search::url_span_at(&text, idx).expect("url span under click"); + assert_eq!(url, "https://user1234@ex.com/z"); + } + #[test] fn wide_glyph_and_its_spacer_resolve_to_the_same_word() { // Each Han char occupies two cells; the second carries WIDE_CHAR_SPACER diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 2de258ff..f69d418d 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -544,14 +544,14 @@ pub struct TerminalView { } /// A link under the mouse, remembered so the grid can underline its cells. The -/// `line` is the alacritty grid line (display row minus the scroll offset), which -/// stays fixed as the viewport scrolls; `start..=end` are the inclusive columns -/// the link's text spans on that line. -#[derive(Clone, PartialEq)] +/// endpoints are alacritty grid points (line = display row minus the scroll +/// offset), which stay fixed as the viewport scrolls. A link the terminal +/// wrapped — or a producer split across rows with a hard newline — spans +/// several rows, so `start` and `end` can sit on different lines. +#[derive(Clone, Debug, PartialEq)] pub(super) struct HoveredLink { - pub line: i32, - pub start: usize, - pub end: usize, + pub start: Point, + pub end: Point, } enum LoopbackOpen { @@ -5410,56 +5410,23 @@ impl TerminalView { if !cx.global::().link_url { return false; } - let term = self.terminal.term.lock(); - let Some(line) = Self::grid_line(&term, row) else { + let include_loopback = self.can_forward_loopback(cx); + let Some((target, _start, _end)) = self.resolve_link_at(col, row, true, include_loopback) + else { return false; }; - let cols = term.columns(); - if col >= cols { - return false; - } - - // 1) Explicit OSC 8 hyperlink carried on the cell. - let cell = &term.grid()[line][Column(col)]; - if let Some(hl) = cell.hyperlink() { - let uri = hl.uri().to_string(); - drop(term); - self.open_url(&uri, window, cx); - return true; - } - - // 2) Fall back to detecting a bare URL or file path in the row's text. - let mut text = String::with_capacity(cols); - for c in 0..cols { - text.push(term.grid()[line][Column(c)].c); - } - drop(term); - // A relative path in the output is resolved against the cwd and - // stat-checked, then handed to the local file opener — so a remote - // pane's cwd must not be used. There, only absolute-looking local hits - // and URLs remain clickable. - let cwd = self.local_cwd(); - if let Some(link) = super::search::link_at(&text, col, cwd.as_deref(), true) { - match link.target { - LinkTarget::Url(url) => self.open_url(&url, window, cx), - LinkTarget::File { path, line, column } => { - // A configured template (e.g. opening the file in an editor) - // takes precedence; otherwise fall back to the OS opener. - match cx.global::().link_file_command.as_deref() { - Some(template) => run_file_command(template, &path, line, column), - None => open_file_path(&path), - } + match target { + LinkTarget::Url(url) => self.open_url(&url, window, cx), + LinkTarget::File { path, line, column } => { + // A configured template (e.g. opening the file in an editor) + // takes precedence; otherwise fall back to the OS opener. + match cx.global::().link_file_command.as_deref() { + Some(template) => run_file_command(template, &path, line, column), + None => open_file_path(&path), } } - true - } else if self.can_forward_loopback(cx) - && let Some((_, _, url)) = super::loopback::loopback_url_span_at(&text, col) - { - self.open_url(&url, window, cx); - true - } else { - false } + true } fn open_url(&self, url: &str, window: &mut Window, cx: &mut Context) { @@ -5608,63 +5575,60 @@ impl TerminalView { include_files: bool, include_loopback: bool, ) -> Option { + self.resolve_link_at(col, row, include_files, include_loopback) + .map(|(_, start, end)| HoveredLink { start, end }) + } + + /// The link under screen cell `(col, row)` and the inclusive grid points it + /// spans, shared by hover-underline and click-to-open so both agree on the + /// extent. Resolution runs over the *logical* line — soft-wrapped rows plus + /// producer hard newlines are stitched back together — so a URL split across + /// rows resolves whole instead of stopping at the first row edge. + fn resolve_link_at( + &self, + col: usize, + row: usize, + include_files: bool, + include_loopback: bool, + ) -> Option<(LinkTarget, Point, Point)> { let term = self.terminal.term.lock(); let line = Self::grid_line(&term, row)?; let cols = term.columns(); if col >= cols { return None; } + let click = Point::new(line, Column(col)); - // 1) Explicit OSC 8 hyperlink: highlight the whole contiguous run carrying - // the same URI, which may be wider than the visible link text. + // 1) Explicit OSC 8 hyperlink: highlight the whole contiguous run + // carrying the same URI, following soft wraps across rows. if let Some(hl) = term.grid()[line][Column(col)].hyperlink() { let uri = hl.uri().to_string(); - let same = |c: usize| { - term.grid()[line][Column(c)] - .hyperlink() - .is_some_and(|h| h.uri() == uri) - }; - let mut start = col; - while start > 0 && same(start - 1) { - start -= 1; + if let Some((start, end)) = super::smart_select::hyperlink_run(&term, click) { + return Some((LinkTarget::Url(uri), start, end)); } - let mut end = col; - while end + 1 < cols && same(end + 1) { - end += 1; - } - return Some(HoveredLink { - line: line.0, - start, - end, - }); } - // 2) Bare URL or file path detected in the row's text. - let mut text = String::with_capacity(cols); - for c in 0..cols { - text.push(term.grid()[line][Column(c)].c); - } + // 2) Bare URL or file path detected in the logical line. `bridge_hard_wrap` + // is on so a URL a program printed with a literal `\n` mid-way is + // recovered whole, not truncated at the break. + let (text, points, click_idx) = super::smart_select::logical_line_at(&term, click, true)?; drop(term); - // Same gate as the click path above — hover must not underline a link - // the click cannot open. + // Same gate as the click path — a relative path is resolved against the + // cwd and stat-checked, so a remote pane's cwd must not be used. let cwd = self.local_cwd(); - let link = - super::search::link_at(&text, col, cwd.as_deref(), include_files).or_else(|| { + let link = super::search::link_at(&text, click_idx, cwd.as_deref(), include_files) + .or_else(|| { include_loopback.then(|| { - super::loopback::loopback_url_span_at(&text, col).map(|(start, end, url)| { - super::search::LinkMatch { + super::loopback::loopback_url_span_at(&text, click_idx).map( + |(start, end, url)| super::search::LinkMatch { start, end, target: LinkTarget::Url(url), - } - }) + }, + ) })? })?; - Some(HoveredLink { - line: line.0, - start: link.start, - end: link.end, - }) + Some((link.target, points[link.start], points[link.end])) } /// The inline command line, anchored right where the shell prompt @@ -8300,9 +8264,8 @@ mod gpui_tests { view.hover_link_at(0, 23, true, cx); assert_eq!(view.last_hover_cell, Some((0, 23))); view.hovered_link = Some(HoveredLink { - line: 23, - start: 0, - end: 3, + start: Point::new(Line(23), Column(0)), + end: Point::new(Line(23), Column(3)), }); // The same geometry again changes nothing... view.set_grid_size(80, 24, px(8.), px(17.));