mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
+18
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user