Merge remote-tracking branch 'origin/main' into feat/daemon-workspace-tree

This commit is contained in:
thomas
2026-07-29 21:18:49 -07:00
4 changed files with 211 additions and 109 deletions
+26 -9
View File
@@ -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;
}
}
+1 -1
View File
@@ -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,
+129 -7
View File
@@ -91,7 +91,7 @@ pub(super) fn grid_smart_range<T: EventListener>(
});
}
let (text, points, click_idx) = logical_line_at(term, click)?;
let (text, points, click_idx) = logical_line_at(term, click, false)?;
let chars: Vec<char> = 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<T: EventListener>(term: &Term<T>, click: Point) -> Option<(Point, Point)> {
pub(super) fn hyperlink_run<T: EventListener>(
term: &Term<T>,
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<T: EventListener>(term: &Term<T>, 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<T: EventListener>(
///
/// 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<T: EventListener>(
term: &Term<T>,
click: Point,
bridge_hard_wrap: bool,
) -> Option<(String, Vec<Point>, usize)> {
let cols = term.columns();
if click.column.0 >= cols {
@@ -373,19 +384,46 @@ fn logical_line_at<T: EventListener>(
}
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
+55 -92
View File
@@ -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::<Config>().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::<Config>().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::<Config>().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<Self>) {
@@ -5608,63 +5575,60 @@ impl TerminalView {
include_files: bool,
include_loopback: bool,
) -> Option<HoveredLink> {
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.));