Copy selected file paths as plain text in the TUI (#49564)

## Why

Copying a rendered local file target such as `repo/foo_bar!` should preserve the selected path without adding Markdown escapes.

## What changed

Treat selections contained within rendered file targets as plain text, including partial selections and targets in tables. Coalesce adjacent file targets and inline code into literal ranges while preserving Markdown escaping when the selection includes surrounding content.

## Testing

Add regression coverage for partial paths, adjacent literal spans, styled links, table cells, special characters, and mixed selections at narrow and wide rendering widths.

GitOrigin-RevId: 94bcdacf14b013f9198cb84adfdb237eb644dd48
This commit is contained in:
Felipe Coury
2026-09-30 10:21:00 +00:00
committed by copyberry
parent 2e5fea64ee
commit 0b43721d8d
5 changed files with 85 additions and 9 deletions
+26 -8
View File
@@ -26,6 +26,8 @@ pub(crate) enum Inline {
Link(Arc<str>),
Delimiter(&'static str),
Code,
/// Copy a generated file target literally without changing mixed-selection Markdown.
Literal,
/// Balance parser events for links whose visible text has no copied wrapper.
Ignored,
}
@@ -66,6 +68,7 @@ pub(crate) struct CopyLine {
/// A visual separator inserted between sibling list items, never source content.
pub(crate) omit: bool,
runs: Vec<(Range<usize>, Vec<Inline>)>,
literal_ranges: Vec<Range<usize>>,
}
impl CopyLine {
@@ -73,9 +76,13 @@ impl CopyLine {
if length == 0 {
return;
}
let mut inline = inline[..inline.len().min(MAX_COPY_DEPTH)]
let inline = &inline[..inline.len().min(MAX_COPY_DEPTH)];
let literal = inline
.iter()
.filter(|mark| **mark != Inline::Ignored)
.any(|mark| matches!(mark, Inline::Code | Inline::Literal));
let mut inline = inline
.iter()
.filter(|mark| !matches!(mark, Inline::Ignored | Inline::Literal))
.cloned()
.collect::<Vec<_>>();
inline.sort();
@@ -84,6 +91,16 @@ impl CopyLine {
.runs
.last()
.map_or(/*default*/ 0, |(range, _)| range.end);
// Keep literal spans separate so they never split context-sensitive prose escaping.
if literal {
if let Some(range) = self.literal_ranges.last_mut()
&& range.end == start
{
range.end += length;
} else {
self.literal_ranges.push(start..start + length);
}
}
if let Some((range, previous)) = self.runs.last_mut()
&& previous == &inline
{
@@ -93,11 +110,12 @@ impl CopyLine {
}
}
fn is_inline_code(&self, range: &Range<usize>) -> bool {
fn is_literal(&self, range: &Range<usize>) -> bool {
!range.is_empty()
&& self.runs.iter().any(|(run, inline)| {
run.start <= range.start && range.end <= run.end && inline.contains(&Inline::Code)
})
&& self
.literal_ranges
.iter()
.any(|literal| literal.start <= range.start && range.end <= literal.end)
}
fn render(&self, text: &str, range: Range<usize>, depth: usize) -> String {
@@ -172,7 +190,7 @@ impl CopyLine {
.replace('\r', "%0D");
append_inline(&mut out, &format!("[{trimmed}](<{destination}>)"));
}
Inline::Code | Inline::Ignored => unreachable!(),
Inline::Code | Inline::Literal | Inline::Ignored => unreachable!(),
}
out.push_str(&body[body.trim_end().len()..]);
}
@@ -267,7 +285,7 @@ pub(crate) fn selection(lines: &[SelectedLine], plain: &str) -> (String, CopyFor
.source
.copy
.as_ref()
.is_some_and(|copy| copy.is_inline_code(&line.range))
.is_some_and(|copy| copy.is_literal(&line.range))
{
return (plain.to_owned(), CopyFormat::PlainText);
}
+1 -1
View File
@@ -167,7 +167,7 @@ pub(super) fn render(
&& source
.copy
.as_ref()
.is_some_and(|copy| copy.is_inline_code(&source.range))
.is_some_and(|copy| copy.is_literal(&source.range))
{
return (
source.text[source.range.clone()].to_owned(),
+4
View File
@@ -2211,7 +2211,9 @@ impl<'a, 'policy> Writer<'a, 'policy> {
}
self.push_span_to_table_cell(" (".into());
}
self.copy_inline.push(crate::markdown_copy::Inline::Literal);
self.push_span_to_table_cell(span);
self.copy_inline.pop();
if show_label {
self.push_span_to_table_cell(")".into());
}
@@ -2231,7 +2233,9 @@ impl<'a, 'policy> Writer<'a, 'policy> {
}
self.push_span(" (".into());
}
self.copy_inline.push(crate::markdown_copy::Inline::Literal);
self.push_span(span);
self.copy_inline.pop();
if show_label {
self.push_span(")".into());
}
@@ -128,6 +128,23 @@ fn inline_code_selection_copies_only_selected_content() {
("before ``a`b`` after", "a`"),
("- **`café界_*`**", "café界_*"),
("before ` leading and trailing ` after", " leading"),
(r"before ``real\_literal\!`` after", r"real\_literal\!"),
("before [`foo_bar!`](/repo/foo_bar!) after", "repo/foo_bar!"),
("before [`foo_bar!`](/repo/foo_bar!) after", "bar!"),
(
"[foo_bar!](/foo_bar!)[baz_qux!](/baz_qux!)",
"foo_bar!baz_qux!",
),
("`foo_bar!`[baz_qux!](/baz_qux!)", "foo_bar!baz_qux!"),
("before **[file](/repo/foo_bar!)** after", "foo_bar!"),
(
"| File |\n|---|\n| [`foo_bar!`](/repo/foo_bar!) |",
"foo_bar!",
),
(
"before [file](</repo/%60%2A_%5B%5D%3C%3E~%23%7C%26!%3D-+.)>) after",
"repo/`*_[]<>~#|&!=-+.)",
),
] {
for width in [18, 80] {
let layout = markdown_layout(source, width);
@@ -146,6 +163,25 @@ fn inline_code_selection_copies_only_selected_content() {
insta::assert_snapshot!(copies.join("\n"));
}
#[test]
fn mixed_file_targets_preserve_markdown_escaping() {
for (source, expected) in [
("before [+](/+) after", "before + after"),
("before [1.)](</1.)>) after", "before 1.) after"),
(
"| File |\n|---|\n| [`foo_bar!`](/repo/foo_bar!) |",
"| File |\n|---|\n| repo/foo\\_bar\\! |",
),
] {
let layout = markdown_layout(source, /*width*/ 80);
assert_eq!(
payload(&layout, 0..layout.text().len()),
(expected.into(), CopyFormat::Markdown),
"{source}"
);
}
}
#[test]
fn inline_boundaries_preserve_selected_text_and_formatting() {
let mut copies = Vec::new();
@@ -16,3 +16,21 @@ Selected "a`" → ("a`", PlainText)
Selected "café界_*" → ("café界_*", PlainText)
before ` leading and trailing ` after
Selected " leading" → (" leading", PlainText)
before ``real\_literal\!`` after
Selected "real\\_literal\\!" → ("real\\_literal\\!", PlainText)
before [`foo_bar!`](/repo/foo_bar!) after
Selected "repo/foo_bar!" → ("repo/foo_bar!", PlainText)
before [`foo_bar!`](/repo/foo_bar!) after
Selected "bar!" → ("bar!", PlainText)
[foo_bar!](/foo_bar!)[baz_qux!](/baz_qux!)
Selected "foo_bar!baz_qux!" → ("foo_bar!baz_qux!", PlainText)
`foo_bar!`[baz_qux!](/baz_qux!)
Selected "foo_bar!baz_qux!" → ("foo_bar!baz_qux!", PlainText)
before **[file](/repo/foo_bar!)** after
Selected "foo_bar!" → ("foo_bar!", PlainText)
| File |
|---|
| [`foo_bar!`](/repo/foo_bar!) |
Selected "foo_bar!" → ("foo_bar!", PlainText)
before [file](</repo/%60%2A_%5B%5D%3C%3E~%23%7C%26!%3D-+.)>) after
Selected "repo/`*_[]<>~#|&!=-+.)" → ("repo/`*_[]<>~#|&!=-+.)", PlainText)